On October 13, 2026, rust-lang/rust#164250 was opened with the title “struct’s lifetime requirements can be bypassed via covariant field assignment”. It carries the labels I-unsound and P-high. The program in it has no unsafe block, compiles without a warning, and prints memory that belongs to a different value.
I ran it on the current stable compiler, on nightly with AddressSanitizer, and on Rust 1.4.0 from October 2015 inside Docker. This post has every file and command, so you can repeat it.
Result first
| Run | Compiler | Result |
|---|---|---|
| The program from the issue | rustc 1.98.1 stable | compiles, prints 32 X characters in place of the string |
| Same struct, built with the reference in one expression | rustc 1.98.1 stable | rejected, E0597: l does not live long enough |
| The program under AddressSanitizer | rustc 1.101.0-nightly | stack-use-after-scope inside the variable l |
| A smaller variant from the issue thread | rustc 1.4.0 (2015-10-27) in Docker | compiles, prints me_out: XXXXXXXXXXXXXXXX |
As of October 14, 2026, the issue is open and the fix, rust-lang/rust#164277, is waiting for review.
The program
This is the code from the issue, one statement per line, with use std::any::Any added at the top:
use std::any::Any;
use std::sync::Mutex;
static KEPT: Mutex<Vec<Box<dyn Any + Send>>> = Mutex::new(Vec::new());
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() {
KEPT.lock().unwrap().push(Box::new(t));
}
}
}
fn main() {
{
let l = String::from("hello world, this is long enough");
let mut q = Q(None);
q.0 = Some(&l);
}
let _z = String::from("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
let k = KEPT.lock().unwrap();
println!("{}", k[0].downcast_ref::<&'static String>().unwrap());
}main.rs
There are three parts:
Q<T: 'static>only accepts values that are valid for the whole program.- Its
Dropmoves whatever it holds intoKEPT, a global list. BecauseT: 'static, keeping it forever looks legal. mainbuildsqempty, puts a reference to the block-locallinto it, and lets the block end. Afterwards it reads the reference back fromKEPT.
The 32 X characters are the second String. It is allocated after the block ends, and if it lands where l’s data was, the stale reference prints it.
Run it
rustc --version
rustc main.rs
./main
grep -c unsafe main.rs
rustc 1.98.1 (48a229cea 2026-09-01)
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
0output
rustc main.rs prints nothing at all. The program expects hello world, this is long enough and gets the other string’s bytes.
The one-line version is rejected
direct.rs differs from main.rs in a single place:
diff main.rs direct.rs
19,20c19
< let mut q = Q(None);
< q.0 = Some(&l);
---
> let _q = Q(Some(&l));output
rustc direct.rs 2>&1 | grep -E "^error|^note|static|dropped"
error[E0597]: `l` does not live long enough
| this usage requires that `l` is borrowed for `'static`
| - `l` dropped here while still borrowed
note: requirement that the value outlives `'static` introduced here
6 | struct Q<T: 'static + Send>(Option<T>);
error: aborting due to 1 previous erroroutput
So the compiler knows the rule and enforces it when the struct is built with the reference inside. The hole is in the two-step path.
Why the two-step path gets through
Two things combine.
Covariance. Option<&'a String> is covariant in 'a, so a longer-lived reference can stand in where a shorter one is expected. Q(None) has no reference in it yet, so the compiler is free to pick a short lifetime for q’s type. After q.0 = Some(&l), q is effectively a Q<&'short String>. That type should be impossible, because Q requires T: 'static, but the assignment is not where the bound is checked.
The drop you do not write. At the end of the block, the compiler inserts drop(q). That call runs Q’s Drop, whose code was type-checked under the assumption T: 'static. Nothing checks the call against the actual type of q. Inside drop, a &'short String is treated as &'static String and moved into a global list.
The smallest form of this, posted by theemathas in the issue thread, has no Mutex and no output. It only shows where the mismatch happens:
struct Q<T: 'static>(Option<T>);
impl<T: 'static> Drop for Q<T> {
fn drop(&mut self) {
// Code in here has access to a non-'static &String
// but assumes that it's a &'static String
}
}
fn main() {
let l = String::new();
let mut q = Q(None);
q.0 = Some(&l);
}core.rs
AddressSanitizer
The wrong output alone does not say which memory was read. AddressSanitizer reports it directly. Rust supports it only on nightly, through -Zsanitizer=address:
rustc +nightly -Zsanitizer=address main.rs -o main-asan
./main-asan 2>&1 | grep -oE "AddressSanitizer: [a-z-]+|'l' <==.*" | head -n 2
AddressSanitizer: stack-use-after-scope
'l' <== Memory access at offset 200 is inside this variableoutput
The full report is longer. These two lines say what happened and where: the read touches the stack slot of l after its scope ended.
Back to 2015
main.rs needs a const Mutex::new, which old compilers do not have. In the thread, lcnr posted a variant that needs no 'static bound and no global: the Drop writes the short reference into a variable that lives longer. theemathas then posted a version of it that compiles on Rust 1.4.0. It adds an 'a: 'c bound to the struct and uses {} in place of the inline {me_out} format argument. I added one line, the _z string, so the freed memory gets overwritten with something visible:
struct MyType<'a: 'c, 'b: 'a, 'c>(&'c mut &'a str, &'b str);
impl<'a: 'c, '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;
}
let _z = String::from("XXXXXXXXXXXXXXXX");
println!("me_out: {}", me_out);
}old.rs
It is the same pattern: build the struct with a harmless value, assign the field later, let the implicit drop do the rest.
The image installs Rust 1.4.0 through rustup on an amd64 Debian:
FROM --platform=linux/amd64 debian:bookworm-slim
RUN apt-get update && apt-get install -y curl ca-certificates gcc libc6-dev \
&& curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.4.0
ENV PATH=/root/.cargo/bin:$PATH
WORKDIR /srcDockerfile
docker build -t rust-old:1.4.0 .
docker run --rm --platform linux/amd64 -v $PWD:/src rust-old:1.4.0 rustc --version
docker run --rm --platform linux/amd64 -v $PWD:/src rust-old:1.4.0 sh -c "rustc old.rs -o /tmp/old && /tmp/old"
rustc 1.4.0 (8ab8581f6 2015-10-27)
me_out: XXXXXXXXXXXXXXXXoutput
Rust 1.4.0 came out five months after 1.0. I did not test 1.0 to 1.3, so “since at least 1.4.0” is as far as this run goes. On an Apple Silicon Mac the --platform linux/amd64 flag runs the image under emulation.
How worried should you be
The note at the top of the issue makes two points. The bug would be P-critical, and it is only P-high because it is eleven years old: a bug nobody found in that time cannot be easy to hit. And to exploit it, you need an intentionally malicious Drop implementation.
All the versions in the thread have the same shape:
- The struct is built without the reference, and the field is assigned afterwards.
- Its
Drophands the contents to something that outlives the value: a global list inmain.rs, an outer variable through&mutinold.rs, or, in a third version by maxdexh, a closure stored in the struct that writes to an outer variable.
A Drop that only releases what the struct owns (closes a file, frees a buffer, unlocks a mutex) does not pass a reference anywhere. I did not search any codebase for this pattern. The list above describes the programs in the thread, not every possible way to trigger the bug.
The fix
rust-lang/rust#164277, “require MIR local types to be well-formed”, was opened the same day as the issue. MIR is the compiler’s mid-level representation, and every local variable has a type there, including q. The PR checks that each of those types is well formed, which means Q<&'short String> breaks the T: 'static bound no matter how the value was built. The PR description reads “Fixes rust-lang/rust#164250” and, on the next line, “sound again :)”.
The PR adds the issue’s case as a UI test, tests/ui/borrowck/local-type-wf-issue-164250.rs. Its expected output is the same E0597 that the one-line version gets today, now pointing at the field assignment:
error[E0597]: `text` does not live long enough
--> $DIR/local-type-wf-issue-164250.rs:24:20
|
LL | let text = String::from("temporary");
| ---- binding `text` declared here
LL | let mut value = Q(None);
LL | value.0 = Some(&text);
| ---------------^^^^^-
| | |
| | borrowed value does not live long enough
| assignment requires that `text` is borrowed for `'static`
LL |
LL | }
| - `text` dropped here while still borrowedlocal-type-wf-issue-164250.current.stderr
When I checked on October 14, the PR was open and waiting for review, so stable Rust still compiles main.rs.