all posts

Building untrusted Rust code in a microVM

Ajay Kumar··9 min read

Rust has a reputation for safety, and it's earned — at runtime. The place that reputation quietly evaporates is build time. When you run `cargo build` on a repository you didn't write — a dependency you're auditing, an AI-generated project, a contributor's PR, a crate an agent decided to pull — you are not compiling inert source into a safe binary. You are executing arbitrary code on your machine, right then, before your program runs a single line. `cargo build` on an untrusted crate is remote code execution with a friendly progress bar.

There are two specific mechanisms, and both are load-bearing Rust features, not exploits. This post is why they make untrusted builds dangerous and how to contain them: run the build inside a throwaway Firecracker microVM with no host credentials, egress locked down, and a hard time limit, so a malicious build script gets a disposable machine instead of your laptop, your CI runner's cloud role, or your `~/.cargo` token. I'm Ajay; I build PandaStack, a Firecracker microVM platform, and untrusted-build containment is exactly the kind of thing it exists to make routine.

The two ways a Rust build runs code

build.rs — a program that runs during the build

A crate can include a `build.rs` file: a build script that Cargo compiles and runs before compiling the crate itself. Its legitimate job is to generate code, probe the system, or link native libraries. But it's an ordinary Rust program with the full standard library, and Cargo runs it with your privileges, your environment, and your network. It can read your environment variables (including secrets CI helpfully injected), read files (your SSH keys, your cloud credentials, your `~/.cargo/credentials`), open sockets, and exfiltrate anything it finds. There is no sandbox around it by default. build.rs is just cargo asking 'may I run some code?' and answering itself yes.

Procedural macros — code that runs inside the compiler

The subtler vector. A procedural macro is a crate that runs during compilation, inside the `rustc` process, transforming token streams into code. If a crate you depend on uses a proc-macro — and half the ecosystem does, for derives and attributes — then compiling code that invokes that macro executes the macro's code on your machine. A malicious proc-macro doesn't wait for build.rs; it runs the moment the compiler expands it. So even a crate with no build.rs can execute arbitrary code at compile time through a proc-macro dependency several levels down. You can't audit your way out of this by reading the top-level Cargo.toml — the code that runs is buried in the transitive graph.

The trap is that Rust's safety story trains you to trust the build. `cargo build` feels like 'just compiling,' the way `tsc` or `javac` feel. It isn't: between build.rs and proc-macros, a compile runs code from every crate in your dependency graph, at your privilege level, before you've run anything yourself. Treat `cargo build` / `cargo test` on untrusted code as running that code, because that's what it is.

What a malicious build can reach

  • Your credentials. A build script or proc-macro reads process environment and the filesystem: cloud credentials, SSH keys, `~/.cargo/credentials` (your crates.io publish token), CI secrets injected into the job. On a CI runner, the environment is often a treasure chest and the cloud metadata endpoint (169.254.169.254) is one request away from the runner's role.
  • The network. Builds legitimately fetch — crates.io, git dependencies — so outbound network is usually open, which is exactly the channel a malicious script uses to exfiltrate what it read.
  • Your host, persistently. On a developer laptop or a reused CI runner, a build that drops a file or modifies a dotfile persists long after the build finishes. The compile is a moment; the backdoor is however long that machine lives.
  • Your compute, until it stops. A build.rs that spins forever, forks a miner, or allocates until OOM is a denial-of-service that costs you real money on a CI fleet — no data theft required, just an expensive infinite loop.

Build it in a throwaway microVM

The fix is to run the build somewhere disposable that holds nothing worth stealing and can't reach anything worth reaching. Spin up a fresh Firecracker microVM, drop the untrusted repo in, run `cargo build` or `cargo test` inside it, pull out the artifact you wanted (if any), and destroy the VM. The build's build scripts and proc-macros run behind a hardware-virtualization boundary in a machine with no `~/.cargo` token, no SSH keys, no cloud role, and no route to your metadata endpoint. When it turns hostile, it detonates in an empty room you were about to demolish.

