On 2026-10-13 at 08:45:54Z, an issue was filed against rust-lang/rust with the title “struct’s lifetime requirements can be bypassed via covariant field assignment”. Within fifteen minutes the type team had it labelled P-critical; by 09:25 it was P-high and marked I-unsound. A Hacker News submission the same morning reached 667 points and 373 comments. The issue number is #164250.
The reason a compiler bug of this kind attracts that much attention is not the bug. It is the code that triggers it. The reproducer contains no unsafe, no transmute, no raw pointers, and no FFI. It is the kind of struct-plus-Drop pairing that appears in ordinary Rust, and as of this writing every released rustc and every nightly accepts it.
I did not settle for reading the issue and the thread. I bisected the compiler across versions, compiled the reproducer locally, and ran it under Miri and AddressSanitizer. Everything below that is stated as a measurement is something I ran on 2026-10-13/14, and the commands and outputs are reproduced so you can check them yourself.
The reproducer
Four distinct reproducers appeared in the issue thread within an hour. This is the one that needs neither a static nor a second thread, and it is the version that proves the failure is about bounds, not about concurrency:
struct MyType<'a, 'b: 'a, 'c>(&'c mut &'a str, &'b str);
impl<'a, 'b: 'a, 'c> Drop for MyType<'a, 'b, 'c> {
fn drop(&mut self) {
*self.0 = self.1;
}
}
fn main() {
let mut me_out = "rarw";
{
let l = String::from("some cool string");
let mut q = MyType(&mut me_out, "");
q.1 = &l;
}
println!("me_out: {}", me_out);
}
The struct declares 'b: 'a. The Drop implementation is allowed to assume it. It writes self.1 — nominally a &'b str that outlives the &'a str in self.0 — into the slot that me_out will be printed from.
Then main assigns &l to q.1, where l is a String inside a scope that ends two lines later, and me_out is printed after that scope has ended. The compiler accepts all of it.
There is no unsafe keyword in that program. Run the compiler on it and you get a clean build and exit code 0.
What actually happens when you run it
I installed the current stable toolchain (rustc 1.99.0, commit b940084d7, 2026-09-28) and compiled three demos: the original issue’s static-plus-Mutex PoC, the scope-escape Drop above, and a version that hands the fake 'static reference to another thread. All three compiled with zero warnings about soundness, and all three produced garbage:
| Demo | Mechanism | Observed result on rustc 1.99.0 |
|---|---|---|
| Type confusion | Option<T> with T: 'static + Send, Drop pushes Box<dyn Any> to a static Mutex | Prints heap bytes instead of the stored string |
| Scope escape | 'b: 'a bound, Drop copies self.1 into self.0 | Prints garbage; the read goes through a dangling &str |
| Cross-thread | 'a: 'static bound, Drop spawns a thread holding &'static String | Second thread reads the freed String — and println! itself panics |
The third demo is the most instructive, because the failure is not “wrong bytes printed”. It is a violated String invariant:
thread '<unnamed>' panicked at library/core/src/fmt/mod.rs:2949:38:
start byte index 17 is out of bounds for string of length 16
A String whose length field says 16 was produced from a value the compiler believed was a &'static String. The panic is not the bug; it is a downstream symptom of reading a heap block that had already been reallocated. Neither build nor runtime produced a single diagnostic that referenced the actual problem.
The version boundary, measured instead of asserted
The issue thread contains a claim — “unsound since 1.4” — and a correction from a compiler contributor — “unsound since 1.4 🤣”. Claims like this are cheap. I bisected it with Godbolt’s compiler API, which exposes 124 rustc toolchains from 1.0.0 forward, and compiled the same source against each.
The first sweep produced a false result that is worth flagging, because anyone reproducing this will hit it: every version before 1.58 rejected the program, but for the wrong reason. Rust 1.58 stabilised captured identifiers in format strings, so println!("me_out: {me_out}") fails to compile on 1.32 with “there is no argument named me_out” — not with a borrow-check error. Rewriting the print to println!("me_out: {}", me_out) removes the artifact and exposes the real boundary.
| Variant | First version that accepts | Last version that rejects | Rejection reason |
|---|---|---|---|
With explicit 'a: 'c bound on struct and impl | 1.4.0 (tagged 2015-10-29) | 1.3.0 (2015-09-17) | E0597: 'l' does not live long enough |
Without the bound (&'c mut &'a str, 'b: 'a) | 1.31.0 (tagged 2018-12-06) | 1.30.0 | E0491: reference has a longer lifetime than the data it references |
The two boundaries matter because they are different bugs with the same root. The 1.4.0 boundary is the one the issue title is about: the 'b: 'a bound on the struct is not proven when the compiler builds the local’s type. The 1.31.0 boundary is the same hole reached without an explicit bound: the E0491 structural check that used to catch &'c mut &'a str stopped firing.
Every sampled release from those boundaries to the present accepts the program: 1.35.0, 1.40.0, 1.50.0, 1.60.0, 1.70.0, 1.80.0, 1.85.0, 1.90.0, 1.95.0, 1.96.0, 1.97.0, 1.98.0, 1.99.0, and the nightly builds.
From the 1.4.0 tag to the moment the issue was filed is 4,001 days — 10.95 years. From 1.31.0 it is 2,867 days. If you have read that this is an “11-year-old bug”, that figure is real, and it is precise: the first stable release that should have rejected the program is 1.4.0, released 2015-10-29, and from 1.4.0 through 1.99.0 there were 96 minor stable releases that all accepted it.
Miri and ASan both catch what rustc does not
This is the part with immediate practical value. Rust ships two dynamic tools that detect exactly this class of mistake, and both of them flag the code the compiler calls sound.
Miri, on the same source, on nightly 1.101.0-nightly (e9bbadf68 2026-10-12):
error: Undefined Behavior: constructing invalid value of type &std::string::String:
encountered a dangling reference (use-after-free)
Miri reports it in demo1, the first demo, before the program has printed anything. It does not need a sanitizer build, thread scheduling luck, or a segfault to notice; the interpreter knows the allocation is gone.
AddressSanitizer, via rustc +nightly -Zsanitizer=address:
==47175==ERROR: AddressSanitizer: stack-use-after-scope on address 0x71bcb0bf0408
READ of size 8 at 0x71bcb0bf0408 thread T0
SUMMARY: AddressSanitizer: stack-use-after-scope
That is the identical sanitizer class the original reporter cited — AddressSanitizer: stack-use-after-scope — reproduced independently on a different machine and toolchain.
Note what this pairing means for a codebase today. The compiler’s static analysis is the only layer that is wrong; both dynamic layers are correct. A CI job that runs cargo miri test and a sanitizer pass would have caught this pattern in the module that contains it, even while cargo build was happily certifying it.
Why the compiler accepted it
The failure is in one place: MIR borrow-check’s type checker never asked whether the MIR local’s type is well-formed.
Rust’s type checker runs in two passes. hir_typeck handles the source-level types, and it does prove well-formedness there — that is why plain named locals with, say, struct RequiresStatic<T: 'static> are normally rejected. MIR borrow-check then re-checks a much smaller set of properties. Well-formedness of locals and temporaries was simply not one of them, so a Q<&'short String> local whose declaration demands T: 'static slipped through, and the bound became an unproven assumption that Drop was entitled to rely on.
The deeper reason a single missing check is this destructive: a lifetime bound is exactly the kind of thing that Drop is allowed to assume. When impl<T: 'static> Drop for Q<T> runs, the body has permission to move the value into a static, spawn a thread with it, or hand out a &'static reference, because the bound was supposed to guarantee the referent outlives the program. Once the bound can be bypassed at the assignment, every one of those permissions becomes a licence for use-after-free — which is precisely why the three demos in my run all fail through the destructor path rather than through an ordinary read.
One nuance deserves to be stated precisely, because the type team is right about it and the nuance is often lost in the retelling. The bound bypass itself needs no Drop at all. I compiled the bound-free variants on stable 1.99.0 and they build:
struct RequiresStatic<T: 'static>(Option<T>);
fn main() {
let text = String::from("temporary");
let mut value = RequiresStatic(None);
value.0 = Some(&text); // compiles on 1.99.0, no Drop impl, no unsafe
println!("compiled: {:?}", value.0);
}
That printed compiled: Some("temporary"). The same holds for a Cell<&'static ()>-structured variant with an 'a/'b: 'a pair. So the invariant violation is unconditional; what requires a Drop implementation is only the part where the violation becomes observable. If a codebase never writes a Drop impl for a type carrying a lifetime bound, the bug is latent rather than exploitable in that codebase — but the latent state is exactly what a later refactor turns into a use-after-free.
The fix is 8 lines. The bill is 39 files.
Pull request #164277, “require MIR local types to be well-formed”, was opened by a first-time contributor at 18:48:21Z on 2026-10-13 — 10 hours and 2 minutes after the issue was filed. The substantive change is eight lines in compiler/rustc_borrowck/src/type_check/mod.rs:
if self.body.local_kind(local) == LocalKind::Temp {
self.prove_clause(
ty::ClauseKind::WellFormed(local_decl.ty.into()),
Locations::All(local_decl.source_info.span),
ConstraintCategory::Boring,
);
}
The PR’s headline statistics tell a story that the eight lines do not:
| Metric | Value |
|---|---|
| Additions / deletions | +823 / −23 |
| Files changed | 39 |
Files in compiler/ | 1 |
Files in tests/ | 38 |
.stderr expectation files updated | 20 |
| State as of 2026-10-14 | open, mergeable_state: blocked, S-waiting-on-review |
| CI | test-tidy failed; test-x86_64-gnu-gcc failed on diagnostic diffs |
Read that table again. Twenty existing test expectations had to be rewritten, and 38 test files in total, to add one well-formedness proof for temporaries. That is a measurement of how much of rustc’s own test suite was implicitly encoding the missing check. It is also the most concrete available answer to the Hacker News commenter who asked “It will be interesting to see if fixing this bug actually causes any crates to not compile” — inside the compiler’s own test corpus, the answer is already 20 files deep.
The fix is also deliberately narrow, and the narrowness is the interesting design decision. It is gated on LocalKind::Temp, meaning it applies to temporaries rather than to every local, and the PR ships a check-pass test proving ordinary covariance still compiles:
let y = x; ywherex: &'a T— still fine.- Closure and
asyncclosure signatures that merely mention a lifetime — still fine. let mut value = Some("static"); value = Some(input); value— still fine.
The boundary is not “covariance is banned”. The boundary is whether the local’s type carries a bound that the instantiated arguments violate. Shrinking 'static to 'a stays legal; constructing Q<&'short String> where Q demands T: 'static now does not. That is the line the compiler should always have drawn, and the fix draws it in eight lines.
To be clear about status: as of this writing the PR is open and blocked, the reviewer was reassigned from mu001999 to the types group, and no release contains the fix. Anyone testing this on a toolchain they can install today will still see the program compile.
The same hole was already “fixed” once, in February 2026
Here is the part of the story that is not in the Hacker News thread’s top comments, and it changes how the age of the bug should be read.
Six months earlier, a different reporter filed #148854, “Lifetime bounds of Drop aren’t checked properly”, on 2025-11-12. Its opening line reads: “The below code causes use-after-free in safe code. (Prints garbage data in my testing.)” It carried the labels I-unsound, P-high, A-destructors, A-lifetimes, T-types. It was assigned P-critical and downgraded to P-high the same morning — a pattern that repeated almost exactly on 2026-10-13.
That issue was closed as fixed by PR #149389, “WF check lifetime bounds for locals with type params”, merged 2026-02-14T06:22:35Z (+123 / −26, 22 files), whose body states Fixes #115175 and Fixes #148854. Issue #115175 had been open since 2023-08-24 — 905 days.
So a well-formedness check for locals with type parameters landed in February 2026. Issue #164250 is the lifetime-only sibling of the same missing invariant: the February fix keys on uninstantiated type parameters, while the October reproducers instantiate to concrete Q<&'a String> types whose violation is in the lifetime arguments. The February fix was correct and it closed real unsoundness; it simply bounded its own scope, and the sibling case stayed open for another 241 days until someone wrote the four-line struct that made it obvious.
That is the actual finding here, and it is more useful than the “11 years” headline. The bug is 11 years old, but the class of bug was in front of the compiler team eight months ago, was declared fixed, and a variant was still shipping. The 11 years measure how long the code was accepted; the 241 days measure how long the false sense of closure lasted. The second number is the one to watch in your own project’s issue tracker.
And the same family has open siblings
Well-formedness of locals is not one check. It is a small family of checks, and several members are still open in rust-lang/rust:
| Issue | Title | Open since | Days open |
|---|---|---|---|
| #104478 | closures accept ill-formed inputs | 2022-11-16 | 1,428 |
| #123312 | closure rvalue not checked for WF allowing it to assume more implied bounds | 2024-04-01 | 926 |
| #128225 | mut struct member var(ref) bind to new obj still hint: does not live long enough | 2024-07-26 | 810 |
All three carry NLL-sound-family labels. #104478 and #123312 are both about closures — and in the #148854 thread, a compiler contributor had already noted that closure signatures are never checked for well-formedness unless they are called, and that fixing it “should be a separate PR however”. It was not that PR either.
The size of the queue is easy to measure. As of 2026-10-14 the rust-lang/rust repository carries 140 open issues labelled I-unsound (721 in total, 581 closed), plus 42 open A-destructors and 198 open A-borrow-checker. Exactly one issue currently holds P-critical: #163911, “Miscompilation with FFI bool return type on x86_64”, filed 2026-10-07 — a reminder that the slot #164250 briefly occupied is occupied by something equally capable of producing wrong machine code.
The counterweight is that the project says so out loud. The Project Zero goal, whose contact is lcnr and task owner is Boxy, states the target plainly: “Fix every known type system soundness bug in Rust, so that unsafe is the only way to cause undefined behavior.” Its design axioms are “Zero means zero” — “Not ‘fewer bugs’ or ’the important ones.’” — and “Migrate, don’t break”. Its stated motivation is not abstract: “if they are not fixed, one of them is likely to be the basis for a Rust exploit.”
The counter-arguments, stated at full strength
A fair reading of #164250 has to include the case for “this is less alarming than it looks”, because the strongest version of that case comes from the people who understand it best.
The type team’s position. lcnr annotated the issue the same morning: the bug would have been P-critical “regardless of whether people encountered it in the wild”, and it is only P-high “because it’s 11 years old. A bug like this which has not been found during the last 11 years cannot actually be that easy to hit.” On exploitability: “While it looks easy to trigger, to exploit this you need an intentionally malicious Drop implementation.” My own testing supports the mechanical half of that claim — the observable failures in my three demos all run through Drop, and the no-Drop variants compile but do nothing harmful. It says nothing about the accidental-code half, and the team does not claim it does.
“Nobody writes manual Drop.” Ygg2 on Hacker News: “Writing manual Drop isn’t usual. And writing drop with such lifetimes is definitely on the weirder scale.”
“Manual Drop is everywhere.” neysofu, replying in the same thread: “That’s easily the exception rather than the rule. I’ve seen custom Drop implementations in almost every non-trivial Rust codebase I worked on.” gspr adds the case that makes the point concrete: “A custom Drop appears very quickly with FFI code.” If you are writing a wrapper around a C library that requires an explicit release call, you have a hand-written Drop — and FFI wrappers are also where lifetimes and bounds get negotiated most aggressively.
“This is not a security vulnerability.” An early thread comment argued exactly that, on the grounds that “Rust explicitly does not defend from malicious programs, only from mistakes.” The same commenter then withdrew it after lcnr’s edit to the issue: “This edit is newer than my comment (and its writer is an experienced T-types member, so I take back my words).” A retraction in the thread is worth more than the original claim.
“It is an obscure constellation.” This is the claim the thread rejected most firmly. vincent-uden: “This is not the esoteric, crazy, impractical constellation I expected to find with a title like this. The minimal example is so simple and is definitely something you’d easily come across in normal code.” theemathas, who filed the issue and produced several reproducers: soundness bugs are not unusual, but “this one is unusual in how simple the code that causes it can be.”
“Certified toolchains are insulated.” They are not. Ferrocene is not a separate compiler implementation — it is rustc with a qualification package, a few releases behind. On Hacker News, afdbcreid: “It uses the same code, so yes.” And the specification question has a concrete answer, which I verified rather than repeated. The published Ferrocene specification at public-docs.ferrocene.dev contains the modality:
4.15.2:40 An assignment expression requires that the type of its value operand is a subtype of the type of its assignee operand.
I fetched the specification page and that clause is there verbatim, in the section titled “4.15.2. Subtyping and Variance — Legality Rules”. q.0 = Some(&l) violates a documented legality rule while the compiler accepts it. A certified toolchain is not a toolchain that cannot be wrong; it is a toolchain whose wrongness is documented as a legality rule and therefore traceable.
“Static guarantees are now worthless.” The opposite conclusion is better supported. The bug is a compiler bug, not a type-system bug: the type system already says the program is invalid, and the fix makes the compiler agree, without changing a single rule of the language. As GrantMoyer put it in the thread: “The compiler should always have been rejecting that code according to lifetime rules of Rust’s type system, so the fix is to just make it reject the code, rather than having to change the fundamental rules of lifetimes.” And medler, on the practical consequence: “It seems the bug here was caught by Asan instead of at compile time, so basically, for this one bug, rust is reduced to C++-like behavior. You’re still better off using rust because of all the bugs the borrow checker does catch.”
What to do this week
Ordered by cost-to-benefit, and based on the measurements above rather than on reassurance.
- Assume the toolchain you have is affected. I verified acceptance on every sampled stable release from 1.4.0 to 1.99.0 and on nightly 1.101.0-nightly (e9bbadf68, 2026-10-12), which predates the fix PR. The fix is in no release and the PR is still open. Version pinning does not help here; no currently installable stable is unaffected.
- Run
cargo miri testin CI for crates that define manualDrop. Miri caught this in the first demo with no sanitizer build and no luck. Scope it to the crates with lifetimes and destructors if full-workspace Miri is too slow. - Add a sanitizer pass.
-Zsanitizer=addressproduced the samestack-use-after-scopereport the original reporter saw, which means your existing integration tests already cover the trigger if you run them under ASan. - Audit the specific pattern. The condition to grep for is a local whose type carries a lifetime bound —
T: 'staticon a type parameter, or an explicit'b: 'arelation between the struct’s parameters — combined with a field assignment into that local. TheDropimplementation is what converts the violation into memory unsafety. This is a small, reviewable grep, not a rewrite. - Expect future-compatibility warnings, not a wall. Project Zero’s second axiom is “Migrate, don’t break”, and its 2026 goal list includes “Support emitting FCW for borrowck errors” so that closed-ness is re-run and reported as a warning before it becomes an error. A crater run of the public crates universe is the expected companion to a fix of this kind, which is the mechanism that will tell you whether your code is one of the crates affected.
- Do not treat “no production impact” as a durability argument. #148854 closed on 2026-02-14 with a fix that was real, scoped, and did not cover the sibling case; #164250 arrived 241 days later. Both things were true at once. If your threat model includes a 2027 audit, the relevant question is not “has this ever bitten anyone”, it is “is the invariant now enforced everywhere”.
FAQ
Does this give an attacker remote code execution in my Rust service?
Not by itself. To be exploitable, a program must construct the ill-formed local type and have a Drop implementation that relies on the bound the compiler failed to prove. That is a real configuration in any codebase with manual destructors and lifetime-bounded generics, and an attacker who contributes code to your project can write both halves — but the presence of the bug in the compiler is not a vulnerability in your binary until such code exists. The correct framing is latent unsoundness, not an active CVE.
Is this a bug in the Rust language, or in the implementation? The implementation. The published specification — the Ferrocene spec’s clause 4.15.2:40, which I checked verbatim — already requires this assignment to be rejected. The compiler has been failing to enforce a rule the language already has.
Which toolchain do I need to be safe?
None that exists yet. I confirmed acceptance through stable 1.99.0 and a nightly build dated one day before the fix PR was opened. Watch #164277 for merge status rather than watching release numbers, and expect an FCW phase before enforcement.
Why did the fix touch 38 test files if it is eight lines of code?
Because 20 existing .stderr expectations had to change to account for new diagnostics, and those files had been recording the old behaviour as correct. The count is the most direct available measure of how much rustc’s own test suite depended on the missing check. It is also the cleanest evidence for the “will this break crates?” question — inside the compiler, it already breaks twenty baseline files.
Is the “11 years” number accurate?
Yes, with a precise boundary, and it is worth stating precisely because it is usually stated loosely. The first stable release that accepts the reproducer with the explicit 'a: 'c bound is 1.4.0, tagged 2015-10-29T18:02:23Z; the issue was filed 2026-10-13, which is 4,001 days later. Without the explicit bound the entry point is 1.31.0, tagged 2018-12-06, or 2,867 days. Across the 1.4.0→1.99.0 window there were 96 minor stable releases.
Did the compiler team already know?
They had a different instance of the same missing invariant in hand: #148854, labelled I-unsound and P-high, closed as fixed by PR #149389 on 2026-02-14. That fix checked locals with uninstantiated type parameters. #164250’s reproducers are lifetime-only instantiations, which the February fix does not reach. Both statements are supported by the PR bodies and the issue timeline, and together they are the most important thing an engineering manager can take from this incident.
Where does this leave Rust’s “fearless concurrency” and safety claims? Intact, and more specific than before. Safe Rust’s promise is enforced by a compiler that has bugs, and one of them was found, scoped, reproduced, and fixed in about ten hours by a first-time contributor; two dynamic tools caught it the whole time; the fix is eight lines at the root. What changes is the honest caveat: “safe Rust cannot cause undefined behaviour” is a goal with 140 open exception tickets attached, and the project is on record that fixing every one of them is the plan.
The bottom line
A four-line struct with a lifetime relationship between its parameters, plus a Drop implementation that believes the relationship holds, has compiled on every stable Rust release since 1.4.0 — 4,001 days, 96 minor releases — and still compiles on stable 1.99.0 and on nightly as of 2026-10-12. When you run it, you get a dangling reference, corrupted heap bytes, and a String whose length field is larger than its allocation. Miri reports use-after-free and ASan reports stack-use-after-scope on the very first demo. No unsafe is involved at any point.
The fix is eight lines, opened ten hours and two minutes after the report, and it has not shipped. The test-suite bill is 20 rewritten expectation files among 38 changed, which is the most honest available measure of how far the missing check reached. And the same invariant was declared fixed in February 2026 for the type-parameter case, closed, and celebrated — while the lifetime-only case sat open for another 241 days.
If you take one thing from this, make it the 241 days rather than the 4,001. Long-lived bugs are a fact of compiler work and usually a sign that nobody’s code hits them. A sibling case surviving a “fixed” label is a process signal, and it is the one you can act on: when you close a soundness bug, write down which instantiations you proved and which ones you did not.