11 year old unsoundness bugs found in Rust borrow checker

Source: github.com
632 points by formerly_proven 12 hours ago on hackernews | 239 comments

Description

@jsnell

In the following code:

use std::sync::Mutex;
static KEPT: Mutex<Vec<Box<dyn std::any::Any + Send>>> = Mutex::new(Vec::new());
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)); } }
}
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::<&'static String>().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

  1. theemathas commented on Oct 13, 2026

    @theemathas

    The core of the unsoundness:

    struct Q<T: 'static>(Option<T>);
    
    impl<T: 'static> Drop for Q<T> {
        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);
    }

    Reacted by Daniel Vogelbacher, Serhiy and BioTomateDE

  2. rustbot commented on Oct 13, 2026

    @rustbot

  3. theemathas commented on Oct 13, 2026

    @theemathas

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

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

    Reacted by Max, Alisa Sireneva, Leon, maxbry-uh, Luke, slaiyer, Screeching Bagel, Slanterns and maliciousblahaj

  4. theemathas commented on Oct 13, 2026

    @theemathas

  5. lcnr commented on Oct 13, 2026

    @lcnr

    without relying on a static

    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}");
    }
  6. lcnr commented on Oct 13, 2026

    @lcnr

    Reacted by Rémy Rakic, D. Savchenko, Mathijs Follon, FelixKLG, Alex Gershberg, Kamilla 'ova, Tux3, slaiyer and Daniil Kondratev

  7. rustbot commented on Oct 13, 2026

    @rustbot

    Assigning P-high (discussion on Zulip).

    Reacted by Jeremy Lempereur

  8. theemathas commented on Oct 13, 2026

    @theemathas

  9. maxdexh commented on Oct 13, 2026

    @maxdexh

    Another version that segfaults/prints freed memory since 1.4

    struct Q<T, F: FnMut(&T)>(Option<T>, F);
    
    impl<T, F: FnMut(&T)> Drop for Q<T, F> {
        fn drop(&mut self) {
            self.1(self.0.as_ref().unwrap())
        }
    }
    
    fn main() {
        let mut out: Option<&'static _> = None;
        {
            let l = vec![vec![1]];
            let mut q = Q(None, |s| {
                out = Some(*s);
            });
            q.0 = Some(&l);
        }
        println!("{:?}", out);
    }
  10. changed the title

    [-]struct's `T: 'static` bound bypassed via covariant field assignment[/-] [+]struct's lifetime requirements can be bypassed via covariant field assignment[/+]

    on Oct 13, 2026
  11. theemathas commented on Oct 13, 2026

    @theemathas

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

    use std::time::Duration;
    use std::thread;
    
    struct Q<'a: 'static>(Option<&'a String>);
    
    impl<'a: 'static> Drop for Q<'a> {
        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));
    }
  12. maxdexh commented on Oct 13, 2026

    @maxdexh

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

    struct Q<T: Copy, F: FnMut(T)>(T, F);
    
    impl<T: Copy, F: FnMut(T)> Drop for Q<T, F> {
        fn drop(&mut self) {
            self.1(self.0)
        }
    }
    
    type T = Vec<Vec<i32>>;
    
    fn main() {
        let long = Box::leak(Box::new(T::new())) as &'static T;
        {
            let local: T = vec![vec![1]];
    
            // item type is Q<&'local T, fn(&'static T)>, which is not WF
            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);
    }