RC RANDOM CHAOS

Compiling a Rust crate runs the author's code.

crates.io federates all publish identity to GitHub, turning one account takeover into build-time code execution across every downstream Cargo project.

· 7 min read
Compiling a Rust crate runs the author's code.

crates.io accepts one identity provider for publishing. GitHub. Every version pushed to the canonical Rust registry authenticates through a GitHub account. Compromise the account, and publish capability transfers with it.

That is the coupling. The registry federates author identity to a third-party code-hosting platform. Crate tarballs live on crates.io infrastructure, S3 behind Fastly. The credential that authorises a new upload originates from GitHub OAuth. There is no standalone crates.io password. There is no second federated provider. The trust anchor for who may publish serde, tokio, syn, or libc is a GitHub session token. A registry serving billions of downloads inherits the account-security posture of an external platform it does not control. Map it to CWE-1357, reliance on an insufficiently trustworthy component.

The index compounds it. Until Rust 1.70, Cargo resolved dependencies by cloning a git repository - crates.io-index - hosted on GitHub. The sparse HTTP index, default since June 2023, moved resolution to index.crates.io and cut the clone. The canonical index git repository still lives on GitHub. Identity still routes through GitHub. The dependency did not disappear. It narrowed.

Now the primitive. The exposure is not metadata. It is code execution during compilation. Cargo runs build.rs before it compiles a crate. build.rs is arbitrary Rust: compiled, then executed on the host performing the build, with that host’s privileges. No sandbox. No capability boundary. Procedural macros are the same class - arbitrary code executed inside rustc at compile time. A cargo build across 300 transitive dependencies is 300 arbitrary-code-execution points running under the identity of the build environment. On a developer laptop that is the developer. On a CI runner that is the pipeline: its cloud role, its registry tokens, its signing keys.

This maps to MITRE T1195.001, compromise of software dependencies and development tools, seeded by T1078 for the valid-account step. CWE-829, inclusion of functionality from an untrusted control sphere. The technique does not require a memory-safety bug. Rust’s guarantees stop at runtime memory behaviour. They say nothing about a build script that shells out.

The chain is short. Phish or hijack a maintainer’s GitHub session: OAuth token theft, a malicious device authorisation, credential stuffing against a reused password, a poisoned GitHub Action exfiltrating the runner’s token. T1078, valid accounts. Log into crates.io through that identity. Mint an API token. Publish a new patch version of a crate already in wide use. Embed the payload in build.rs or in a proc-macro the crate exports. Every downstream project that runs cargo update inside the semver range pulls it. The next cargo build executes attacker code on every consumer’s machine. T1195.001 to T1059, command and scripting interpreter, in one version bump.

Semver widens the blast radius. A caret requirement - the Cargo default - accepts every compatible minor and patch release. A malicious 1.4.7 published against a stated 1.2 dependency reaches every project pinned to ^1.2 the moment its lockfile refreshes. No consumer action beyond a routine update.

This is not modelled risk. CVE-2024-3094 - the xz-utils backdoor, CVSS 10.0 - is the reference implementation. A maintainer identity, cultivated over years through social engineering, injected obfuscated code assembled only at build time, concealed in test fixtures, activated by the build system’s own scripts. The payload never appeared in the readable source tree. It materialised during compilation. The delivery model - trusted maintainer, build-time assembly, downstream execution - is the model crates.io’s GitHub coupling exposes for Rust. Different ecosystem, identical mechanics. node-ipc in 2022 did it with intent rather than intrusion: the maintainer shipped file-wiping logic gated on IP geolocation to consumers who had done nothing but depend on the package. event-stream in 2018 did it with a handoff - a new maintainer, granted publish rights, added a payload targeting a specific downstream wallet. Each is the same failure at the trust anchor. The publisher was trusted. The publisher, or their account, was not trustworthy.

