Developer tooling

Rust borrow checker unsoundness: how a field assignment bypasses Drop's lifetime bounds (rust#164250)

A safe-Rust use-after-free that has compiled since 1.4: how it works, which Drop impls are exposed, and how to close the hole today.

Kemo
Updated 7 min read
A crack on a wall by Brina Blum
On this page
  1. The program that should not compile
  2. The same borrow through a constructor is rejected
  3. What the borrow checker checks, and what it skips
  4. How long it has been there
  5. Who is actually exposed
  6. Find the types that rely on their bounds
  7. What a fix will mean for you

A Rust issue opened on 13 October, rust-lang/rust#164250, shows safe code that reads freed memory: no unsafe, no dependencies. Assign a short-lived reference to a struct field, and the struct's T: 'static bound, which its Drop impl relies on, is never checked. Contributors traced the bug back to Rust 1.4, released in October 2015, which is why the Hacker News thread calls it an 11-year-old bug. It is labelled I-unsound and P-high, and no fix has been merged yet.

You do not need to stop shipping Rust. But if your code has a Drop impl that trusts the struct's lifetime bounds, especially one that does unsafe work because of them, safe callers can now break it. This article shows the bug running on stable 1.97.0, explains what the compiler checks and what it skips, and gives you a way to find exposed types in your own code and close the hole without waiting for the compiler fix.

The program that should not compile

Here is a self-contained version of the report, written for this article from the pattern in the issue. It compiles without warnings on rustc 1.97.0 (2026-07-07) with both edition 2021 and edition 2024:

src/main.rsRust
use std::any::Any;use std::sync::Mutex; // Box<dyn Any> only holds 'static values, so everything in KEPT must be 'static.static KEPT: Mutex<Vec<Box<dyn Any + Send>>> = Mutex::new(Vec::new()); // The bound is a promise to the Drop impl: T holds no short-lived borrows.struct Q<T: 'static + Send>(Option<T>); impl<T: 'static + Send> Drop for Q<T> {    fn drop(&mut self) {        if let Some(v) = self.0.take() {            KEPT.lock().unwrap().push(Box::new(v));        }    }} fn main() {    {        let l = String::from("this text should be gone");        let mut q = Q(None);        q.0 = Some(&l); // accepted, although &l is not 'static    } // q is dropped first and stores &l in KEPT, then l is freed     let kept = KEPT.lock().unwrap();    let s: &&'static String = kept[0].downcast_ref().unwrap();    println!("{s}"); // reads freed memory}

Box::new(v) inside drop is only allowed because T: 'static; Box<dyn Any> cannot hold anything shorter. The borrow checker accepts q.0 = Some(&l) anyway. When the inner block ends, q is dropped first (locals drop in reverse order), its Drop impl stores &l in a static, and then l is freed. The last line prints memory that no longer belongs to the program. On our run it printed a few bytes of garbage followed by the tail of the original string.

AddressSanitizer confirms it, as it did for the original report. Sanitizers are a nightly-only flag; on a stable toolchain you can enable them for a throwaway check with RUSTC_BOOTSTRAP=1:

Terminal
$ RUSTC_BOOTSTRAP=1 rustc --edition 2024 -Zsanitizer=address src/main.rs -o uaf$ ./uaf==600==ERROR: AddressSanitizer: stack-use-after-scope on address 0x… at pc 0x… bp 0x… sp 0x…

The same borrow through a constructor is rejected

What makes this a compiler bug rather than a Rust design choice is that the same borrow, written another way, fails to compile:

