In-depth articles on technology shaping what comes next.

Rust's Borrow Checker Bug: What Soundness Really Means

An 11-year-old Rust borrow checker unsoundness bug survived a decade of review. What it teaches about proofs, compilers, and soundness guarantees.

Illustration of a stone bridge with a single hairline crack in one arch, highlighted under a magnifying glass
One hairline crack in the load-bearing structure — invisible for eleven years of traffic.

Rust's type system is supposed to make a hard promise: if your safe code compiles, it can't have dangling pointers, data races, or use-after-free. So when a GitHub issue showed a snippet of perfectly safe-looking Rust that prints garbage pulled from a dead stack frame — and it turned out the hole had been sitting in the Rust borrow checker for eleven years — a lot of people felt the ground shift. The bug lets a struct's lifetime requirements slip through via covariant field assignment, and with a malicious Drop impl, you can stash a reference to a local variable in a global and read it after the variable is gone. AddressSanitizer calls it stack-use-after-scope. The compiler called it fine.

The reactions were predictable. Some declared Rust's safety story dead. Others shrugged: bugs happen. Both miss what's actually interesting here. This bug is a clean natural experiment in what static guarantees are worth, where informal reasoning breaks down, and why the distance between "the type system is sound" and "the compiler enforces a sound type system" is where the most interesting bugs live. So let's take the common beliefs this story kicked up and test each one.

Myth 1: An Unsoundness Bug Means Rust's Safety Model Failed

The first instinct is to treat this as a crack in the theory. It isn't. Nobody rewrote the rules of lifetimes. Nobody discovered that ownership and borrowing are fundamentally broken. The Rust type system, as designed on whiteboards and in RFCs, says this program must be rejected. The compiler just failed to reject it.

That distinction — specification versus implementation — is one of the oldest fault lines in our field. Tony Hoare gave us a beautiful theory of communicating sequential processes; decades of C compilers then miscompiled code that was perfectly valid under the standard. The theory holds while the artifact leaks. We've written before about how the specification-implementation gap is where bugs live, and this is a textbook case. The lifetime rules weren't wrong. The variance inference code in rustc — the part deciding when it's legal to substitute a &'a T where a &'static T is expected — had a hole when assignment flowed through a covariant field.

Here's the shape of the exploit, simplified. You build a wrapper whose Drop impl copies its payload into a global 'static store. Normally the compiler forces the payload to be 'static, so references to locals can't get in. The bug lets you launder a short-lived reference through a covariant assignment, so the wrapper ends up holding &l where l is a local String:

struct Q<T: 'static + Send>(Option<T>);
impl<T: 'static + Send> Drop for Q<T> {
fn drop(&mut self) {
if let Some(t) = self.0.take() {
KEPT.lock().unwrap().push(Box::new(t)); // escapes to 'static!
}
}
}
fn main() {
{
let l = String::from("hello world, this is long enough");
let mut q = Q(None);
q.0 = Some(&l); // should be rejected; 'static required
} // <- drop runs here, stashing &l in KEPT
// l is gone; KEPT now holds a dangling &'static String
}

Every individual line looks innocent. The unsoundness only exists in the composition: a covariant field, an assignment that loosens a lifetime bound, and a Drop that moves the payload somewhere it outlives its referent. That's the signature of implementation bugs in checkers — the invariant is global, but enforcement is local, and one code path forgot to check.

So no, the safety model didn't fail. The enforcement of the safety model failed. That's a smaller failure — but as we'll see, it's not a trivial one.

Myth 2: Eleven Years Undetected Means the Bug Is Harmless

The Rust team's own triage reasoning went roughly like this: this would be P-critical if people were hitting it, but eleven years without a sighting means it can't be easy to trigger, so it's P-high. There's truth in that. To actually cause memory unsafety you need a deliberately nasty Drop implementation that moves data into a 'static location. Almost nobody writes that. The accidental-exposure surface is tiny.

But "rarely triggered by accident" is doing a lot of work in that sentence. Safety guarantees aren't only about accidents — they're about what you can rely on when the code around you isn't friendly. The whole point of a sound type system is that you can reason modularly: I don't need to audit your Drop impl, because the compiler has already proven that certain bad compositions are inexpressible. Once there's a hole, that modularity is gone. A malicious dependency — or a subtly wrong one from a well-meaning author — can now do something the language promised was impossible.

History is full of "unexploitable" bugs that became very exploitable once the right gadget appeared. The practical question, as one commenter put it, is whether fixing this will break any real crate in the ecosystem — and that answer is probably "very few or none," which is good news. But the correct lesson from eleven quiet years isn't "the bug was harmless." It's that absence of evidence isn't evidence of absence, especially for failure modes that require intent rather than chance.

Myth 3: Enough Eyeballs Make All Bugs Shallow

This is the myth that should embarrass us most. rustc is one of the most scrutinized compilers ever written. It's open source, it has a dedicated types team, an active academic community (RustBelt gave us a formal model of a core of Rust years ago), and millions of users hammering on it daily. And yet a soundness hole sat in lifetime inference for over a decade, through complete rewrites of surrounding machinery.

Linus's Law assumes eyeballs are looking at the right place with the right model in their heads. Type system unsoundnesses defeat both assumptions. They live at the intersection of several features — variance, drop order, coercion, trait bounds — and each feature is locally correct. Reviewers read the code and see the intended invariant, because the code was written by people who also saw the intended invariant. Confirmation bias is built into the process. The counterexample requires thinking like an adversary, and adversarial thinking about your own abstractions is genuinely hard.

