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 declaredmut, in safe code, because the checker let the inside of a&mutchange 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 compiled | What it did | Fixed 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> = ∅
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.