chaowen.← Back to archive

Security / software supply chain deep dive

Cargo build scripts: the supply-chain boundary that runs during compilation

A Rust dependency is not necessarily passive source code. If its package contains build.rs, Cargo compiles and executes that script as part of the build. That boundary is useful for native libraries and code generation—and it is also a direct path from a registry release to a developer machine or CI runner.

Security deep dive · Cargo package resolution, build-script execution, lockfiles, checksums, and defenses against poisoned crates

2026-08-22

The concrete problem

Imagine a project whose source code has been reviewed, its tests pass, and its dependency names look familiar. A fresh checkout runs cargo build. Before the compiler builds the application, Cargo may compile and run code shipped by a dependency. That code can read environment variables, write files, invoke processes, and make network connections with the permissions of the build.

This is not a hypothetical interpretation of “build.” The Rust Security Response Team reported on 20 August 2026 that proc-macro1 contained a build script that downloaded a malicious payload. A republished [email protected] was made to depend on it; the release was available for 86 minutes. The incident is a compact demonstration of the real trust boundary:

Cargo.toml / Cargo.lock │ ▼ resolver selects package versions │ ▼ registry tarballs + checksums │ ▼ extract package; compile build.rs │ ▼ execute build-script-build ──► filesystem / env / network / child processes │ ▼ compile the library and application
Core invariant: the dependency graph is executable supply-chain input. A lockfile makes the graph reproducible; it does not make a package trustworthy, and a checksum proves byte identity—not benign behavior.

What Cargo does, in order

The important path is spread across Cargo's resolver, registry client, build planner, and process runner. Exact module names change between releases, but the ownership boundaries are stable.

StageWhat happensSecurity meaning
ResolveCargo reads manifests, registry indexes, and usually Cargo.lock to choose package versions and features.A transitive dependency can enter without appearing in the root manifest.
FetchIt downloads a .crate archive and verifies the registry-provided checksum.Integrity catches corruption or substitution after publication, not a malicious publisher.
UnpackThe archive is unpacked into Cargo's registry source/cache area.Package contents—including build.rs—become local build input.
Compile scriptCargo builds build.rs as a host executable, commonly under target/debug/build/<package>-<hash>/.The script is native code executed before the dependent crate.
Run scriptCargo launches the generated build-script executable with Cargo-set environment variables and captures its stdout.Its OS permissions are inherited unless the build environment imposes a sandbox.
Compile crateCargo passes printed directives such as cargo::rustc-link-lib and cargo::rustc-env to later steps.Build output can alter linking, conditional compilation, and generated inputs.

Build scripts exist for legitimate reasons: compiling a bundled C library, probing system libraries, generating bindings, or producing code into OUT_DIR. Cargo's documentation explicitly describes them as custom build steps, not as a restricted declarative manifest feature. The distinction matters: a build script is a program.

The August 2026 attack path

The published facts describe a dependency-confusion-shaped route through a trusted package graph:

  1. The attacker published malicious packages including proc-macro1, a lookalike name for the legitimate proc-macro2.
  2. The malicious package's build.rs selected a payload for the host operating system and CPU architecture, downloaded it, and executed it.
  3. A legitimate maintainer account or workstation was likely compromised. A new arrayref release added proc-macro1 as a dependency. Related packages internment and append-only-vec were also affected.
  4. Recent legitimate releases were yanked, which made the poisoned releases more attractive to a resolver that needed a compatible version. Yanking is a registry-resolution signal, not a cryptographic revocation mechanism.
  5. Rust deleted the identified malicious packages and locked the affected account. The response team advised checking local Cargo caches and lockfiles.

The payload reportedly targeted browser profiles and extension storage, including cryptocurrency-wallet data, and attempted persistence. The valuable lesson is earlier in the chain: the attacker did not need to compromise every project. They needed a package that many build processes would execute automatically.

There is also a subtle timing trap. A package being deleted from the public registry does not erase copies already in a developer's cache, a CI cache, an artifact mirror, a container layer, or a checked-in vendor directory. Conversely, a package being yanked does not invalidate an existing lockfile entry. Treat registry remediation and local incident response as separate operations.

Important files, functions, and data flow

For a source-level tour, the open-source Cargo code is conveniently split by this same flow: src/resolver/resolve.rs defines the resolved graph, src/sources/registry/download.rs handles registry downloads, and src/compiler/custom_build.rs contains BuildScriptOutputs, BuildScripts, prepare, and the build-script work path. Cargo's integration coverage is in tests/testsuite/build_script.rs. These are implementation landmarks, not a promise that internal names will never change.

Cargo.toml: declared intent

The manifest names direct dependencies and may select features that activate optional transitive packages. It is the human-facing graph, not the complete graph. Use cargo tree to inspect the resolved transitive path and cargo tree -i proc-macro1 to ask which package pulled a suspicious crate into the build.

Cargo.lock: resolved identity

A lockfile records package names, exact versions, registry sources, and checksums. In a reproducible build, cargo build --locked refuses to change the lockfile. --frozen additionally enables offline behavior, preventing Cargo from modifying the lockfile or accessing the network. These flags constrain resolution; they do not sandbox already selected build scripts.

