CVE-2023-35946 (published 2023-06-30T21:15Z)

Gradle is a build tool with a focus on build automation and support for multi-language development. When Gradle writes a dependency into its dependency cache, it uses the dependency's coordinates to compute a file location. With specially crafted dependency coordinates, Gradle can be made to write files into an unintended location. The file may be written outside the dependency cache or over another file in the dependency cache. This vulnerability could be used to poison the dependency cache or overwrite important files elsewhere on the filesystem where the Gradle process has write permissions. Exploiting this vulnerability requires an attacker to have control over a dependency repository used by the Gradle build or have the ability to modify the build's configuration. It is unlikely that this would go unnoticed. A fix has been released in Gradle 7.6.2 and 8.2 to protect against this vulnerability. Gradle will refuse to cache dependencies that have path traversal elements in their dependency coordinates. It is recommended that users upgrade to a patched version. If you are unable to upgrade to Gradle 7.6.2 or 8.2, `dependency verification` will make this vulnerability more difficult to exploit.

CVSS v3 base score: 5.5, MEDIUM (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N)

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/gradle/gradle
commit_details: repo_url: https://github.com/gradle/gradle
author: Louis Jacomet
author_date: 2023-06-15 17:10:18+02:00
msg: Fix dependency cache path traversal vulnerability

Gradle leverages the protection added to prevent ZipSlip for any path
computation inside a Gradle cache.

See https://github.com/gradle/gradle/security/advisories/GHSA-2h6c-rv6q-494v
repository: repo_url: https://github.com/gradle/gradle
repo_name: gradle/gradle
description: Adaptable, fast automation for all
repo_language: Groovy
owner: gradle
file_changes: filename: DefaultPathKeyFileStore.java
change_type: ModificationType.MODIFY
diff: @@ -27,11 +27,15 @@
 import org.gradle.internal.hash.ChecksumService;
 import org.gradle.util.internal.GFileUtils;
 import org.gradle.util.internal.RelativePathUtil;
+import org.gradle.util.internal.ZipSlip;
 
 import java.io.File;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
 import static org.gradle.internal.FileUtils.hasExtension;
 