src/rejected.rsRust
struct Q<T: 'static + Send>(Option<T>);impl<T: 'static + Send> Drop for Q<T> {    fn drop(&mut self) {}} fn main() {    let l = String::from("x");    let _q = Q(Some(&l)); // the same borrow, passed through the constructor}
Terminal
$ rustc --edition 2024 src/rejected.rserror[E0597]: `l` does not live long enough  |                this usage requires that `l` is borrowed for `'static`note: requirement that the value outlives `'static` introduced here1 | struct Q<T: 'static + Send>(Option<T>);  |             ^^^^^^^

The compiler knows the rule and points at the bound. It just does not apply it when the value goes in through a field assignment.

What the borrow checker checks, and what it skips

The issue does not yet contain a root-cause analysis, so treat this section as our reading of the evidence, not the Rust team's diagnosis.

When you write let mut q = Q(None);, the compiler infers q: Q<&'x String> for some lifetime 'x it has yet to pick. Calling the constructor Q(...) requires the type to be well formed, which includes T: 'static. With None there is nothing to borrow, so that check passes. The field assignment q.0 = Some(&l) is then checked as a plain assignment to a place of type Option<&'x String>: the borrow of l must outlive 'x. That is all. The requirement that 'x: 'static, which belongs to Q, does not come back into play, so 'x is allowed to shrink to the inner block. The Drop impl, which was type-checked once on its own against T: 'static, then runs on a value that does not meet its bound.

let mut q = Q(None)type Q<&'x String>q.0 = Some(&l)checks only 'l: 'xBound T: 'staticnot re-checkedDrop for Qassumes T: 'staticstatic KEPTholds &ll freed at end of blockprintln! reads KEPTuse after freeskippedend of scopeBox::new(&l)
  • let mut q = Q(None) → q.0 = Some(&l)
  • q.0 = Some(&l) → Bound T: 'static: skipped
  • q.0 = Some(&l) → Drop for Q: end of scope
  • Drop for Q → static KEPT: Box::new(&l)
  • Drop for Q → l freed at end of block
  • static KEPT → println! reads KEPT
  • l freed at end of block → println! reads KEPT
The field assignment satisfies the field's own type but never the struct's bound, and Drop trusts the bound.

The issue has since been retitled from "T: 'static bound bypassed" to "struct's lifetime requirements can be bypassed via covariant field assignment", and Rust contributor lcnr showed in the thread that 'static is not essential. Any outlives bound between a struct's lifetime parameters can be bypassed the same way. This is a version of lcnr's variant, again written for this article and compiled on 1.97.0:

src/outlives.rsRust
// 'b: 'a promises Drop that the second field outlives what the first points at.struct Swap<'a, 'b: 'a, 'c>(&'c mut &'a str, &'b str); impl<'a, 'b: 'a, 'c> Drop for Swap<'a, 'b, 'c> {    fn drop(&mut self) {        *self.0 = self.1; // fine, if 'b: 'a really holds    }} fn main() {    let mut kept: &'static str = "static";    {        let short = String::from("short-lived");        let mut s = Swap(&mut kept, "also static");        s.1 = &short; // accepted: 'b: 'a is not re-checked for the new value    } // s drops and writes &short into kept, then short is freed    println!("{kept}");}

It compiles, and kept, a &'static str, ends up pointing at a freed heap buffer.

How long it has been there

The versions below come from the issue thread. Nobody has bisected the exact commit yet.

FindingEarliest versionWho reported it
Original 'static program fails under ASan1.85.0 (oldest stable tested)jsnell, #164250
Minimal T: 'static version compiles1.18.0 (2017)theemathas, same thread
Lifetime-only variant compiles1.4.0 (2015)lcnr and theemathas, same thread
Our versions of both programs compile1.97.0 (checked for this article)k13

The bug sat there because writing a short-lived value into a field of a struct whose Drop depends on a lifetime bound is rare in practice. theemathas asked in the thread, "How on earth was this not found before this???" theemathas linked an older issue about field assignments, #128225, as possibly related. There, assigning to a field is rejected where replacing the whole value is accepted, and Ralf Jung suggests the cause is that assignments can change a variable's type.

A lifetime bound on a struct is only as strong as the paths that check it, and field assignment is not one of them.

Who is actually exposed

Writing the code above on purpose is easy. Writing it by accident takes three things at once:

  1. A Drop impl that does something only a lifetime bound makes safe: moving a value into a static or a global registry, sending it to another thread, spawning a task with it, or using unsafe to extend a lifetime because the bound says it is fine.
  2. A struct whose field of that generic type can be assigned directly by the caller. That means a public field, or code in the same module.
  3. A caller who assigns a borrowed value into that field.

The second condition is the one you control. We checked whether the hole crosses a module boundary: with pub struct Q<T: 'static + Send>(pub Option<T>); in one module, q.0 = Some(&l) from another still compiles. With the field private and a setter in an impl<T: 'static + Send> Q<T> block, the same caller gets E0597, because calling the method requires the bound:

src/stash.rsDiff
- pub struct Q<T: 'static + Send>(pub Option<T>);+ pub struct Q<T: 'static + Send>(Option<T>);++ impl<T: 'static + Send> Q<T> {+     pub fn new() -> Self { Q(None) }+     pub fn set(&mut self, v: T) { self.0 = Some(v); }+ }

That is the practical fix today. For a library, the risk is a type that has both public generic fields and a Drop with unsafe code that relies on its bounds. Any downstream user can then hit undefined behaviour from safe code, and it counts as your soundness bug as much as the compiler's.

Find the types that rely on their bounds

Start with Drop impls that carry a 'static bound or an outlives bound between lifetimes. This search finds bounds written inline in the impl header:

Terminal
$ rg -n --type rust -U "impl<[^>]*(:\s*'static|'\w+\s*:\s*'\w+)[^>]*>\s*Drop\s+for" src/

Bounds can also sit in a where clause. This second search finds Drop impls whose where clause mentions a lifetime:

Terminal
$ rg -n --type rust -U "impl<[^>]*>\s*Drop\s+for[^{]*where[^{]*'" src/

For each match, ask two questions. Does drop do anything that is only sound because of the bound (store, send, spawn, or unsafe)? Can code outside the type assign the generic field directly? If both answers are yes, make the field private and route writes through a method in an impl block that carries the same bound.

Dependencies deserve the same search. Run it over ~/.cargo/registry/src for the crates you ship. Most matches will be harmless, but a crate with public generic fields and an unsafe Drop is worth an issue upstream.

What a fix will mean for you

The fix will presumably make field assignments enforce the struct's own bounds. Code that compiles today only because of this bug, such as a short-lived reference written into a field that requires 'static, would then stop compiling. That is correct, but it would land as a new compile error after a toolchain upgrade.

Separately, the next borrow checker, Polonius, is described as close to stabilising, with its own soundness fixes still landing (Jack Huey). Nothing published so far says whether Polonius rejects the program in this article, so do not assume a Polonius switch fixes it.

The one thing to remember: a struct's lifetime bound protects its Drop impl only when every write goes through a path that checks it. Until the compiler is fixed, private fields are that path.

Discussion

Join the discussion

Sign in with Google, Apple or an email code to comment and reply.

One practical article a week

Follow k13 in any RSS reader. No account needed.