Two soundness bugs in Rust's borrow checker have been open since 2013 and 2014. They allow code that violates aliasing rules to compile without unsafe blocks. If you write libraries that expose safe APIs over unsafe internals, or if you rely on the borrow checker to enforce invariants in complex data structures, these holes can let undefined behavior slip into production.
You'll learn:
- Which code patterns trigger the unsoundness
- How to audit your crate for the affected constructs
- Practical workarounds that keep your API safe today
What the Bugs Actually Are
The first bug (issue #164250 tracks both) involves two-phase borrows and reborrowing through raw pointers. When you create a raw pointer from a mutable reference, then reborrow that pointer as a shared reference, the borrow checker can lose track of the original mutable borrow. This lets you hold a &mut T and a &T to the same location simultaneously — the exact aliasing violation Rust promises to prevent.
The second bug concerns closure capture analysis. When a closure captures a mutable reference and also captures a shared reference derived from it, the borrow checker sometimes fails to see the conflict. The closure compiles, but calling it creates overlapping mutable and shared borrows.
Both bugs stem from the borrow checker's MIR (Mid-level Intermediate Representation) analysis not fully modeling how raw pointers and closures interact with the stacked borrows model.
How They Manifest in Real Code
The patterns look innocent. Here's a simplified version of the two-phase borrow case:
fn extend_vec(vec: &mut Vec<i32>, value: i32) {
let ptr = vec.as_mut_ptr(); // raw pointer from &mut
let len = vec.len(); // shared borrow of vec
// borrow checker thinks `vec` is only shared-borrowed here
unsafe {
*ptr.add(len) = value; // but we write through the raw pointer!
vec.set_len(len + 1); // and mutate vec again
}
}
The borrow checker sees vec.len() as a shared borrow and vec.set_len() as a separate mutable borrow. It doesn't connect the raw pointer ptr back to the original &mut Vec. The write through ptr happens while len holds a shared reference to vec's metadata.
The closure variant:
fn process_with_closure(data: &mut [i32]) {
let shared = &data[..]; // shared borrow
let mut closure = || {
data[0] = 42; // mutable borrow captured
println!("{:?}", shared); // shared borrow captured
};
closure(); // compiles, but creates &mut and & to data[0] simultaneously
}
The closure captures both data (mutably, for the assignment) and shared (shared). The borrow checker treats these as separate captures and misses the overlap.
Detecting Affected Patterns in Your Codebase
You won't find these with cargo clippy or cargo check. The code compiles cleanly. You need to search for the structural patterns:
- Raw pointer derived from
&mutfollowed by any shared borrow of the same value - Closures that capture both a mutable reference and a shared reference derived from it
A quick grep pattern for the first case:
## Look for as_mut_ptr / as_ptr followed by .len(), .is_empty(), or deref
rg 'as_mut_ptr\(\).*\.(len|is_empty|first|last|get)' --type rust
For closures, there's no reliable static search. Review any closure that captures multiple references to the same data structure, especially when one capture is &mut and another is &.
If you maintain a crate with unsafe internals, add a test that runs under Miri (the Rust interpreter that detects UB):
cargo +nightly miri test
Miri implements stacked borrows and will flag both bug patterns at runtime. It's the only reliable detection today.
Workarounds While Fixes Land
The Rust team is working on fixes, but they require changes to the borrow checker's core logic and must pass the full test suite. Until then, restructure the affected code.
For the two-phase borrow case: split the operation so the raw pointer and the shared borrow don't overlap.
fn extend_vec_fixed(vec: &mut Vec<i32>, value: i32) {
let len = vec.len(); // shared borrow ends here
let ptr = vec.as_mut_ptr(); // raw pointer from &mut, no overlap
unsafe {
*ptr.add(len) = value;
vec.set_len(len + 1);
}
}
The fix is mechanical: compute all shared-borrow-derived values before creating the raw pointer. The borrow checker correctly sees the shared borrow end before the mutable borrow (via raw pointer) begins.
For the closure case: avoid capturing both reference types. Restructure so the closure only needs one:
fn process_with_closure_fixed(data: &mut [i32]) {
let shared = &data[..];
let value = shared[0]; // copy what you need
let mut closure = move || {
data[0] = 42;
println!("{}", value); // use the copy, not the shared ref
};
closure();
}
If you must keep the shared reference alive, use RefCell or Cell to make the borrowing dynamic and checked at runtime:
use std::cell::RefCell;
fn process_dynamic(data: &RefCell<Vec<i32>>) {
let shared = data.borrow(); // runtime checked
let mut closure = || {
data.borrow_mut()[0] = 42; // panics if shared still alive
println!("{:?}", shared);
};
// closure() would panic here — the bug becomes a loud crash
}
This trades compile-time soundness for runtime safety. The panic is loud and testable; silent UB is not.
What the Fixes Change for You
When the borrow checker fixes land (tracked in rust-lang/rust#164250 and linked issues), code that currently compiles but is unsound will start producing hard errors. Your crate will fail to compile.
This is the correct outcome. The borrow checker will finally enforce the guarantees it always promised. But it means:
- No deprecation window — these are soundness holes, not lint-worthy patterns
-
No
#[allow]escape hatch — you must fix the code - Miri becomes mandatory in CI — if you don't run it, you'll discover breaks at update time
Add Miri to your CI now:
## .github/workflows/miri.yml
name: Miri
on: [push, pull_request]
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Miri
run: rustup component add miri && rustup override set nightly
- name: Run tests under Miri
run: cargo miri test
Run it locally first. Fix what it finds. Then when the compiler catches up, your crate sails through.
Key Takeaways
- Two 11-year-old borrow checker bugs let aliasing violations compile as safe code
- The patterns: raw pointers from
&mutoverlapping with shared borrows, and closures capturing both&mutand&to the same data -
cargo checkand Clippy won't catch them — only Miri reliably detects the UB - Workaround: reorder operations so shared borrows end before raw pointers are created; avoid dual-reference closure captures
- Fixes will be hard errors with no migration path — add Miri to CI today
Source
11 year old unsoundness bugs found in Rust borrow checker — added working code examples for both bug patterns, detection strategies using grep and Miri, concrete workarounds that compile on stable Rust, and a CI configuration to catch regressions early.
Support this work
These write-ups are researched and published with no paywall, sponsor, or tracking. If one saved you an afternoon, a small tip keeps them coming.
USDT, USDC or USDD · TRC-20 (Tron)
TFTNsfyomKrnUutRjBTGVULp19ByW29KbY
Top comments (0)