What the borrow checker let through

TL;DR — A review of Vx's borrow checker found eleven programs that compiled and then did something they should not. One printed 2 without optimization and 1 with it. Another, sent by a reader of this blog, changed a variable that was not declared mut, in safe code, because the checker let the inside of a &mut change type the way the inside of a & may. All eleven are fixed. Rust's checker has bugs of the same two kinds. One, reported on 13 October, gets past a struct's lifetime bounds by assigning to a field, and has been in the compiler for eleven years. Vx had a bug of the same shape, with a different cause, fixed on 14 October.

A borrow checker refuses programs that could reach memory they should not. When it accepts one by mistake, nothing later in the compiler notices: the optimizer trusts what the checker promised. This post goes through the holes found in Vx's checker in the last week, and the ones found in Rust's checker in the same months. Most of them are of two kinds. In the first, a type is allowed to change where it must stay the same (a variance mistake). In the second, a rule is checked in the places a borrow is usually made and missed in one place it seldom is.

The type inside a &mut must not change

A reference to something that lives longer can be used where a reference to something that lives for a shorter time is expected. A function that wants a &str valid for one block can be given a string literal, which is valid for the whole program. A type that allows this is called covariant in what it points to. &T is covariant in T, and that is safe, because a shared reference can only read.

&mut T cannot be covariant in T. Through a &mut the program can write, and a write puts a value of type T into a place whose own type may be stricter. This is the example from the Rustonomicon:

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

fn main() {
    let mut hello: &'static str = "hello";
    {
        let world = String::from("world");
        assign(&mut hello, &world);
    }
    println!("{hello}"); // use after free
}

If a &mut &'static str could be used as a &mut &'world str, assign would store a reference to world in hello, and the last line would read a string that has been freed. So &mut T is invariant in T: when one &mut is used where another is expected, the two types inside must be the same, lifetimes included. Rust refuses the program with error[E0597]: `world` does not live long enough.

What Vx did instead

