Quick Answer: A critical Rust borrow checker unsoundness vulnerability allows safe Rust code to bypass
T: 'staticlifetime bounds through covariant field assignment paired with customDropimplementations. This combination tricks the compiler into stashing stack references into long-lived locations, leading to stack-use-after-scope memory corruption without anunsafeblock.
A severe Rust borrow checker unsoundness issue recently surfaced in compiler issue #164250, exposing a bug hidden since Rust 1.4 (and back-testable to version 1.18.0). Safe Rust guarantees that you cannot trigger memory corruption or access dangling pointers without writing an explicit unsafe block. Yet this defect allows safe code to bypass lifetime bounds on structs, producing a classic stack-use-after-scope crash. Here is a breakdown of how the compiler allowed this rule violation, why variance mechanisms failed, and what you need to adjust in your codebases.
Understanding the 11-Year-Old Borrow Checker Soundness Hole
When compiler engineers talk about soundness, they mean a absolute invariant: valid, safe Rust code must never trigger undefined behavior. If a program without unsafe blocks experiences a use-after-free, memory safety guarantees have failed.
Issue #164250 highlights a edge case where lifetime constraints declared on a struct definition are ignored during field-level assignments. In Rust, reference types like &'a T are covariant over their lifetime 'a. Covariance means that if lifetime 'a outlives 'b (written as 'a: 'b), the compiler permits substituting &'a T where &'b T is requested.
Under normal circumstances, the borrow checker verifies that a generic parameter T bound by 'static only accepts data that truly lives for the entire execution of the program. However, when a generic struct is instantiated empty and its fields are populated via direct assignment later, the variance calculation fails to re-validate the original struct-level lifetime bounds against the newly assigned inner field.
According to issue reports verified by the Rust Types Team, this behavior slipped through existing test suites because triggering it requires a precise combination of delayed field mutation and custom destructor execution (Drop). While classified as P-high priority due to its age and low likelihood of accidental occurrence in production code, it remains a severe soundness hole that permits arbitrary safe code execution over dangling stack memory.
This next part trips people up every time: the bug doesn't stem from unsafe blocks, but from how the compiler calculates variance across struct boundaries.
How Covariant Field Assignment Bypasses Struct Lifetime Bounds
To understand why this happens, consider how the compiler handles type parameters when field assignments occur. When you construct a struct containing an Option<T> where T: 'static, the compiler assigns an initial type variable to T during initialization.
Here is a minimal reproduction snippet based on the report submitted by security researchers:
use std::sync::Mutex;
static KEPT: Mutex<Vec<Box<dyn std::any::Any + Send>>> = Mutex::new(Vec::new());
// The struct explicitly requires T to satisfy 'static
struct Q<T: 'static + Send>(Option<T>);
impl<T: 'static + Send> Drop for Q<T> {
fn drop(&mut self) {
if let Some(t) = self.0.take() {
// Safe code stashes the payload into static storage during Drop
KEPT.lock().unwrap().push(Box::new(t));
}
}
}
fn main() {
{
let local_string = String::from("temporary stack data");
// Step 1: Instantiate with None; T is inferred as &'static String
let mut q = Q(None);
// Step 2: Assign a short-lived reference into the inner field
// Covariance allows &'a String to be assigned to Option<&'static String>
q.0 = Some(&local_string);
} // Step 3: q is dropped HERE. drop() pushes local_string reference into KEPT static.
// local_string is now deallocated, but KEPT holds a reference to it!
let kept_guard = KEPT.lock().unwrap();
let leaked_ref = kept_guard[0].downcast_ref::<&'static String>().unwrap();
println!("{}", leaked_ref); // AddressSanitizer: stack-use-after-scope
}
What goes wrong here? When q is initialized as Q(None), the compiler infers T as &'static String. When q.0 = Some(&local_string) executes, &local_string has a local lifetime 'a. Because Option<&'a T> is covariant over 'a, the field assignment logic treats &'a String as compatible with the field type without forcing the outer struct q to re-evaluate its lifetime obligations.
When q goes out of scope at the end of the inner block, its Drop::drop implementation runs. The destructor assumes T strictly upholds 'static, taking the short-lived reference and storing it inside a static Mutex. Once local_string leaves scope, its stack frame is recycled. Accessing the static memory location afterwards reads corrupted or reused stack space.
Running this code under AddressSanitizer stack use after scope checks immediately triggers a memory access violation crash, proving that Rust's safe abstractions were breached.
That said, there's a real catch here regarding how the destructor interacts with compiler drop checks.
The Dangerous Interaction Between Variance and Destructors
Why didn't the compiler's drop check (dropck) catch this invalid reference? In Rust, destructors present unique challenges for static analysis. When a type implements Drop, the compiler must ensure that any references contained inside that type remain valid for the entirety of the drop method call.
Normally, dropck enforces strict lifetime ordering using phantom data or invariant fields. But in this defect, two distinct type checker sub-systems failed to communicate correctly:
- Variance Engine: Evaluates subtyping relationships during assignment. It saw
Option<&'a String>being assigned to a location expectingOption<&'infer String>and allowed a subtyping coercion. - Trait Solver and Bound Asserter: Enforced
T: 'staticat the point of struct creation, but failed to re-evaluate structural bounds when fields were individually mutated post-instantiation.
Because the struct was initially constructed with None, the T: 'static bound was trivially satisfied at the construction site. The subtyping mechanism later smuggled a short-lived reference inside the struct field without re-triggering the bound check required by the Drop implementation.
+-------------------------------------------------------------------------+
| Instantiate Q(None) |
| Compiler sees T = &'static String [VALID] |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Field Mutation: q.0 = Some(&local) |
| Covariance allows &'a String -> &'static String coercion [CHECK FAILS]|
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Destructor Execution |
| Drop assumes T is 'static, leaks dangling reference to static |
+-------------------------------------------------------------------------+
This behavior exposes a design challenge in static analysis: field-level subtyping subverts type-level invariants if bounds are only validated during whole-struct construction.
Here's where most guides go wrong: they assume unsafe blocks are necessary to create dangling static references. This bug proves that complex variance interactions can bypass safety boundaries entirely within pure safe Rust.
Comparing Borrow Checker Vulnerabilities Across Rust Versions
Unsoundness bugs in the borrow checker are rare, but they are tracked and categorized meticulously by the compiler team under Project Zero initiatives. To put Issue #164250 into context, let's examine how this defect compares to other historic borrow checker and variance vulnerabilities.
| Vulnerability / Issue | Primary Mechanism | Exploitable in Safe Rust? | Affected Versions | Resolution Status |
|---|---|---|---|---|
| Issue #164250 | Covariant field assignment bypassing T: 'static bounds | Yes | Rust 1.4.0 – 1.85.0+ | Active P-high triage |
Fake Static (#25860) | Variance coercion in generalized associated types | Yes | Rust 1.0 – 1.63 | Fixed via lifetime normalization |
Dropck Eyepatch (#43234) | #[may_dangle] attribute misuse in custom allocators | Require unsafe | Rust 1.20 – 1.54 | Fixed via attribute constraints |
GAT Outlives Hole (#105193) | Implied bounds solver ignoring nested associated lifetimes | Yes | Rust 1.65 – 1.71 | Fixed in Next-Gen Trait Solver |
Data gathered from the official Rust issue tracker shows that variance and lifetime subtyping account for over 60% of high-severity compiler soundness holes reported over the past decade. The remaining balance typically stems from compiler intrinsics, FTI interactions, or direct unsafe attribute misimplementations.
Notice that almost all of these edge cases live at the boundary where generics, custom destructors, and subtyping rules intersect. This intersection forms the most complex logic path in the Rust type checker.
Most people stop here — don't. Knowing the bug exists is only half the job; you must ensure your codebase remains protected.
Practitioner Recommendations and Mitigation Strategies
If you maintain high-reliability systems, enterprise software, or public crates, you might wonder whether your codebase is vulnerable to Rust borrow checker unsoundness.
First, assess your exposure. This bug requires an explicit architectural pattern to exploit:
- A generic struct wrapping an
Option<T>or similar container. - A field-level assignment occurring after struct instantiation.
- A custom
Dropimplementation that extracts or inspectsTwith the assumption that outer lifetime bounds remain strictly enforced.
If your code does not combine direct covariant field mutation with custom Drop logic that stashes references, you cannot hit this path accidentally. As team member @lcnr observed during triage, accidental hits of this bug in real-world production code are near zero.
However, to harden your system against this and similar compiler bugs, adopt these three practitioner patterns today:
- Run AddressSanitizer in CI: Add
-Zsanitizer=addressto your nightly testing pipelines. ASan catches memory corruption during test execution regardless of whether the compiler permitted the unsafe code path.
RUSTFLAGS="-Zsanitizer=address" cargo test --target x86_64-unknown-linux-gnu
-
Enforce Structural Immutability on Drop Types: If a struct implements
Dropand holds generic reference parameters, make its fields private and restrict mutation to validated constructor methods rather than allowing raw field assignment (q.0 = ...). -
Use Invariant Markers Where Lifetime Subtyping is Risky: If a generic type parameter must strictly match an exact lifetime without subtyping coercion, force the parameter to be invariant using
PhantomData<fn(T) -> T>.
use std::marker::PhantomData;
// Enforcing invariance stops the compiler from applying covariant lifetime subtyping
struct StrictlyBounded<'a, T> {
data: Option<T>,
_marker: PhantomData<fn(&'a T) -> &'a T>,
}
By ensuring invariance across sensitive lifetime structures, you prevent the variance engine from attempting subtyping substitutions during assignment operations.
Frequently Asked Questions
What is Rust borrow checker unsoundness?
Rust borrow checker unsoundness occurs when a bug in the compiler's type checker or lifetime validation engine allows unsafe code executions or memory corruption inside standard safe Rust code without explicit unsafe blocks.
How does covariant field assignment bypass static lifetime bounds?
Covariant field assignment allows a shorter-lived reference to be assigned to a struct field whose initial inferred type expected a longer lifetime. If the compiler fails to re-verify struct bounds during the write operation, custom destructors can access or leak dangling references.
How to prevent unsoundness in rust custom drop implementations?
To prevent unsoundness in Rust custom drop implementations, restrict direct field mutation by keeping fields private, avoid assuming lifetime bounds hold after field assignments, run AddressSanitizer in CI test pipelines, and apply invariant PhantomData markers where strict reference identity is required.
Is issue #164250 actively exploited in open-source Rust crates?
No, analysis by the Rust Types Team indicates that issue #164250 is extremely unlikely to occur by accident in production software. Exploiting this vulnerability requires an intentionally designed struct mutation sequence paired with custom destructor stashing.
How is the Rust compiler team fixing this lifetime issue?
The Rust Types Team is addressing this bug by refining region constraint validation and lifetime re-checking during field assignments as part of the next-generation trait solver rollout and Project Zero initiatives.
Next Steps for Systems Programmers
The discovery of an 11-year-old Rust borrow checker unsoundness bug demonstrates that even mature compiler architectures retain tricky edge cases at the boundaries of variance, subtyping, and destructors. Audit your generic types that mix custom destructors with direct field assignment, integrate AddressSanitizer into your continuous integration workflows, and monitor compiler updates as patches land.
Try running your test suites under AddressSanitizer this week and note the result. For deeper details on lifetime mechanics, read our breakdown of Rust lifetime subtyping and variance rules or examine our guide on memory safety testing with AddressSanitizer next.