CVE-2022-1537: Time-of-check Time-of-use (TOCTOU) Race Condition (CWE-367)
value: file.copy operations in GruntJS are vulnerable to a TOCTOU race condition leading to arbitrary file write in GitHub repository gruntjs/grunt prior to 1.5.3. This vulnerability is capable of arbitrary file writes which can lead to local privilege escalation to the GruntJS user if a lower-privileged user has write access to both source and destination directories as the lower-privileged user can create a symlink to the GruntJS user's .bashrc file or replace /etc/shadow file if the GruntJS user is root.
CWE description: The product checks the state of a resource before using that resource, but the resource's state can change between the check and the use in a way that invalidates the results of the check. This can cause the product to perform invalid actions when the resource is in an unexpected state.
CVSS v3 base score: 7
Published: 2022-05-10T14:15Z
Repository: https://github.com/gruntjs/grunt
Language: JavaScript
Files changed: CHANGELOG, lib/grunt/file.js, test/grunt/file_test.js
Commit date: 2022-05-10 07:54:40 -0400
Commit message: Patch up race condition in symlink copying.

Diff: diff --git a/CHANGELOG b/CHANGELOG
index bd1848ca..17bb4b7f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,9 @@
+v1.5.3
+  date: 2022-04-23
+  changes:
+    - Patch up race condition in symlink copying.
 v1.5.2
   date: 2022-04-12
   changes:
     - Unlink symlinks when copy destination is a symlink.
 v1.5.1
diff --git a/lib/grunt/file.js b/lib/grunt/file.js
index 604736cd..82863f35 100644
--- a/lib/grunt/file.js
+++ b/lib/grunt/file.js
@@ -292,15 +292,10 @@ file.write = function(filepath, contents, options) {
 // Read a file, optionally processing its content, then write the output.
 // Or read a directory, recursively creating directories, reading files,
 // processing content, writing output.
 // Handles symlinks by coping them as files or directories.
 file.copy = function copy(srcpath, destpath, options) {
-  if (file.isLink(destpath)) {
-    // in case destpath is a symlink, avoid following the symlink, instead overwrite it later
-    fs.unlinkSync(destpath);
-  }
-
   if (file.isLink(srcpath)) {
     file._copySymbolicLink(srcpath, destpath);
   } else if (file.isDir(srcpath)) {
     // Copy a directory, recursively.
     // Explicitly create new dest directory.
@@ -336,12 +331,12 @@ file._copy = function(srcpath, destpath, options) {
       grunt.verbose.error();
       throw grunt.util.error('Error while processing "' + srcpath + '" file.', e);
     }
   }
   // Abort copy if the process function returns false.
-  if (contents === false) {
-    grunt.verbose.writeln('Write aborted.');
+  if (contents === false || file.isLink(destpath)) {
+    grunt.verbose.writeln('Write aborted. Either the process function returned false or the destination is a symlink');
   } else {
     file.write(destpath, contents, readWriteOptions);
   }
 };
 
diff --git a/test/grunt/file_test.js b/test/grunt/file_test.js
index 0448f305..eafc5774 100644
--- a/test/grunt/file_test.js
+++ b/test/grunt/file_test.js
@@ -914,7 +914,15 @@ exports.file = {
       grunt.file.copy(symlinkSource, destSource);
       test.ok(fs.lstatSync(symlinkSource).isSymbolicLink());
       test.ok(fs.lstatSync(path.join(destdir.path, path.basename(fixtures))).isSymbolicLink());
       test.done();
     },
-  }
+  },
+  'symbolicLinkDestError': function(test) {
+    test.expect(1);
+    var tmpfile = new Tempdir();
+    fs.symlinkSync(path.resolve('test/fixtures/octocat.png'), path.join(tmpfile.path, 'octocat.png'), 'file');
+    grunt.file.copy(path.resolve('test/fixtures/octocat.png'), path.join(tmpfile.path, 'octocat.png'));
+    test.ok(fs.lstatSync(path.join(tmpfile.path, 'octocat.png')).isSymbolicLink());
+    test.done();
+  },
 };

Vulnerable code:   if (file.isLink(destpath)) {
    // in case destpath is a symlink, avoid following the symlink, instead overwrite it later
    fs.unlinkSync(destpath);
  }

  if (contents === false) {
    grunt.verbose.writeln('Write aborted.');
  }
Fixed code: v1.5.3
  date: 2022-04-23
  changes:
    - Patch up race condition in symlink copying.
  if (contents === false || file.isLink(destpath)) {
    grunt.verbose.writeln('Write aborted. Either the process function returned false or the destination is a symlink');
  },
  'symbolicLinkDestError': function(test) {
    test.expect(1);
    var tmpfile = new Tempdir();
    fs.symlinkSync(path.resolve('test/fixtures/octocat.png'), path.join(tmpfile.path, 'octocat.png'), 'file');
    grunt.file.copy(path.resolve('test/fixtures/octocat.png'), path.join(tmpfile.path, 'octocat.png'));
    test.ok(fs.lstatSync(path.join(tmpfile.path, 'octocat.png')).isSymbolicLink());
    test.done();
  },