RC RANDOM CHAOS

crustc ports rustc to C and voids every safety proof

Translating rustc to C strips Rust's compile-time memory-safety guarantees and reopens out-of-bounds writes, UAF, and type confusion in the toolchain.

· 7 min read
crustc ports rustc to C and voids every safety proof

crustc is a mechanical translation of the entire rustc source tree into C. The stated goal is portability and toolchain compatibility. The security consequence is not incidental. Every memory-safety guarantee Rust enforces at compile time stops applying the moment the source becomes C, because nothing in the C artifact re-checks what the borrow checker proved before the translation ran.

Rust safety is not a runtime service. It is a static property, encoded partly in the type system and partly in the machine code rustc emits. Slice indexing emits a bounds check. Enum access is gated on a discriminant read. Ownership guarantees a value is freed exactly once and never touched afterward. None of these survive a source-to-source port unless the translator reproduces each one, correctly, on every control-flow path. The borrow checker does not execute inside the compiled binary. It executed when rustc was built. Port the source to C and the proof it produced no longer describes the artifact that ships.

Consider what the translation has to hold invariant. v[i] in safe Rust lowers to a length comparison and a panic branch. In C that is an explicit conditional. Elide the branch under an assumption the C compiler cannot verify, or narrow usize to a 32-bit type in the comparison, and the check is gone and the store lands out of bounds. CWE-787. Rust enums are tagged unions, and a match reads the active field only after checking the tag. Translate the union without reproducing that check on every access, and reading the pointer field of a variant that currently holds an integer is type confusion, CWE-843, the same primitive that turns a V8 bug into an arbitrary read. Ownership and Drop become explicit free() calls the translator inserts. Get the move semantics wrong on a single edge - free a value that was moved out, fail to null a moved-from pointer - and use-after-free and double-free, CWE-416, return to a codebase that could not previously express them.

The undefined-behavior surface is its own problem. Rust release builds define integer overflow as two’s-complement wraparound. C leaves signed overflow undefined. A translation that maps Rust arithmetic onto C signed integers reintroduces UB, CWE-190, and the C optimizer is entitled to delete any check it can prove redundant under the assumption that overflow never happens. A bounds test that was sound in Rust can be optimized out of the C output. Rust’s &mut also encodes stronger non-aliasing than C expresses by default. Translate it to a plain pointer and the optimizer loses information. Translate it to restrict and any translation error becomes a miscompilation that is itself a memory-safety hole. The safety lived in the guarantees. The guarantees do not cross the language boundary for free.

The attack surface is the compiler’s input. rustc parses source, deserializes crate metadata from .rmeta and .rlib files, reads an on-disk incremental-compilation cache, and loads proc-macro dynamic libraries. The binary metadata path is the exposure. Compiling a dependency feeds attacker-controlled bytes to a deserializer, CWE-502. In safe Rust a malformed .rmeta produces an internal compiler error. A panic. Controlled unwind, no corruption. In crustc the same input reaches a translated deserializer carrying no bounds guarantee, and the failure mode shifts from ICE to heap corruption. The attacker controls the bytes. The bytes shape the allocation. What follows is ordinary C heap work: groom the allocator, overwrite an adjacent length field or a stored function pointer, redirect execution.

The metadata deserializer is the highest-value target because it runs before type checking and before most of the compiler’s own validation. rustc reads a length-prefixed, position-encoded blob and reconstructs interned strings, symbol tables, and type structures from offsets embedded in the file. A crafted length prefix the translated reader trusts without validating against the buffer size is a direct out-of-bounds read. A crafted offset pointing into a freed or not-yet-initialized region is the write. In Rust the reader operates on slices whose bounds are checked on access. In C the reader operates on raw pointers and a length the translator hopes is honest. That single difference converts a parsing bug into a controlled write into compiler heap state that later code dereferences as a function pointer.

The incremental cache widens it. rustc trusts the serialized dependency graph and query cache in its target directory. Shared and remote build caches - sccache backed by S3, Bazel remote cache, CI cache restore - mean those bytes are not always produced by the same trusted process that consumes them. An attacker with write access to a shared cache, or the ability to poison a cache key, delivers untrusted bytes to the same deserialization paths. Same primitive, different door.