The properties that matter: no host credentials in the guest, so there's nothing for build.rs to read and exfiltrate; egress allowlisted (or off) so even code that reads something can't send it — with builds, allow only the registry/git source you actually need, or go fully offline with vendored dependencies; a hard `ttl_seconds` plus an exec timeout so a build bomb gets reaped instead of pinning your fleet; and total teardown, so any payload the build dropped vanishes with the VM. And it's cheap: PandaStack creates by restoring a baked snapshot, so the restore step is around 49ms, an end-to-end create is p50 179ms and p99 about 203ms, and a true cold boot happens only on the first spawn of a template (around 3s).

from pandastack import Sandbox

def build_untrusted_crate(repo_tarball: bytes, job_id: str) -> dict:
    """Run `cargo build`/`cargo test` on an untrusted repo in its own microVM.
    build.rs and proc-macros execute behind a hardware boundary, with no host
    creds and locked-down egress -- then the VM is destroyed."""
    with Sandbox.create(
        template="base",                     # a language-agnostic build runtime
        ttl_seconds=1200,                    # backstop: a build bomb reaps itself
        metadata={"job_id": job_id, "purpose": "cargo-build"},
        # Create with egress allowlisted to the crates.io / registry mirror
        # ONLY -- no metadata endpoint, no arbitrary internet. Or: no egress at
        # all, and vendor deps (see below).
    ) as sbx:
        # Drop the untrusted repo in. We never trust it and never build it on
        # the host.
        sbx.filesystem.write("/work/repo.tar", repo_tarball)
        sbx.exec("mkdir -p /work/src && tar -xf /work/repo.tar -C /work/src",
                 timeout_seconds=60)

        # No ~/.cargo token, no SSH keys, no cloud role live in this guest.
        # timeout_seconds is the circuit breaker for a spinning build.rs;
        # it kills THIS vm only.
        result = sbx.exec(
            "cd /work/src && cargo build --release --locked 2>&1",
            timeout_seconds=900,
        )
        logs = result.stdout

        artifact = None
        if result.exit_code == 0:
            # Pull ONLY the artifact you asked for out of the disposable VM.
            artifact = sbx.filesystem.read("/work/src/target/release/app")

        return {"job": job_id, "ok": result.exit_code == 0,
                "logs": logs, "artifact": artifact}
    # VM gone here: any payload build.rs dropped, the whole target/ dir, and
    # every byte the build touched vanish with the machine.
For the strongest posture, vendor dependencies and build fully offline. Run `cargo vendor` once in a trusted context, commit the vendored sources, and build the untrusted repo with `--offline --frozen` and egress turned OFF entirely. Now build.rs and proc-macros run with no network at all — they can't fetch a second-stage payload and can't exfiltrate, because the guest has nowhere to send anything. `--locked`/`--frozen` also stops the build from silently resolving to different dependency versions than you vetted.

Build on the host vs. in a container vs. in a microVM

  • On the host / CI runner directly — build.rs and proc-macros run with your user, your env secrets, your SSH keys, your ~/.cargo token, and (on CI) your cloud role via the metadata endpoint. A malicious crate reads all of it and exfiltrates over the build's open network. Convenient and catastrophic.
  • In a container — better: namespaces and a dropped capability set shrink the blast radius, and a fresh container per build helps with persistence. But it's a shared host kernel, so a kernel LPE or container escape from a build.rs crosses the boundary, and mounted secrets or an inherited env still leak. A step up, not a wall.
  • In a microVM — the build runs behind a separate guest kernel under hardware virtualization, with no host credentials, allowlisted/off egress, a ttl backstop, and total teardown. A hostile build.rs gets a disposable machine with nothing in it and no way out. This is the boundary that matches 'I'm running code I didn't write.'

Wiring it into CI and coding agents

The two big consumers of this are CI (building forks, PRs from outside contributors, dependency-update bots) and AI coding agents (which cheerfully `cargo add` and `cargo build` whatever gets the tests passing). For both, the pattern is the same: never build untrusted Rust in the trusted environment. Route every untrusted build into a fresh microVM, keep the trusted secrets in the control plane that dispatches the job, and let each build's teardown clean up after itself. Because each agent pre-allocates 16,384 /30 subnets, every build VM gets its own network namespace, so per-build egress allowlisting (registry-only, or offline) is the default rather than a special case. For a stream of similar builds, snapshot a VM with the Rust toolchain and a warmed registry cache and fork it per job (same-host fork 400–750ms, cross-host 1.2–3.5s) so each build starts from a known-good image without a full create — and still dies, dropped payloads and all, when it finishes.