There's a whole website, counterexamples.org, cataloging ways plausible-looking type rules break, and the common thread is mutability composed with something else tricky — exactly the shape of this bug. These aren't new lessons. ML's type system had a famous unsoundness around mutable references and polymorphism, fixed with the value restriction. Java's arrays are covariant and can throw ArrayStoreException at runtime because the language designers chose convenience over soundness. The more things change, the more the same three features — subtyping, mutation, and generics — combine to bite someone. If you enjoy this kind of archaeology, Lisp's sixty-year history shows how long these ideas have been evolving, and Rob Pike's old rules about simplicity read differently when you realize complexity is where soundness holes breed.

Chalkboard with interwoven ropes forming knots, with a small gap where light leaks through the crossing point
Each rope is sound on its own; the hole appears only where several cross.

Eyeballs help. They're not sufficient. The honest position — one I heard from someone building their own language — is closer to: no amount of review solves every problem, so you need mechanical methods that don't get tired and don't share your assumptions.

Myth 4: Informal Reasoning Is Basically a Proof

When Rust's lifetime rules were designed, they were argued carefully, by smart people, with lots of examples. That is informal reasoning, and it's how essentially all production compilers are built. The result is a system that's sound as far as anyone can tell — which, we've now learned twice over, is not the same as sound.

The gap between the two is where projects like RustBelt and the ongoing work on a next-generation trait solver come in. RustBelt, built in Coq (now Rocq) on the Iris separation-logic framework, machine-checked a model of Rust's core typing rules — including the scary parts like unsafe abstraction boundaries. That was a landmark. But note what it proved: a model of Rust, not rustc. The proof covers the λRust calculus, and the production compiler has drifted, grown features, and accumulated implementation decisions that no one has re-verified. This issue is the bill coming due on that drift.

Formalizing the whole of a production compiler is brutally expensive. CompCert, the formally verified C compiler, took years of expert effort for a language far smaller than Rust, and it verifies compilation passes rather than proving the source type system catches every bad program. Nobody is going to write a Coq proof of rustc's two million lines. The realistic path looks more like defense in depth:

  • Machine-checked models of core rules (RustBelt-style), so the theory itself has no holes.
  • Systematic fuzzing and case-analysis tooling that generates adversarial programs — the feature-interaction corners humans don't think to test.
  • Mutation and property-based testing of the checker: if flipping a bound should make a program fail, assert that it does. SQLite's testing culture is the model here.
  • A maintained list of known unsound issues — Rust has over a hundred tagged — treated as a backlog with engineering priority, not folklore.

Interestingly, LLM-based search may end up being a net positive here: a tool that tirelessly composes features in perverse ways is exactly the adversary informal reasoning lacks. The endgame many of us hope for is a formally specified Rust with a checker proven against the spec — but between here and there, the work is grinding, unglamorous constraint-solving.

Myth 5: If Soundness Isn't Absolute, Static Safety Is a Con

The cynical reading goes: Rust promised safety, the promise had a loophole, therefore the promise was marketing and we might as well go back to YOLOing pointers in C. This is exactly backwards, and it's worth saying why with some precision.

Soundness in a type system is a theorem about a mathematical object. What a production language gives you is a statistical and economic guarantee: entire classes of bugs — use-after-free, data races, iterator invalidation — go from "constant background radiation of your codebase" to "events rare enough to make the front page when found after eleven years." Compare the base rates. Memory-safety bugs in large C and C++ codebases account for the majority of severe CVEs year after year, and nobody is surprised by any individual one anymore. Rust's unsoundness bugs are so rare that each one gets its own GitHub issue, its own discussion thread, and its own postmortem culture. Rarity you can enumerate is a different world from rarity you can't.

There's also a difference in how failures compound. In C, every line is a potential memory-safety hazard, so the cost of vigilance scales with code size forever. In Rust, vigilance concentrates at the edges: unsafe blocks, FFI, and (as we now know) the compiler's own checker. That concentration is what makes auditing, tooling like Miri, and formal methods tractable at all. The guarantee isn't binary; it's a drastic reshaping of where your risk lives.

A type system with a known bug that eleven years of production traffic never hit is still infinitely stronger than a language where the bug is the design.

What You Should Actually Take Away

If you write Rust, you don't need to change anything. Your code wasn't silently unsafe before this issue was filed; the practical exploit path required intent. If you maintain libraries, the honest move is what it always was: keep unsafe surface small, run Miri on the tricky bits, and don't treat the borrow checker as an oracle for other people's malicious code — treat it as an extremely reliable tool for catching your own mistakes.

If you build languages or compilers, this bug is a gift — a rare, well-documented specimen of how soundness actually fails in production. Not in the theory, but in the enforcement; not in a single feature, but at a feature intersection; not caught by review, but by adversarial construction. The teams that take that seriously — funding trait-solver rewrites, region-constraint rework, and verification-adjacent tooling — are the ones whose guarantees will mean something in ten years.

And for everyone: recalibrate what a guarantee is. We've confused "proven" with "argued convincingly and never yet contradicted" for decades, across type systems, cryptographic protocols, and distributed consensus. The grown-up version of trust isn't assuming the bridge is perfect. It's knowing the bridge has inspectors who publish their findings, fix what they find, and keep a public list of the cracks they haven't fixed yet. On that score, eleven years to find a hole isn't an indictment. It's evidence the system — the human one around the compiler — is working roughly the way it should.