Breaking the borrow checker


Today we are going to do horrifically unsafe things and break Rust *shoked bird face*!!! Of course, you can't break the borrow checker using safe Rust code[1]--you have to rely on unsafe code blocks. And it is surprisingly easy to write broken code with it. It requires a tiny bit of knowledge in Rust lifetimes to understand what the problem is, but that's what we're for!


Lifetimes 101


The borrow checker is what keeps your Rust cost safe from memory vulnerabilities. It is also what shouts at you for every little wrong thing you're doing when compiling. The way it works among other things is waves hands by looking at the lifetimes of every object and see if you're not doing anything incorrect with those. In Rust, everything secretly has lifetimes. When you create an object, the compiler attributes it a lifetime. When you borrow that object, it gives that reference another lifetime, like so:


let thing: u64 = 1;  // lifetime 'a
let stuff = &thing;  // lifetime 'b
println!("{stuff}");
drop(stuff);         // after this point 'b is gone
println!("{thing}"); // but it's fine, 'a is still there

In this case the compiler enforces that 'b cannot outlive 'a. In other words, the lifetime of 'a must always be equal or greater than 'b. If that weren't the case, then you'd have an issue where the instance stuff still exists, but thing is gone, so what value is stuff = &thing supposed to have?


The compiler figures out a lot of those things for you, but not always. Let's look at some real examples where you will have to deal with lifetimes yourself.


Lifetimes in structs


Let's say you want to create a struct that contains a reference:


struct Thing {
    value: &u64
}

You may have different instances of Thing with different lifetimes, and the compiler cannot guarantee that the lifetime of Thing will always outlive the &u64 borrow. To fix this, you need to pass the lifetime of &u64 as a lifetime parameter in the struct:


struct Thing<'a> {
    value: &'a u64
}

Forcing the lifetime of Thing to be the same as the value reference is perfectly fine, because 'a won't outlive itself.


Lifetimes in functions


Lifetimes are also important in function arguments. Consider the following function:


pub fn some_number() -> &u64 {
    let number: u64 = 3;

    return &number;
}

This won't work, because the compiler enforces that the &number we're returning can't outlive number since it borrows it. But it's our function's scope that owns number! And after the function returns, the scope ends, and number will be destroyed. So you can't return a reference to it. Rust figures that out. In C, you'd get a dangling pointer. In Rust, you get complaints by the borrow checker.


Returning a static lifetime &'static str will work because 'static by definition outlives the entire program, so you'll never have issues.


That doesn't mean you can never return references though. It all depends where the borrow happens. For instance, this will work:


#[derive(Debug)]
struct Thing<'a>(&'a u64);

fn value_of(thing: Thing) -> &u64 {
    thing.0
}


fn main() {
    let thing: Thing = Thing(&1);
    println!("{:?}", value_of(thing));
}
Try it out on the Rust Playground

How is that possible? It's possible because the compiler figured out that Thing has the same lifetime as thing.0, thanks to our requirement we specified in struct.


When desugared, the value_of function is like this:


fn value_of<'a>(thing: Thing<'a>) -> &'a u64 {
    thing.0
}

When the compiler figures out lifetimes in a way that doesn't force you to specify it, we say those lifetimes are elided.


Breaking the borrow checker


Now is time to break the borrow checker. What would happen if we wanted to change the lifetime of something? Let's make a function that tries that.


fn stuff<'a, 'b>(slice: &'a str) -> &'b str {
    &slice
}

We defined two independant lifetimes 'a and 'b. We take the input with lifetime 'a and try to return it as something with lifetime 'b. Again here, the compiler doesn't know what to do. It can't know whether 'b will outlive 'a or not. If 'b doesn't outlive 'a, this is fine, but if it does, then we're going to eventually get in a &'b str reference in our code even though 'a was dropped/destroyed. Dangling pointer again. In this case, 'b is an unbounded lifetime.


The compiler tells us what we should do here: we once again need to specify that 'b can't outlive 'a:


help: consider adding the following bound: 'a: 'b


This is where the unsafe code comes in. We can do a bit of silly and use std::mem::transmute to change our &'a str into &'b str in our stuff function, giving it the 'b lifetime. Because this is unsafe, you're allowed, but you're on your own. The compiler won't be able to help you on your silliness.


fn stuff<'a, 'b>(slice: &'a str) -> &'b str {
    unsafe {
        let new_slice: &'b str = std::mem::transmute(slice);

        new_slice
    }
}

Let's write an example to demonstrate how things break when you do that. Here is some code that dynamically allocates an integer on the heap using a Box, which calls free() on its contents when it gets dropped. Using the stuff function, we created an unbounded lifetime of pointer, resulting in a dangling pointer after our original input boxed gets dropped:


fn stuff<'a, 'b>(slice: &'a Box<u64>) -> &'b Box<u64> {
    unsafe {
        let new_slice: &'b Box<u64> = std::mem::transmute(slice);

        new_slice
    }
}

fn main() {
    let boxed = Box::new(1);
    let pointer = stuff(&boxed);
    println!("cool: {:?}", pointer);
    drop(boxed); // now `pointer` is dangling, wooops...
    println!("dangling: {:?}", pointer);
}
Try it out on the Rust Playground

This is the output:


$ cargo run --quiet
cool: 1
dangling: 0

Memory leaks, but ~safe~


In Rust, memory leaks aren't actually considered a memory vulnerability, because they won't actually cause real issues besides just using up memory for no reason. In safe Rust, std::mem::forget allows you to "forget" an object without calling the Drop mechanisms it, permanently leaking it.


But more usefully, you can also use Box::leak to store an object on the heap, and then leak it, returning a reference to that memory location which will never get dropped. If you call Box::leak on an object T with lifetime 'a, this will return a &'b mut T reference if you need one, where 'b: 'a ('b outlives 'a). There are no limits besides that, and you can even make 'b a 'static lifetime. Thanks, unsafe shenanigans! (Although Rust actually binds it in a safe way so it's not unbounded)


A crate to break stuff more easily


There is a crate named you_can that uses macros create unbounded lifetimes on objects, essentially "disabling" the borrow checker, since it will stop complaining about everything.


The [you_can::turn_off_the_borrow_checker] macro

The moral of the story is that though obvious as it may seem, you should always be careful about lifetimes when dealing with unsafe code blocks in Rust. It's quite easy to end up accidentally creating unbounded lifetimes. Best case, you get a fake 'static lifetime that leaks memory. Worst case, you get use-after-free bugs. If you can, rely on lifetime eliding whenever possible (in other words, try not to desugar too much), because the compiler will bind lifetimes when doing that.




Footnotes


[1]: Unless the borrow checker has soundness issues. It happens, but it's very unlikely to cause issues in practice.

See https://github.com/Speykious/cve-rs