Build-script environment and stdout

Cargo supplies variables such as OUT_DIR, TARGET, HOST, CARGO_MANIFEST_DIR, and package metadata. The script's stdout is a control channel: lines beginning with cargo: or cargo:: tell Cargo how to rerun the script, link native libraries, set compiler configuration, or expose environment values. Untrusted code therefore has both ordinary process capabilities and a narrow way to influence downstream compilation.

Registry cache and target directory

The source archive is commonly visible under ~/.cargo/registry/cache. After compilation, the generated script and its output remain under the target directory. For incident response, the cache is evidence: preserve timestamps and hashes before deleting it, and search CI caches and build images as well as developer laptops.

registry/index ──► resolver ──► Cargo.lock │ │ ▼ ▼ registry/cache/*.crate ──► source unpack ──► build.rs │ ├─ OUT_DIR artifacts ├─ cargo: directives └─ inherited process privileges

What the defenses do—and do not do

ControlIt helps withIt cannot guarantee
Cargo.lock + --lockedUnexpected version drift and accidental graph changes.That the locked release is safe.
Registry checksumCorruption or a proxy serving different bytes for the same package record.That the publisher's bytes are non-malicious.
Reviewing Cargo.tomlSuspicious direct dependencies and feature changes.Hidden transitive additions.
cargo tree, cargo deny, cargo vetGraph visibility, policy violations, and review provenance.Perfect detection of novel malicious behavior.
Private registry or cargo vendorAllowlisting and audit of the exact source archives used by CI.Safety if the allowlist is refreshed without review.
Network-egress deny and sandboxed buildersStops or limits payload download and reduces blast radius.Malicious code that abuses local inputs or already-mounted secrets.

The strongest practical design is layered: resolve and review the graph, pin it, build from audited source archives, run dependency builds in an ephemeral least-privilege environment, deny unnecessary network egress, and keep cloud credentials and browser data out of the build process. A CI runner should be disposable even when the source repository is trusted.

Failure modes and debugging

For the August incident, start with the Rust Security Response Team's exact package/version list. Search every lockfile, registry cache, vendor directory, CI cache, and build image. Do not rely on a source grep alone: a dependency can be introduced through another package, and the malicious behavior may live in generated or binary build output.

Compact reproducible lab

Goal: observe a harmless dependency build script execute, write an artifact, and receive host/build metadata. This proves the execution boundary without downloading or executing malware.

Prerequisites: Rust and Cargo. Use a disposable directory. The lab does not contact the public registry.

cargo --version
LAB="$(mktemp -d)"
cd "$LAB"
cargo new --lib demo
cd demo

cat > build.rs <<'EOF'
use std::{env, fs, path::PathBuf};

fn main() {
    let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
    let target = env::var("TARGET").unwrap_or_default();
    fs::write(out.join("execution-marker.txt"), format!("target={target}\n"))
        .expect("write marker");
    println!("cargo:warning=demo build script ran for {target}");
    println!("cargo::rerun-if-changed=build.rs");
}
EOF

cargo build --offline -vv
find target -name execution-marker.txt -print -exec sed -n '1p' {} \;

Expected result: Cargo prints the warning from build.rs, then finds a marker below target/debug/build/. The library source itself never calls the script; Cargo's build plan does.

Variation 1: add std::net::TcpStream::connect(("127.0.0.1", 8765)) to build.rs, start python3 -m http.server 8765 in another terminal, and run cargo build -vv again. The local server logs a connection. This demonstrates why --offline is not a sandbox: it prevents Cargo's registry access, but does not stop a build script from using a permitted local socket.

Variation 2: run cargo build --locked --offline after deleting Cargo.lock (this new package has no dependencies). It succeeds because the graph is empty; in a real project with a missing or stale lockfile it fails rather than silently resolving a new graph. That is the useful failure.

Environment result: this lesson's environment returned zsh: command not found: cargo, so the lab could not be executed here. On a disposable machine, install a Rust toolchain, rerun cargo --version, and then use the commands above. If installation is not appropriate, inspect the generated build.rs and read Cargo's build-script documentation; do not install toolchains or run package-manager commands on a production host just to reproduce this lesson.

Production checklist

  1. Commit and review Cargo.lock; use --locked in CI and --frozen where offline reproducibility is intended.
  2. Run cargo tree --locked in review tooling and flag new transitive packages, typosquats, yanks, and unexpected registry or git sources.
  3. Make dependency builds network-denied by default; allow a separate, reviewed fetch phase.
  4. Use ephemeral, least-privilege builders with no browser profiles, long-lived cloud credentials, signing keys, or writable host mounts.
  5. For an incident, preserve and scan lockfiles, ~/.cargo/registry/cache, vendor trees, CI caches, artifacts, and builder images; then rotate secrets that were present during a suspicious build.

The uncomfortable conclusion is also the useful one: “compile the code” is not the whole operation. In Cargo, the dependency graph includes programs that execute before your program exists. Secure the build as an execution environment, not merely as a text transformation.

Primary sources