On 1 October a post here described how Vx compares lifetimes, and its list said the inside of &mut T is invariant. A reader commented that the code only records the lifetime of the outer reference. They were right. When one reference was assigned to another, the checker compared the two inner types in one direction only: the source's inner type had to fit where the target's was expected. That is the rule for &T, and the checker used it for &mut T as well (#1701).

Here is what that allowed for lifetimes:

fn main() -> i32 {
  let long = 1;
  let mut p = &long;
  if true {
    let short = 2;
    let mut q = &short;
    let mut qq = &mut q;   // a &mut to a reference that lives in this block
    qq = &mut p;           // now a &mut to p, which lives longer
    *qq = &short;          // this would put &short into p
  }
  print(*p);
  return 0;
}

Line 8 should be refused. Through qq the program can write a reference to the inner block, and after line 8 that write goes into p, which outlives the block. Vx accepted line 8 and refused line 9, with E4005. That is a separate check: it refuses any store of a reference to a local variable through another reference, and any call that could make such a store, without asking where either reference points. It refuses some correct programs too, and here it hid the mistake. We did not find a program where this lifetime mistake led to a dangling reference.

Then a reader sent a program that E4005 does not cover. A &mut fits where a & is expected, so with the one-way rule a &mut &mut i32 also fit where a &mut &i32 was expected:

fn write_through(shared : &i32) -> i32 {
  let mut dummy = 0;
  let mut slot : &mut i32 = &mut dummy;
  let forged : &mut &i32 = &mut slot;   // a &mut &mut i32 used as a &mut &i32
  *forged = shared;                     // slot now holds the shared reference
  *slot = 99;                           // and writes through it
  return *shared;
}

fn main() -> i32 {
  let x = 7;                            // not mut
  print(write_through(&x));
  print!("\n");
  print(x);
  print!("\n");
  return 0;
}

forged and slot are the same place, seen with two types. Writing shared through forged puts a shared reference to x into a variable whose type says it holds a &mut i32, and the next line writes through it. The program printed 99 twice, at -O0 and at -O1: safe code changed x, which is not mut, through a reference that only allowed reading it.

The fix (#1707) adds the missing direction. When the target is a &mut and the type inside it is a reference, the two inner types must fit both ways. For lifetimes that means the same block. For & and &mut it means the same kind of reference. Both programs are refused now:

Error[E3004] at 8:5: Type mismatch in assignment: both sides are &mut &i32, but they are valid in different blocks. A cast cannot change how long a reference is valid

Error[E3001] at 4:3: 'forged' is declared &mut &i32 but its initializer is &mut &mut i32

The first message is new as well. Vx prints a type without its lifetime, so when two references differed only in lifetime, E3004 used to say cannot assign &i32 to &i32 and suggest an as cast, which cannot make a reference live longer (#1702).

Rust made a mistake of this kind once too. From 2017 to 2020 rustc treated a trait object as covariant in its associated types, which must be invariant because the trait's methods can take them as arguments as well as return them, and a short program used that to return a reference to a String that had been freed (rust-lang/rust#71550). It was fixed in Rust 1.46.

Eleven programs

The variance bug turned up during a wider review, which started on 10 October from a post about the borrow checker of the Zeta language. That post proposes things Vx may want later: telling a[i] from a[j], tracking which fields of a value are set, and proving that threads write different rows. Before building any of that, we checked that the checker refuses what it already claims to. It did not always. Each row below was a short program that compiled. The review is tracked in #1501.

What compiledWhat it didFixed by
A &mut v taken before a loop, and v changed inside the loop (#1503)Printed 15. Rust refuses it.#1523
Two &mut v in one if or match arm, while body or unsafe block (#1504)Printed 2 at -O0 and 1 at -O1#1523
A &mut returned by a call, then the variable it came from changed (#1505)The change went through while the returned &mut was still in use#1527
A call given &mut x and a struct holding &x (#1506)Wrote x through the &mut and read it through the struct, though the compiler tells the optimizer the two cannot overlap#1561
A field with a Drop impl moved out of its struct twice (#1507)Dropped the field three times. For a Vec that is a double free.#1564, #1591
A spawn loop where each pass reads the next pass's row (#1508)Ran in parallel. With 8 threads, 7 rows were wrong.#1530
A spawn loop with a break inside an if (#1645)The break stopped one thread. 58 rows were written instead of 10.#1711
Reading elements of Tensor::uninit() that were never written (#1509)Printed whatever was left in memory#1570 made it a warning, then #1744 an error outside unsafe
A &mut &mut i32 used as a &mut &i32 (#1701)Changed a variable that is not mut#1707
A struct holding a &mut to an outer variable and a reference to an inner one (#1746)Read freed memory#1747
A reference into a value with no name, kept after its statement (#1752)Read freed memory: printed 9 instead of 2.5#1755

The second row is the one whose answer changed with the optimization level:

fn write_both(a : &mut i64, b : &mut i64, n : i64) -> i64 {
  if n > 0i64 {
    return write_both(a, b, n - 1i64);   // keeps the function from being inlined
  }
  let one = 1i64;
  *a = one;
  *b = 2i64;
  return *a;
}

fn main() -> i32 {
  let mut v = 0i64;
  let mut r = 0i64;
  if true {
    let x = &mut v;
    let y = &mut v;   // a second &mut v while x is still in use
    r = write_both(x, y, 0i64);
  }
  print(r);
  return 0;
}

Without optimization it printed 2. At -O1 and above it printed 1. Vx marks a &mut parameter noalias for LLVM, as rustc does. That tells the optimizer that nothing else reaches the same memory while the function runs, so the write to *b cannot change *a, and the function may return 1 without reading memory again. The promise holds only if the borrow checker refuses a second &mut v while the first is in use. Here it did not.

The cause was small. The checker ends a borrow after the last statement that names the variable holding it. To know which statement that is, each block keeps a record of where each variable is last named. A function body and a loop body had such a record. An if arm, a match arm and an unsafe block did not, so inside an arm the checker used the outer block's record. There the whole if is one statement, so the borrow held by x looked over as soon as x was made. Vx's parser turns a while loop into a loop around an if, so every while body had the same problem. The fix (#1523) gives every kind of block its own record, and the program is refused:

Error[E4004] at 16:13: Cannot borrow 'v' because it is already borrowed as mutable.

A value with no name

The last row of the table was added after this post first went up. A slice into a new Vec is kept in s:

import std::vec;

fn main() -> i32 {
  let s = Vec<f32>::with_capacity(8).as_mut_slice();
  *s = 2.5;
  let mut z = Vec<f32>::with_capacity(8);
  for i in 0..8 { z.push(9.0); }
  print!(*s);
  print!("\n");
  return 0;
}

Vec<f32>::with_capacity(8) has no name, so Vx drops it, and frees its buffer, at the end of the let statement, as Rust does. s still points into that buffer. At -O1 the program printed 9, which the later Vec wrote into the reused memory, and without optimization the operating system killed it. With the Vec kept in a variable, the same program prints 2.5.

Rust refuses the same program with E0716, “temporary value dropped while borrowed”. Vx's checker already recorded, for each reference, the block it points into, and refused a reference kept after that block ends. A borrow of a value with no name was given the block it is written in. That is right for a value with nothing to free, which stays in the function's memory, and wrong for one that is freed when the statement ends. The fix (#1755) records such a borrow as pointing into something that ends with the statement, sooner than any block, so the checks that were already there refuse it:

Error[E4005] at 4:3: `s` is given a reference into a value with no name, which is dropped at the end of this statement. What it points at is gone once the statement ends. Put the value in a variable first, with `let`, so it lives as long as `s`

In one form Vx is stricter than Rust. Rust accepts let r = &make(); and keeps the value alive until the end of the block. Vx refuses it for now when the value has something to free. Accepting it would mean dropping the value at the end of the block instead of the statement. Rust's own rules for how long such a value lives are subtle enough that its 2024 edition changed one of them: a value with no name in a block's last expression is now dropped before the block's variables, where earlier editions dropped it after them.

A hole of the same shape in Rust, reported this week

On 13 October someone filed rust-lang/rust#164250, “struct's lifetime requirements can be bypassed via covariant field assignment”. This shorter version is from a comment on the issue:

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}");
}

MyType says its second field lives at least as long as what its first field points to ('b: 'a). q is built with a string literal, which meets that. Then q.1 = &l puts in a reference to l, which does not. As the title says, the assignment is checked against the field's own type, which is covariant, so a shorter lifetime is accepted, and the struct's bound is not checked again. When q is dropped, its Drop impl relies on the bound and stores &l in me_out. l is freed at the end of the block, so the last line reads freed memory. With Rust 1.95 the program compiles without a warning and printed me_out: ZTUM followed by zero bytes.

Rust's tracker marks it as a soundness bug with high priority. A note from the Rust types team at the top explains that it would have had the highest priority, critical, but “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”, and that “to exploit this you need an intentionally malicious Drop implementation”.

Vx had the same shape. This is #1746, written to follow the Rust program:

import core::ops;
import std::vec;

struct Leak {
  out : &mut &Vec<i32>,
  val : &Vec<i32>,
}

impl Drop for Leak {
  fn drop(self : &mut Leak) -> void {
    *self.out = self.val;
  }
}

fn main() -> i32 {
  let mut empty : Vec<i32> = Vec<i32>::new();
  empty.push(0);
  let mut me_out : &Vec<i32> = &empty;
  if true {
    let mut l : Vec<i32> = Vec<i32>::new();
    for i in 0..8 { l.push(1234); }
    let mut q = Leak { out : &mut me_out, val : &empty };
    q.val = &l;
  }
  let mut z : Vec<i32> = Vec<i32>::new();
  for i in 0..8 { z.push(9999); }
  print(me_out[0]);
  print!("\n");
  return 0;
}

Until 14 October it compiled and printed 9999. l's buffer is freed at the end of the if, z is given the same memory, and me_out still points at it. Without the line q.val = &l; it prints 0, as it should.

The cause in Vx is different, because Vx has no lifetime parameters, so there is no bound to forget. E4005 refuses a call that is given a &mut and a reference to a local that the call could store through it, so store(&mut me_out, &l) is refused. A struct that already holds both was not looked inside. Dropping q, or calling a method on it, is a call given only q, so E4005 has nothing to compare. That means any method on the struct can do the store in Vx. For Rust, the types team's note says the exploit needs a Drop impl. The fix (#1747) refuses the struct itself: one value may not hold a &mut to a variable of an outer block together with a reference to a variable of an inner one. The program is now refused at the assignment, and the version without it still compiles:

Error[E4005] at 23:7: `q` holds a `&mut` to a variable of an outer block and a reference to a variable of an inner block. A write through the `&mut` can store that reference where it outlives its block. Declare both variables in the same block

Rust's other open holes

Rust's borrow checker has had eleven years of heavy use, and it still has open soundness bugs. The ones below are of the same two kinds as above.

Variance. rust-lang/rust#25860, filed in 2015, is still open. A function may rely on bounds that its argument types imply: a &'a &'b () can only exist if 'b outlives 'a. Variance lets a program change those lifetimes when it turns the function into a function pointer, after which nothing checks the bound, and that turns any reference into a &'static one. The cve-rs crate, which causes memory errors in safe Rust on purpose, is built on it. Rust 1.83 closed part of it. The rest waits on the new trait solver and a rework of lifetime constraints, which is one of Rust's 2026 project goals.

A rule missing in one place. Three borrow checker bugs filed between August and October 2026 are about where a borrow is made: inside a method call's argument (#164163), inside a match guard (#160599), and a break inside an argument (#160598). The first one is short:

pub fn main() {
    let mut a: Vec<u64> = vec![1];
    let mut b: Vec<u64> = vec![2];
    let mut p: &mut Vec<u64> = &mut a;
    let s: &u64;
    // The receiver `&mut *p` is reserved first. The argument then borrows through `p`, and points `p` elsewhere.
    p.push({
        s = &p[0];
        p = &mut b;
        0
    });
    // `a` has been pushed to, so it may have reallocated. `s` still points into its old buffer.
    for _ in 0..64 { p.push(7); }
    println!("{}", *s);
}

Rust lets v.push(v.len()) compile with a two-phase borrow: it reserves the &mut v for push before computing the argument, and starts using it only when the call runs. Here the argument reads p[0] and then points p at b. The push still goes to a, through the borrow taken before, and s is left pointing into a's old buffer. With Rust 1.95 it printed 3; the value it should print is 1.

This is the same kind of bug as the if arm above: the rule is right where the checker usually sees a borrow, and missing in one place it seldom does. #160598 and #160599 were found by a person who uses an LLM to search for soundness bugs.

How we look for the next one

Every rule, in every kind of block. The if arm bug was a rule that worked in one kind of block and not in another, so a new set of tests (#1577) writes each of seven borrow rules, such as two &mut at once, or reading a variable while a &mut to it is in use, inside each kind of block Vx has: a function body, if and else arms, a match arm, while, for and loop bodies, an unsafe block, a closure body, and an if inside a loop. Each block is tried with the borrow made inside it, and with the borrow made before it and used after. That is 266 cases. The compiler from just before #1523 accepted 11 to 15 of the 19 programs in each rule's test that it should have refused. Matching tests check that the same code compiles when the borrow ends in time, so a fix cannot simply refuse everything.

Refusing too much. A checker that refuses a correct program is also wrong, and each new rule can make it do so. Vx refuses a call whose arguments reach the same variable when one of them can change it. That check also refused f(&mut p, &v) when p holds only &v, because it counted &mut p as able to change v (#1748, fixed by #1749). And a loop running Euclid's algorithm was refused with E4007, “b is moved while it is borrowed”, which broke gcd in a bigint library written in Vx (vx-lang/Vx-starters). The loop calls a method that takes &b and returns a new struct. That struct holds no reference, but the checker kept b borrowed after the call. The same code written as a function call was accepted (#1646, fixed by #1750).

Readers. Both reports about the variance bug came from people who read the earlier post and tried to break the checker. Please keep doing that. If you find a program that compiles and should not, open an issue with the program.

Every program in the table is now refused, or now runs as it should. The ideas from the Zeta post come next.

Vx is Apache 2.0 with the LLVM exception — install it and try the examples.