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.
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:
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.
| Stage | What happens | Security meaning |
|---|---|---|
| Resolve | Cargo 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. |
| Fetch | It downloads a .crate archive and verifies the registry-provided checksum. | Integrity catches corruption or substitution after publication, not a malicious publisher. |
| Unpack | The archive is unpacked into Cargo's registry source/cache area. | Package contents—including build.rs—become local build input. |
| Compile script | Cargo 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 script | Cargo 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 crate | Cargo 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:
- The attacker published malicious packages including
proc-macro1, a lookalike name for the legitimateproc-macro2. - The malicious package's
build.rsselected a payload for the host operating system and CPU architecture, downloaded it, and executed it. - A legitimate maintainer account or workstation was likely compromised. A new
arrayrefrelease addedproc-macro1as a dependency. Related packagesinternmentandappend-only-vecwere also affected. - 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.
- 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.
What the defenses do—and do not do
| Control | It helps with | It cannot guarantee |
|---|---|---|
Cargo.lock + --locked | Unexpected version drift and accidental graph changes. | That the locked release is safe. |
| Registry checksum | Corruption or a proxy serving different bytes for the same package record. | That the publisher's bytes are non-malicious. |
Reviewing Cargo.toml | Suspicious direct dependencies and feature changes. | Hidden transitive additions. |
cargo tree, cargo deny, cargo vet | Graph visibility, policy violations, and review provenance. | Perfect detection of novel malicious behavior. |
Private registry or cargo vendor | Allowlisting and audit of the exact source archives used by CI. | Safety if the allowlist is refreshed without review. |
| Network-egress deny and sandboxed builders | Stops 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
- “The lockfile is committed, so we are safe.” A malicious package can be locked. Review lockfile diffs and investigate unusual new packages, versions, sources, and checksums.
- “The crate was yanked, so builds cannot use it.” Locked builds can still use yanked versions, and cached or vendored archives survive registry changes.
- “We only run
cargo check.” Checking still resolves dependencies and runs required build scripts. Compilation mode is not a security boundary. - “It only happened in CI.” Build caches, artifacts, and runner images can preserve the package after the public release disappears. Rotate credentials exposed to a suspicious build and inspect downstream artifacts.
- “The script ran once, then disappeared.” Cargo may reuse build outputs until inputs change. Compare verbose logs with
cargo build -vv, inspecttarget/*/build, and use a clean target directory when reproducing.
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.
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
- Commit and review
Cargo.lock; use--lockedin CI and--frozenwhere offline reproducibility is intended. - Run
cargo tree --lockedin review tooling and flag new transitive packages, typosquats, yanks, and unexpected registry or git sources. - Make dependency builds network-denied by default; allow a separate, reviewed fetch phase.
- Use ephemeral, least-privilege builders with no browser profiles, long-lived cloud credentials, signing keys, or writable host mounts.
- 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
- Rust Security Response Team: Supply chain attack on arrayref — affected packages, timestamps, and local-cache guidance.
- The Cargo Book: Build Scripts — lifecycle, environment, rerun rules, and
cargo:directives. - The Cargo Book: Specifying Dependencies — version resolution and lockfile behavior.
- The Cargo Book: Registries — registry indexes, sources, and source replacement.
- The Register: Hackers poison popular Rust crates to steal developers' credentials — independent incident reporting and payload summary.
- BleepingComputer: Hackers poison arrayref Rust crate to push infostealer malware — independent reporting on the compromised maintainer path and impact.