This is not abstract about who runs the compiler. rustc runs in CI. It runs on developer workstations. It runs with the credentials of the pipeline that invoked it: registry tokens, cloud keys, signing material. A crate published to a public registry and pulled as a transitive dependency executes inside the compiler on every downstream build agent. MITRE maps the chain as T1195.001, compromise of software dependencies, into T1203, exploitation for client execution, where the client is the compiler itself. Compiler RCE is pipeline RCE. The blast radius is every project below the poisoned crate in the graph.

The distinction from existing risk matters. proc-macros already run arbitrary code at compile time. That property is known, and defenders sandbox around it. crustc adds a memory-corruption path that needs no proc-macro enabled and operates beneath the sandbox built for macro execution. It reopens a bug class the ecosystem stopped defending because Rust had made it unreachable in safe code.

Toolchain-as-target has precedent. CVE-2024-3094, the xz/liblzma backdoor, CVSS 10.0, planted malicious code in a build dependency that activated during compilation; the target was the build, not the shipped binary. Ken Thompson described the compiler as the ideal implant point in 1984. mrustc, a C++ reimplementation of rustc, already exists and bootstraps the toolchain without a prebuilt binary, but that use is bounded to a known-good source tree compiled once under controlled input. A production crustc consuming arbitrary crates from a public registry is the opposite of bounded. Meta and Cloudflare both run large Rust codebases; the integrity of the compiler that builds them is not a hypothetical.

Telemetry is where the position degrades. Exploitation of a corruption bug inside the compiler executes in-process. If the payload spawns a child - cmd.exe, bash, powershell - Sysmon Event ID 1 fires and a process-lineage rule can flag a compiler spawning a shell. If the payload stays in-process, runs through ROP or a hijacked function pointer, and exfiltrates over a socket the agent already holds open to a registry, Event ID 1 stays silent. Outbound to an unexpected host appears in Sysmon Event ID 3, but build agents contact many hosts and the baseline is loud. proc-macro dylib loads trip Event ID 7 continuously. On a hardened host an EDR behavioral category for anomalous allocation or unexpected executable-memory transitions may fire, but most deployments do not tune those for compiler processes, which allocate aggressively and are allow-listed to cut noise. Windows Security Event ID 4688 records the child process creation with command line, but only for the child that a fileless in-process payload never creates. Signal drowns in the noise the compiler generates by design.

The precise blind spot is the crash. rustc crashing is routine. An internal compiler error is filed as a bug, not an incident. A SEGV in a compiler reads as a compiler defect. In crustc a SEGV during metadata deserialization is indistinguishable in the logs from a benign ICE, and it is exactly what a failed or probing corruption attempt produces. EDR observes a trusted, high-privilege, allocation-heavy process fault and restart. No standard alert category maps to the statement that the compiler was exploited.

Detection that fits this treats the compiler as an execution sink, not a passive tool. Baseline the normal child-process tree of the build and alert on any compiler process spawning an interpreter or shell. Constrain and monitor build-agent egress against a registry allowlist and treat first-seen destinations as investigable. Capture crashes: a rustc or crustc SEGV in CI is security-relevant and warrants a core dump and ETW capture, not a silent retry. Run CI toolchains under a hardened allocator with guard pages so corruption faults early and loudly instead of executing. These raise cost. They do not restore the guarantee. CFG, CET shadow stacks, and hardened allocators are C mitigations applied to a class of bug Rust had already removed at the source. A crustc crash pattern in production build infrastructure belongs with the security team as a suspected exploitation attempt, not with the compiler maintainers as a defect report.

There is no single patch, because there is no single bug. Every place the translation dropped, narrowed, or mistranslated a check is a separate defect with its own root cause. The soundness work behind rustc - a decade of it, tracked in RUSTSEC advisories and closed at the type-system level - does not transfer to the C artifact. That artifact must be audited as C, on its own terms, from zero. Every pointer dereference in the output looks the same, and the safe/unsafe boundary that told reviewers where to concentrate is gone. Residual exposure after any given fix is every check not yet re-verified by hand.

The summary is short. crustc did not port a compiler. It ported the compiler, discarded the runtime enforcement of its type system, and aimed the result at a public package ecosystem. The bugs Rust prevents by construction are reachable again, inside the one binary every Rust program passes through.

Share

Keep Reading

Stay in the loop

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