Putting it together

Rust is memory-safe at runtime and wide open at build time, and the two mechanisms that open it — build.rs scripts and procedural macros — are core features you can't just turn off, buried in a dependency graph you can't fully read. So `cargo build` and `cargo test` on an untrusted repo are running its authors' code with your privileges, your secrets, and your network. Don't build it on your host or your trusted CI runner. Build it in a throwaway Firecracker microVM: no `~/.cargo` token or SSH keys in the guest, egress allowlisted to the registry or turned off with vendored deps, `--offline --frozen --locked` to keep the build honest, a ttl to cap a build bomb, and teardown to erase whatever it dropped. The malicious crate still gets published. It just runs build.rs in an empty, disconnected room you were going to throw away anyway.

Frequently asked questions

Why is running `cargo build` on untrusted code dangerous?

Because a Rust build executes arbitrary code before your program runs, through two features. A crate can include a build.rs script that Cargo compiles and runs during the build with your privileges, environment, and network — it can read your secrets and SSH keys, open sockets, and exfiltrate. And procedural macros run inside the rustc process during compilation, so compiling code that invokes a proc-macro (which most of the ecosystem uses for derives and attributes) runs that macro's code on your machine, even if the crate has no build.rs. Between the two, `cargo build` or `cargo test` on an untrusted repo is running that repo's authors' code at your privilege level. Rust's runtime safety doesn't apply to build time.

Can't I just read build.rs before building to check it's safe?

Not reliably. You might catch a hostile top-level build.rs by reading it, but the proc-macro vector is buried in your transitive dependency graph — a proc-macro several crates deep runs during compilation, and auditing every proc-macro in a real dependency tree by hand isn't practical. You also can't easily see what build.rs will do dynamically (it can fetch a second-stage payload over the network). The robust answer isn't to audit your way to trust; it's to assume the build might be hostile and run it somewhere disposable — a microVM with no credentials and locked-down egress — so a malicious build.rs or proc-macro gets an empty machine instead of your host.

How do I build an untrusted crate without giving it my crates.io token or CI secrets?

Run the build in a fresh microVM that simply doesn't contain them. Keep your ~/.cargo credentials, SSH keys, and CI secrets in the trusted environment that dispatches the job, and provision the build guest with none of them — so there's nothing for build.rs or a proc-macro to read and exfiltrate. Allowlist the guest's egress to only the registry or git source the build needs (or turn egress off and build offline with vendored dependencies), block the cloud metadata endpoint, cap the build with a ttl and an exec timeout, and destroy the VM when it's done. The build gets exactly the network and files it needs to compile and nothing that would let it steal your identity.

Is a container enough to sandbox a Rust build, or do I need a VM?

A container is better than building on the host — namespaces, dropped capabilities, and a fresh container per build shrink the blast radius and help with persistence. But it shares the host kernel, so a kernel local-privilege-escalation or container escape triggered by a hostile build.rs crosses the boundary, and any mounted secret or inherited environment variable still leaks. For genuinely untrusted code — outside PRs, dependencies you're auditing, AI-generated projects — a microVM gives a stronger boundary: a separate guest kernel under hardware virtualization, so an escape has to beat the hypervisor, plus no host credentials and total teardown. Use a container for semi-trusted builds and a microVM when you truly don't trust the code.

How do I make the build fully offline so it can't exfiltrate anything?

Vendor the dependencies and build with no network. In a trusted context, run `cargo vendor` to fetch and pin all dependency sources locally and commit them, then build the untrusted repo in the microVM with `cargo build --offline --frozen --locked` and the guest's egress turned off entirely. Now build.rs scripts and proc-macros run with no network access at all — they can't fetch a second-stage payload and they can't send anything out, because the guest has nowhere to reach. The `--frozen`/`--locked` flags also prevent the build from resolving to different dependency versions than the ones you vendored and vetted, closing the door on a swapped-out dependency slipping in during the build.

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.