The Cargo client has carried its own flaws in the handling path. CVE-2022-36113 and CVE-2022-36114 - RUSTSEC-2022-0059 and -0060 - covered arbitrary file corruption via crafted archive entries and a decompression-bomb denial of service during crate extraction, both patched in the Cargo shipped with Rust 1.64. They are a smaller class than build-time execution. They require a crafted tarball reaching a victim’s extraction step, and they do not grant the clean, authorised, checksum-valid delivery that a compromised publish identity does. The extraction bugs are fixed. The design that runs build.rs is not a bug.

Telemetry is where the defence thins. A malicious build.rs produces a child process. Cargo compiles the script to a binary named build-script-build and executes it. On Windows, Sysmon Event ID 1 records the process creation under the cargo or rustc parent. Event ID 3 records outbound network connections. Event ID 11 records file writes. On Linux, auditd or an eBPF sensor records the exec and the socket. The signal exists.

The problem is base rate. Build scripts legitimately compile C, invoke pkg-config, probe the host, and fetch resources. A build.rs opening a socket is not anomalous - plenty do. Distinguishing malicious egress from legitimate egress requires content or destination intelligence the process tree alone does not carry. And the highest-value execution context, the CI runner, is frequently the least instrumented. Ephemeral. Spun up per job. Torn down in minutes. Often no EDR agent installed, because the runner image was never in scope for endpoint coverage. The place where build-time code executes with the most privilege is the place where the least telemetry lands. That is the gap.

Detection that works targets the specific pattern, not the generic one. Baseline the destinations build scripts in a given repository legitimately contact, then alert on egress from build-script-build to anything outside that set. Correlate a dependency’s version change in Cargo.lock against first-seen network or file-write behaviour from that crate’s build script - a build.rs that never touched the network across fifty builds and reaches out on the fifty-first is the signal. Hash build.rs and proc-macro source across versions and flag additions in a patch release, where build-logic changes should be rare. None of this ships by default. It is detection-engineering work, and it belongs on the CI runner, where the execution lands.

cargo-audit reads the RustSec advisory database and flags dependencies with known advisories or yanked versions. It is reactive by construction. It catches what has already been reported and catalogued. A freshly published malicious version - the first hours after a compromised publish, which is the exploitation window - carries no advisory. The database describes the past. The compromised 1.4.7 is the present.

Cargo.lock does not close the gap. The lockfile pins a SHA-256 checksum per resolved crate. That hash defends against post-publish tampering - a modified tarball fails verification. It does not defend against a version that was malicious when published. The checksum certifies that the bytes are the bytes the registry served. It certifies nothing about intent. A backdoored 1.4.7, uploaded through a stolen GitHub identity, produces a valid checksum and a clean verification chain.

Two-factor authentication on GitHub raises the cost of the initial takeover. It does not eliminate it. Session-token theft, OAuth application abuse, and phishing proxies that relay the second factor in real time all bypass a TOTP prompt. The token minted after a successful login carries the same publish authority regardless of how the login was obtained. crates.io Trusted Publishing - OIDC from GitHub Actions, modelled on PyPI’s - removes the long-lived API token from the maintainer’s laptop, which is a real reduction in token-theft surface. It also binds publishing more tightly to GitHub, not less. The identity provider and the CI provider become the same platform. A compromise of a repository’s Actions configuration, or of the OIDC trust policy, becomes a publish primitive.

The residual exposure after every current control is specific. Build-time code execution remains unsandboxed by design - cargo-vet and cargo-crev provide human audit trails, not runtime containment, and neither is mandatory. Publish authority remains anchored to a single external identity provider with no in-registry fallback. CI runners remain the highest-privilege, lowest-visibility execution point in the chain. The sparse index narrowed the GitHub coupling for resolution and left it intact for identity. --locked in CI freezes the dependency set to the reviewed lockfile and blocks silent range upgrades, which constrains the semver blast radius but does nothing for the version an author reviewed and trusted before it was replaced upstream.

The single-provider identity model is the structural fault. A registry that authorises publication through one external platform inherits every account-takeover path that platform carries, and passes the result directly into a build system that executes dependency code without a boundary. The memory-safety guarantees Rust is bought for stop at the language runtime. The compilation step that produces the binary runs arbitrary third-party code before the runtime exists. That is the surface. It is not patched. It is designed.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.