TUESDAY, OCTOBER 13, 2026|No. 18573
Technology · Security

Rust Borrow Checker Unsoundness Discovered After 11 Years

A significant bug in Rust's borrow checker, present for 11 years, has been identified, raising questions about the language's memory safety guarantees.

The Rust programming language logo displayed on a computer screen.
The Rust programming language logo displayed on a computer screen. · Photo by Chamfjord. on Unsplash
1 sources
Pipeline ingest
3 reads
Positive / Neutral / Negative
0 countries
Related coverage

struct's lifetime requirements can be bypassed via covariant field assignment

Description

@jsnell

jsnell

opened 12h agoon Oct 13, 2026

Note

Given that this issue has been shared online and is currently getting quite a few visitors, here's some additional context.

Rust's type system is intended to prevent memory unsafety. Upholding this promise is something we take very seriously, which is why we've been working on things like the next-generation trait solver, the region constraint rework, and more generally Project Zero. It is a large priority of the Types Team to fix all type system unsoundnesses and to work towards there being a solid foundation to minimize the risk of unknown ones.

This bug is a P-high issue and would have been P-critical regardless of whether people encountered it in the wild. The reason that it is "only P-high" is that 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.

Looking at the bug itself, it is also very likely that this unsoundness has not caused memory unsafety by accident. While it looks easy to trigger, to exploit this you need an intentionally malicious Drop implementation.

~ @lcnr


In the following code:

use std::sync::Mutex;
static KEPT: Mutex>> = Mutex::new(Vec::new());
struct Q(Option);
impl Drop for Q {
 fn drop(&mut self) { if let Some(t) = self.0.take() { KEPT.lock().unwrap().push(Box::new(t)); } }
}
fn main() {
 {
 let l = String::from("hello world, this is long enough");
 let mut q = Q(None);
 q.0 = Some(&l);
 }
 let _z = String::from("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
 let k = KEPT.lock().unwrap();
 println!("{}", k[0].downcast_ref::().unwrap());
}

I expected to see this happen: program is not accepted

Instead, this happened: program compiles and prints XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX. With asan, the program will crash with a AddressSanitizer: stack-use-after-scope

Meta

Issue on current nightly, and stables at least down to 1.85.0

Godbolt with asan: https://rust.godbolt.org/z/83MvfbKde

Godbolt without asan: https://rust.godbolt.org/z/GsGdqqMWe

Activity

theemathascommented11 hours agoonOct 13, 2026

The core of the unsoundness:

struct Q(Option);

impl Drop for Q {
 fn drop(&mut self) {
 // Code in here has access to a non-'static &String
 // but assumes that it's a &'static String
 }
}

fn main() {
 let l = String::new();
 let mut q = Q(None);
 q.0 = Some(&l);
}

theemathascommented11 hours agoonOct 13, 2026

My version of the code has been compiling ever since 1.18.0 (2017)

How on earth was this not found before this???

theemathascommented11 hours agoonOct 13, 2026

Possibly related: #128225

lcnrcommented11 hours agoonOct 13, 2026

without relying on a static

struct MyType(&'c mut &'a str, &'b str);

impl Drop for MyType {
 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}");
}

maxdexhcommented11 hours agoonOct 13, 2026

Another version that segfaults/prints freed memory since 1.4

struct Q(Option, F);

impl Drop for Q {
 fn drop(&mut self) {
 self.1(self.0.as_ref().unwrap())
 }
}

fn main() {
 let mut out: Option = None;
 {
 let l = vec![vec![1]];
 let mut q = Q(None, |s| {
 out = Some(*s);
 });
 q.0 = Some(&l);
 }
 println!("{:?}", out);
}

theemathascommented11 hours agoonOct 13, 2026

Another version, cuz why not. I think this version is probably the easiest to understand.

use std::time::Duration;
use std::thread;

struct Q(Option);

impl Drop for Q {
 fn drop(&mut self) {
 // Code in here has access to a non-'static &String
 // but assumes that it's a &'static String
 let wrong: &'static String = self.0.unwrap();
 thread::spawn(move || {
 thread::sleep(Duration::from_secs(1));
 println!("{}", wrong);
 });
 }
}

fn main() {
 {
 let l = String::from("abc");
 let mut q = Q(None);
 q.0 = Some(&l); // q.0 is supposed to be a &'static String, but we're assigning it a non-'static &String
 }
 thread::sleep(Duration::from_secs(2));
}

maxdexhcommented10 hours agoonOct 13, 2026

I think unification just generally doesn't check lifetime bounds of the type. Example with unifying coercion, without field assignment:

struct Q(T, F);

impl Drop for Q {
 fn drop(&mut self) {
 self.1(self.0)
 }
}

type T = Vec>;

fn main() {
 let long = Box::leak(Box::new(T::new())) as &'static T;
 {
 let local: T = vec![vec![1]];

 // item type is Q, which is ill-formed
 let mut unified = [
 Q(
 long,
 (|s: &'static T| {
 std::thread::spawn(move || {
 println!("{s:?}");
 });
 }) as fn(&'static T),
 ),
 Q(&local, (|_: &T| {}) as fn(&T)),
 ];

 let [l, r] = &mut unified;
 std::mem::swap(&mut l.0, &mut r.0);
 }

 std::thread::sleep_ms(1000);
}

apirainocommented7 hours agoonOct 13, 2026

Hello everyone, mod here.

We're preventively locking this issue to prevent spam and other unhelpful comments

PAN's pipeline reviewed approximately 1 open sources for this article. No human editor reviewed this article before publication.

Related Reads

Show on timeline →

Earlier on PAN

More in Technology →