DEV Community

RobustTrueTry
RobustTrueTry

Posted on

The Borrow Checker Gaps That Let Unsafe Code Compile

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Raw pointer derived from &mut followed by any shared borrow of the same value
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 &mut overlapping with shared borrows, and closures capturing both &mut and & to the same data
  • cargo check and 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)