On August 20, 2026, someone published [email protected] to crates.io at 07:15 UTC. It was gone by 08:41, alive for 86 minutes. During that window nobody had to call the crate. Resolving it into your dependency tree and running cargo build was enough to download and execute a remote binary with your user privileges.
Short version: the malicious code shipped inside a build script (build.rs) carried by proc-macro1, a one-character typosquat of proc-macro2 that [email protected] added as a dependency. Cargo runs build.rs automatically at compile time, so cargo build or cargo update triggered it with no function call required. Rust has no cargo --ignore-scripts equivalent, so the fix is not a flag. It is committing Cargo.lock, building with --locked, and refusing brand-new versions with a cooldown.
arrayref is a nine-line convenience crate with roughly 245 million downloads, sitting under winit, egui, iced, blake3, and a swath of Solana and Ethereum tooling, per The Hacker News and the Rust Security Response Team. The poisoned release added one new dependency: proc-macro1, published from dtolney, a near-miss of the real maintainer account dtolnay. Two sibling crates from the same owner, [email protected] and [email protected], were poisoned the same morning and pulled within 90 and 107 minutes.
What actually executed during cargo build?
The payload ran at build time, not run time. Per StepSecurity's teardown and the Rust Project advisory, the malicious logic lived in proc-macro1's build.rs. Nothing from arrayref or its siblings had to be invoked. Resolving the tree and compiling it was the trigger.
The script was written to slip past a casual look. It reconstructed its C2 host (23.254.165.112, a Hostwinds VPS) from base64 URL fragments, used an AcceptAll TLS verifier so it could talk to a bare IP with no valid certificate, wrote /tmp/rust-setup on Unix (a .ps1 plus .vbs pair driven by wscript.exe on Windows), and called std::mem::forget(child) so the spawned process outlived the build. On one Linux victim, stage two dropped MonoService and MonoXpc binaries under ~/.config/AzureKits and registered a systemd service for persistence.
The trust boundary that matters here is not "code I wrote" versus "libraries I use." It is "code that runs when I compile." In Rust those are different sets, and build.rs is the second one.
Why "I never called that crate" is the wrong model
Every JavaScript developer reading this already survived the install-script era. npm shipped --ignore-scripts, then npm 12 turned install scripts off by default behind an allowlist you commit to package.json, and pnpm added its own onlyBuiltDependencies gate. If you have wrestled with the npm --allow-scripts not allowed in project installs error or the npm 12 allowScripts warning, you already know the muscle: name the packages allowed to run code at install, deny the rest.
Rust's build.rs is the structural twin of an npm install script, with one difference. Rust never shipped the seatbelt. There is no flag today that says "compile this project, but do not execute my dependencies' build scripts." So the assumption a JavaScript team now gets for free is unavailable to a Rust team in August 2026, and this incident is the first at-scale demonstration of what that gap costs.
The second-order effect is where teams get burned. Because the payload sits in a build script, static reasoning about your own code (grepping for use arrayref, checking which functions you call) tells you nothing. A dependency you pulled purely as a transitive requirement of winit or blake3, and never reference directly, ran code on your build box. Audit the set of crates in your resolved graph that carry a build.rs, not the set you import.
Is --offline the fix? No, and here is why
The reflex I saw within hours of the disclosure was "I'll just build --offline." That is false comfort. Offline builds only protect you if the malicious .crate is not already sitting in ~/.cargo/registry/cache. Once a poisoned cargo update has run, it is there, and an offline build will happily compile and execute it.
The controls that would have shrunk this to zero are duller and older. Commit Cargo.lock and build with --locked so CI never silently resolves a brand-new version behind your back. Then add a cooldown that refuses versions younger than a few days. An 86-minute exposure window does not survive a 3-day cooldown, because no build in that window ever sees the poisoned version as installable. This is the same argument the dependency cooldown work makes on the npm side, and it ports directly to Cargo.
One more trap: yanking the version upstream does not clean your machine. The Rust advisory ships a find command against ~/.cargo/registry/cache for exactly this reason. Cached .crate files survive an upstream yank. Any host that ran cargo build or cargo update in the 07:11 to 09:25 UTC window on August 20 should be treated as compromised, with its reachable credentials rotated.
How a one-character typosquat survived a glance
The attacker staged the name to look legitimate. They first published a clean [email protected], a bit-for-bit copy of proc-macro2 source with no payload, to build innocent history. Then they weaponized 1.0.107 with the malicious build.rs and three build-only dependencies (base64, rustls, ureq), per StepSecurity. A developer who clicked through to vet the crate would have found a real, building, plausible package with prior releases. Provenance-by-eyeballing loses against this.
cargo vet and cargo-deny are the durable answer, and it is worth being honest that neither would have flagged 1.0.107 the instant it landed. What cargo vet gives you is a "who audited this, and this exact version" model, which is the thing typosquat-by-history is designed to defeat. That is a different job from a CVE scanner. If you want the distinction between scan-what-exists and attest-what-you-trust laid out for the container world, the same split shows up in Trivy versus Grype.
Who ran this, and why the target set fits
Wiz reports significant overlap between this campaign's infrastructure and tradecraft and prior DPRK-linked operations. That reading is consistent with the stage-two persistence, which focused on credentials and crypto tooling, and with the choice of crates sitting under Solana and Ethereum ecosystems. The counterpoint worth stating: attribution from infrastructure reuse is a probabilistic claim, not proof, and the defensive posture does not change based on who did it. Whether the actor is a nation-state or a bored opportunist, the control gap (build scripts run arbitrary code, and Cargo cannot stop them) is identical.
The broader signal is the cadence. Published-then-yanked-in-under-two-hours is now a repeatable pattern across npm, PyPI, and Cargo. Speed is the attacker's advantage, and every defense that depends on a human noticing in time is already beaten.
What to do before your next CI build
Order these by what bites first.
- Commit
Cargo.lockand add--lockedto every CI build command today. For applications there is no excuse:cargo build --lockedfails the build rather than silently resolving0.3.10. This one line closes the exact vector that pulled the poisoned version in during the 86-minute window. - Set a cooldown of 3 to 7 days on new crate versions via
cargo-denypolicy or a filtering mirror. Every published-then-yanked-in-90-minutes attack dies against a 3-day floor. Pick 3 days as a minimum; go to 7 if your release pace allows. - Scan your cache for the known-bad versions now. Run the advisory's
findagainst~/.cargo/registry/cachefor[email protected],[email protected],proc-macro1(any version), and[email protected]. If any host rancargo buildorcargo updatebetween 07:11 and 09:25 UTC on Aug 20, rotate every credential reachable from that host, including cloud tokens and signing keys. Check~/.config/AzureKitsand for a rogue systemd service. - Baseline build-time egress, not just runtime. StepSecurity's Harden-Runner caught this because a build phoning
23.254.165.112:9089had never appeared in the workflow's network baseline. If your CI cannot see whatcargo buildtalks to, wire up egress monitoring; the same default-deny egress logic that contains a pod contains a build runner. - Adopt
cargo vetfor your critical dependency set and pin trusted publishers. It will not catch a fresh typosquat instantly, but it makes version-level provenance a gate instead of a hope. - Track cargo #13681 (build-script allowlist) and #5720 (sandbox). Until one ships, treat every
cargo buildon a credentialed machine as arbitrary code execution by your entire transitive tree, because that is what it is.
FAQ
Does building with --offline protect me? Only if the poisoned .crate never reached your cache. After a cargo update pulled it, the file is in ~/.cargo/registry/cache and offline builds still run its build.rs. Use --locked against a committed lockfile.
I never called arrayref. Am I safe? No. The payload was in a build.rs, which Cargo runs for any crate in the resolved graph regardless of whether you import it.
Is there a cargo --ignore-scripts? No. As of August 2026 no flag skips dependency build scripts. Proposals cargo #13681 and #5720 are open and unmerged.
How do I know if I was hit? Run the advisory's find over ~/.cargo/registry/cache, and treat any host that compiled between 07:11 and 09:25 UTC on Aug 20 as compromised. Rotate its credentials.
Comments
Be the first to comment.