@@ -72,13 +76,19 @@ protected File getBaseDir() {
     }
 
     private File getFile(String... path) {
-        File result = baseDir;
-        for (String p : path) {
-            result = new File(result, p);
+        String composedPath;
+        if (path.length == 1) {
+            composedPath = path[0];
+        } else {
+            // We need to ignore empty Strings as this is what "new File(parent, path)" was doing for "path" empty.
+            composedPath = Arrays.stream(path)
+                .filter(((Predicate<String>) String::isEmpty).negate())
+                .collect(Collectors.joining(File.separator));
         }
-        return result;
+        return new File(baseDir, ZipSlip.safeZipEntryName(trimLeadingSlash(composedPath)));
     }
 
+
     private File getFileWhileCleaningInProgress(String... path) {
         File file = getFile(path);
         File markerFile = getInProgressMarkerFile(file);
@@ -212,4 +222,10 @@ public LocallyAvailableResource get(String... path) {
     private static void deleteFileQuietly(File file) {
         file.delete();
     }
-}
+
+    private static String trimLeadingSlash(String composedPath) {
+        if (!composedPath.isEmpty() && composedPath.charAt(0) == '/') {
+            return composedPath.substring(1);
+        }
+        return composedPath;
+    }}

programming_language: Java
filename: DefaultPathKeyFileStoreTest.groovy
change_type: ModificationType.MODIFY
diff: @@ -42,7 +42,8 @@ class DefaultPathKeyFileStoreTest extends Specification {
         def b = createFile("def")
 
         when:
-        store.move("a", a)
+        // leading slash does not mean absolute path
+        store.move("/a", a)
         store.move("b", b)
 
         then:

programming_language: Scala
filename: CacheResolveIntegrationTest.groovy
change_type: ModificationType.MODIFY
diff: @@ -20,6 +20,8 @@ import org.gradle.cache.internal.scopes.DefaultCacheScopeMapping
 import org.gradle.integtests.fixtures.AbstractHttpDependencyResolutionTest
 import org.gradle.integtests.fixtures.ToBeFixedForConfigurationCache
 import org.gradle.integtests.fixtures.cache.CachingIntegrationFixture
+import org.gradle.internal.hash.Hashing
+import org.gradle.test.fixtures.file.TestFile
 
 import java.nio.file.Files
 
@@ -153,6 +155,155 @@ task listJars {
         succeeds('listJars')
     }
 
+    def 'cannot write cache entries outside of GAV'() {
+        given:
+        def fakeDep = temporaryFolder.testDirectory.file('fake-repo/pwned.txt')
+        fakeDep << """
+Hello world!
+"""
+        def hash = Hashing.sha1().hashFile(fakeDep).toString()
+        def hashOfBootJar = '1234' // for demo purpose
+        def invalidPath = "org.spring/core/1.0/$hash/artifact-1.0./../../../../boot/2.0/$hashOfBootJar/pwned.txt"
+        def invalidLocation = executer.gradleUserHomeDir.file(cachePath + invalidPath).canonicalFile
+
+        server.allowGetOrHead("/repo/org/boot/2.0/$hashOfBootJar/pwned.txt", fakeDep)
+
+        and:
+        withValidJavaSource()
+        buildWithJavaLibraryAndMavenRepoArtifactOnly()
+
+        and:
+        buildFile << """
+dependencies { implementation 'org.spring:core:1.0@/../../../../boot/2.0/$hashOfBootJar/pwned.txt' }
+"""
+
+        when:
+        fails('compileJava')
+
+        then:
+        failureCauseContains('is not a safe zip entry name')
+        // If the build did not fail, Gradle would effectively write a file inside org.spring/boot/2.0 instead of inside org.spring/core/1.0
+        // If we have the real hash of a JAR in those other coordinates, Gradle could overwrite and replace the real JAR with a malicious one
+        !invalidLocation.exists()
+    }
+
+    def 'cannot write cache entries outside of dependency cache'() {
+        given:
+        def fakeDep = temporaryFolder.testDirectory.file('fake-repo/pwned.txt')
+        fakeDep << """
+Hello world!
+"""
+        // Code block used to verify what happens if the build succeeds
+        def hash = Hashing.sha1().hashFile(fakeDep).toString()
+        def invalidPath = "org.spring/../../../../../core/1.0/$hash/artifact-1.0./../../../../.ssh/pwned.txt"
+        def invalidLocation = executer.gradleUserHomeDir.file(cachePath + invalidPath).canonicalFile
+
+        server.allowGetOrHead('/repo/org/.ssh/pwned.txt', fakeDep)
+
+        and:
+        withValidJavaSource()
+        buildWithJavaLibraryAndMavenRepoArtifactOnly()
+
+        and:
+        buildFile << """
+dependencies { implementation 'org.spring/../../../../../:core:1.0@/../../../../.ssh/pwned.txt' }
+"""
+
+        when:
+        fails('compileJava')
+
+        then:
+        failureCauseContains('is not a safe zip entry name')
+        // If the build did not fail, Gradle would effectively write a file inside a folder that is a sibling to the Gradle User Home
+        // If this was ~/.gradle, Gradle would have written in ~/.ssh
+        !invalidLocation.exists()
+    }
+
+    def 'cannot write cache entries anywhere on disk using metadata'() {
+        given:
+        // Our crafty coordinates
+        def pwnedDep = mavenRepo.module('org.spring/../../../../../', 'core')
+        // Our abused coordinates that will see a POM request
+        def abusedCoordinates = mavenHttpRepo.module('org.spring', 'core', '1.0').publish()
+        // Defeat the Gradle validation that will verify metadata content match requested coordinates
+        abusedCoordinates.pom.file.replace('<groupId>org.spring</groupId>', '<groupId>org.spring/../../../../../</groupId>')
+        // Our test dependency that now has a crafty dependency itself
+        def testDep = mavenHttpRepo.module('org.test', 'test').dependsOn(pwnedDep, type: '/../../../../.ssh/pwned.txt').publish()
+
+        def fakeDep = temporaryFolder.testDirectory.file('fake-repo/pwned.txt')
+        fakeDep << """
+Hello world!
+"""
+        def hash = Hashing.sha1().hashFile(fakeDep).toString()
+        def invalidPath = "org.spring/../../../../../core/1.0/$hash/artifact-1.0./../../../../.ssh/pwned.txt"
+        def invalidLocation = executer.gradleUserHomeDir.file(cachePath + invalidPath).canonicalFile
+
+        testDep.allowAll()
+        abusedCoordinates.allowAll()
+        server.allowGetOrHead('/repo/org/.ssh/pwned.txt', fakeDep)
+
+        and:
+        withValidJavaSource()
+        buildWithJavaLibraryAndMavenRepo()
+
+        and:
+        buildFile << """
+dependencies { implementation 'org.test:test:1.0' }
+"""
+
+        when:
+        fails('compileJava')
+
+        then:
+        failureCauseContains('is not a safe zip entry name')
+        // If the build did not fail, Gradle would effectively write a file inside a folder that is a sibling to the Gradle User Home
+        // If this was ~/.gradle, Gradle would have written in ~/.ssh
+        !invalidLocation.exists()
+    }
+
+    private String getCachePath() {
+        "caches/${CacheLayout.ROOT.key}/${CacheLayout.FILE_STORE.key}/"
+    }
+
+    private void buildWithJavaLibraryAndMavenRepoArtifactOnly() {
+        buildFile << """
+plugins {
+    id('java-library')
+}
+
+repositories {
+    maven {
+        url "${mavenHttpRepo.uri}"
+        metadataSources {
+            artifact()
+        }
+    }
+}
+"""
+    }
+
+    private void buildWithJavaLibraryAndMavenRepo() {
+        buildFile << """
+plugins {
+    id('java-library')
+}
+
+repositories {
+    maven {
+        url "${mavenHttpRepo.uri}"
+    }
+}
+"""
+    }
+
+    private TestFile withValidJavaSource() {
+        temporaryFolder.testDirectory.file('src/main/java/org/test/Base.java') << """
+package org.test;
+
+public class Base {}
+"""
+    }
+
     def relocateCachesAndChangeGradleHome() {
         def otherHome = executer.gradleUserHomeDir.parentFile.createDir('other-home')
         def otherCacheDir = otherHome.toPath().resolve(DefaultCacheScopeMapping.GLOBAL_CACHE_DIR_NAME)

programming_language: JavaScript
repo_url: https://github.com/gradle/gradle
commit_details: repo_url: https://github.com/gradle/gradle
author: Louis Jacomet
author_date: 2023-06-27 09:32:54+02:00
msg: Merge pull request from GHSA-2h6c-rv6q-494v

Fix dependency cache path traversal vulnerability
repository: repo_url: https://github.com/gradle/gradle
repo_name: gradle/gradle
description: Adaptable, fast automation for all
repo_language: Groovy
owner: gradle