programming languages

How a Rust Borrow-Checker Bug Lets Dead References Escape

How a Rust Borrow-Checker Bug Lets Dead References Escape

A Rust program can pass through the compiler with no unsafe block, stash a reference to a local String in a global container, let that String go out of scope, and then read through the reference. That is exactly the class of failure Rust’s borrow checker—a compile-time analysis that tracks references and how long they remain valid—is meant to prevent.

Issue #164250, opened on October 13, 2026, reports this behavior at the intersection of lifetimes, variance, field assignment, and destructors. A lifetime is the compiler’s name for the interval during which a reference is valid. The initial report reproduces on the development compiler and on stable releases back to Rust 1.85. Later discussion includes related variants reported to work as far back as Rust 1.18, and another dating to Rust 1.4, released on October 29, 2015. The exact origin still needs investigation, but the flaw may be nearly 11 years old.

The small program with the large consequence

Here is the shape of the reported reproducer, with the names shortened slightly:

use std::sync::Mutex;

static STASH: Mutex<Vec<Box<dyn std::any::Any + Send>>> =
 Mutex::new(Vec::new);

struct Holder<T: 'static + Send>(Option<T>);

impl<T: 'static + Send> Drop for Holder<T> {
 fn drop(&mut self) {
 if let Some(value) = self.0.take {
 STASH.lock.unwrap.push(Box::new(value));
 }
 }
}

fn main {
 {
 let local = String::from("hello world, this is long enough");
 let mut holder = Holder(None);
 holder.0 = Some(&local);
 }

 let _replacement = String::from("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
 let stash = STASH.lock.unwrap;
 println!("{}", stash[0].downcast_ref::<&'static String>.unwrap);
}

The generic parameter T is a placeholder for some concrete type. The bound T: 'static does not mean the Holder value itself must live forever. It means that T cannot contain a reference that expires before the entire program could end. A borrowed reference to a local variable should therefore never satisfy this bound.

The other pieces make the bug visible. Drop is Rust’s destructor mechanism: its method runs when a value is being destroyed. Option<T> lets the destructor call take, moving the value out and replacing it with None. dyn Any is a type-erased container that can hold different concrete types and recover one later with a downcast. The Send bound says the stored value is safe to move between threads, while the Mutex protects the shared vector. None of those tools extends the lifetime of local.

The inner block is important. Rust drops local variables in reverse declaration order, so holder is destroyed while local is still alive. Its destructor moves the reference into STASH. Then the block ends and local is destroyed. The reference in the global vector now points at a dead stack variable.

The later _replacement often occupies the same stack slot. That is why the example may print a row of X characters instead of failing immediately: the dangling reference happens to observe the new String object. AddressSanitizer, a runtime memory-error detector, reports the more honest result: stack-use-after-scope. Once a program reads through a reference to destroyed data, its behavior is undefined. Rust no longer promises what happens next.

Why 'static should have stopped it

Why did the borrow checker miss a dangling reference when the type literally says 'static? The answer involves a subtle gap between several compiler rules.

A custom Drop implementation is allowed to run arbitrary code while the value is being destroyed. Because of that, Rust’s drop-check analysis normally requires the generic types and lifetimes used by a destructible value to remain valid at the point where its destructor runs. In this example, the Drop implementation is written under the assumption that every possible T is 'static. That assumption is what makes sending value into a global Any container appear valid.

The problem is that the field assignment appears to let a shorter-lived reference enter the value anyway. The compiler accepts holder.0 = Some(&local) even though the enclosing Holder<T> carries a stronger lifetime requirement. The destructor then receives a value that violates the assumption under which it was type-checked.

Covariance is normally the good part

The word behind the issue is variance. A generic type is covariant when a subtype relationship between its parameters passes through the wrapper. A subtype is a type that can safely stand in for another type.

References are covariant over their lifetimes. A reference that remains valid for a long time can be used where a shorter borrow is expected:

let permanent: &'static str = "hello";
let temporary_view: &str = permanent;

That flexibility is essential. Without it, ordinary functions could not accept long-lived data when they only need to use it briefly. Option<T> is also covariant in T, so a longer-lived reference can normally be wrapped in an Option whose expected lifetime is shorter. Since Holder<T> contains T inside Option<T>, the enclosing struct inherits that covariance.

Covariance itself is not the mistake. The trouble begins when the compiler applies that flexibility during a write to a field while also needing to preserve a lifetime requirement attached to the surrounding struct.

Assignment is where the seam shows

Rust normally prevents this classic problem:

fn assign<T>(slot: &mut T, value: T) {
 *slot = value;
}

If slot contains a reference known to be valid forever, assign must not be able to replace it with a reference that expires in a moment. That is why mutable references are invariant over the values they contain: the compiler cannot freely substitute one lifetime for another when a place can be overwritten.

The reported bug concerns a different route. An assignment to holder.0 is checked through a field path whose type is covariant, and the current compiler behavior appears not to re-enforce the Holder<T> bound strongly enough at that point. In effect, a short-lived reference can enter a field while the destructor continues to be treated as if T: 'static were fully guaranteed.

Nothing dynamic is happening, and the variable is not changing type at runtime. This is a static type-checking failure at the boundary between the field’s variance and the enclosing struct’s lifetime requirements. That boundary is easy to miss because each individual rule is useful and familiar in isolation.

A narrow bug with a serious lesson

The global vector is only a spotlight. The issue discussion also shows variants that pass the escaped reference to a callback or a spawned thread. Any code that keeps the reference alive beyond the local variable’s scope can expose the same underlying problem.

At the time of writing, the issue is open, marked high priority, and has no public fix attached. That does not mean ordinary Rust code has become unreliable. The pattern requires an unusual combination: a generic lifetime bound, a covariant field, mutation through that field, and a custom destructor that carries the value somewhere else.

For library authors, the combination deserves caution. Lifetime-sensitive fields should generally remain private, with writes controlled by methods that preserve the abstraction’s invariants. Code involving custom Drop implementations and 'static bounds is also a good candidate for testing with AddressSanitizer. Sanitizers cannot make an invalid program safe, but they can reveal when a compiler assumption has allowed a dangling pointer to escape.

Rust’s safety story depends on many rules agreeing with one another: borrow checking, subtyping, variance, type validity, and drop checking. Issue #164250 matters because it appears to show a missing connection between those rules. The lesson is not that lifetimes are pointless; it is that even a mature safety system must keep testing the seams where its guarantees meet.

ahsan

ahsan

Hello! I am Mr Ahsan, the writer of the Website. I am from Netherland. I like to write about technology and the news around it.

Comments (0)

No comments yet. Be the first to respond!

Leave a Comment

Your comment will be visible after review.