It compiles. It should not.
fn exploit<'a, T>(_: [&'static &'a (); 0], v: &'a T) -> &'static T { v }
pub fn fake_static<'a, T>(input: &'a T) -> &'static T {
let f: fn([&'static &'static (); 0], &T) -> &'static T = exploit;
f([], input)
}
fn main() {
let dangling: &'static String;
{ let local = String::from("Hello"); dangling = fake_static(&local); }
println!("{}", dangling); // prints garbage
}
The inner block owns local. At the closing brace local is dropped and its heap
buffer is returned to the allocator. dangling still points at it, and the type system
believes that pointer is valid for the rest of the program.
UB (Undefined Behaviour — the program has no defined meaning, so any output the compiler produces is legal). A read through a dangling reference is UB in Rust exactly as it is in C.
Where the pointer ends up
Why the compiler accepts it.
STEP 1The signature is honest.
Well-formedness of &'static &'a () requires 'a: 'static. rustc grants
that to the function body as an implied bound. No where clause is written. Under that
bound, returning v as &'static T is correct. The function
is honest about its precondition.
WF (Well-Formedness — rustc's check that a written type makes sense before anything uses it).
A nested reference &'x &'y T is well-formed only when 'y outlives
'x. Otherwise the outer reference could outlive the inner one it points at.
STEP 2Nothing is ever built.
The array has length zero, so no value of the impossible type is ever constructed.
[] is accepted at any element type. The parameter exists only to carry the bound
into the body.
STEP 3The damage is inside the coercion.
The call site writes only well-formed types. Function pointers are contravariant in their
arguments. Arrays are covariant in the element type. So rustc picks 'a to be the
short lifetime of input. That produces the type
[&'static &'a (); 0], which is not well-formed. rustc does not re-check
well-formedness at a coercion.
The coercion, with the skipped check marked
Drag sideways to pan →
STEP 4rustc does check this now.
PR #129021 by compiler-errors, "Check WF of source type's signature on fn pointer cast", merged 6 September 2024. It closed the original 2015 shape. What still slips past is one elided lifetime.
PR (Pull Request — a proposed change submitted to a repository for review and merging).
let f: fn(_, &'s T) -> &'static T = foo; // rejected
let f: fn(_, &T) -> &'static T = foo; // compiles, and is UB
An elided lifetime in a function-pointer type is late-bound. So
fn(&T) -> &'static T desugars to
for<'x> fn(&'x T) -> &'static T. That is the higher-ranked case, which
PR #129021 explicitly left as future work. Writing for<'x> by hand also works.
HRTB (Higher-Ranked Trait Bound — a type that quantifies over a lifetime rather than fixing
one), written for<'x>. The type promises to work for every choice of
'x, so the lifetime is chosen later, at the call.
Where rustc checks well-formedness, and where it stops
Definition site
CheckedWF of the parameter type is proved. The body is handed 'a: 'static as an implied bound.
Direct call
CheckedRejected with E0521. The caller cannot prove 'a: 'static, so there is no way through.
Fn-pointer coercion
Checked since Sept 2024 Gap 2015–2024PR #129021 added the WF check on the source signature. This shape is now rejected.
Higher-ranked coercion
Still openThe elided lifetime is late-bound, so this is the for<'x> case. PR #129021 left it as future work.
Eleven years, one shape closed
Drag sideways to pan →
What compiles and what does not.
Every row below was run against the compiler on one machine. Nothing here is inferred from the issue thread.
Compiles, and is unsound
10- ✕The code from the tweet
- ✕The same, with the second lifetime elided
- ✕Explicit for<'x>
- ✕An as cast instead of a let
- ✕PhantomData or Option as the carrier
- ✕A real &'static &'static () static as the carrier
- ✕Behind a type alias
- ✕Behind struct Wrap<'x, 'y: 'x>
- ✕That struct living in a separate crate
- ✕Nightly with -Znext-solver
Rejected by rustc
2- ✓A direct call to exploit([], input), with E0521
- ✓The original 2015 shape, closed by PR #129021
Clippy
SilentRun with pedantic and nursery enabled. No lint exists for this.
Miri
Caught itIs Rust's safety claim a scam?
No. It is a compiler defect. The defect is real, it has been open since May 2015, and the
compiler team tracks it with a known-bug regression test.
A compiler defect is not proof that the guarantee was a lie. A JVM sandbox escape is not proof that Java was never memory safe.
JVM (Java Virtual Machine — the runtime that executes Java bytecode and enforces its security model).
Two corrections to the viral thread
First, the 2015 shape was fixed. PR #129021 landed in September 2024. The demo that circulates as "still broken after ten years" is not the demo that was reported in 2015.
Second, the delay has a reason. Removing implied bounds from nested types would break working crates. The fix needs a migration path, and that is slower than a patch.
The threat model is a dependency
Nobody hits this by accident. The shape has to be built on purpose. So the risk is a hostile
dependency. A crate can export a safe-looking
fn fake_static<T>(&T) -> &'static T with no unsafe
anywhere in its source. Any tool that audits by counting unsafe blocks calls that
crate clean.
Can a linter catch it?
Six ast-grep rules were written for the syntactic ingredients. ast-grep is a syntax-tree search tool. It has no type information, no name resolution, and no view into dependencies.
AST (Abstract Syntax Tree — the parsed shape of source code, with no types attached).
An AST matcher sees that you wrote &'static &'a (). It cannot see what
'a resolves to, or what a struct from another crate declares.
The rules were scanned against every crate vendored on this machine.
Every hit, drawn against everything scanned
Total: 637 hits in 226 files. All nine unsound variants fire, on three to five distinct rules each.
The benign hits are ordinary code. pin-project writes
&'pin &'pin_ &'pin__ T. fiat-crypto and
zerocopy-derive show up. So do &'a &'static str
struct fields. None of them is a bug.
Where the rules stop working.
Remove 'static by returning a generic &'b T. Move the outlives bound
into a where clause on a struct from another crate.
// in victimlib: pub struct Pair<'x, 'y: 'x>(pub PhantomData<..>);
extern crate victimlib;
use victimlib::Pair;
fn exploit<'a, 'b, T>(_: [Pair<'b, 'a>; 0], v: &'a T) -> &'b T { v }
There is no 'static in that signature. There is no nested reference. The dangerous
declaration lives in victimlib, which the linter never opens.
Detection against evasion
across 3 rules
- Nested reference, explicit outer lifetime
- Returns &'static T from a shorter borrow
- Fn item coerced to a lifetime-pinning fn pointer
- Type declaration with an outlives bound
- Type given both 'static and a shorter lifetime
- &'static Foo<'a>
Three independent signals in one file.
across 1 rule
- Nested reference, explicit outer lifetime
- Returns &'static T from a shorter borrow
- Fn item coerced to a lifetime-pinning fn pointer
- Type declaration with an outlives bound
- Type given both 'static and a shorter lifetime
- &'static Foo<'a>
The survivor is the coercion rule, which cannot act alone. On its own it reports ordinary code.
A syntax matcher cannot win this. The dangerous part is a bound in a crate it never reads.
Two further gaps are worse, and both were confirmed by compiling the code.
Macros are a total blind spot
tree-sitter parses a macro_rules! body as an opaque token tree. No
reference_type, function_item or let_declaration node
exists inside it. Put the whole exploit in a macro body and it compiles and scores
zero hits. Code from a procedural macro or from build.rs is worse,
because the source never exists on disk for a matcher to read.
A three-crate split hides the exploit entirely
Put the struct in crate A, the exploiting function in crate B, and the coercion in crate C.
Crate B holds the actual bug, and it contains no 'static, no nested reference,
no coercion, and no bound of its own:
pub fn exploit<'long, 'short, T>(_: Wrap<'long, 'short>, v: &'short T) -> &'long T { v }
That crate scores zero hits. A two-crate split still lights up both halves, so three is the number that defeats the whole set. The coercion rule also sees only two of the five places a coercion can appear. A call argument, a struct-field initialiser, and a return position all evade it.
What to do about it.
-
Run Miri
cargo +nightly miri test. That is the defense that works. It caught this program, and it catches the variants. -
Add the ast-grep rules as a CI tripwire
They are cheap and they fire on your own code. At 226 files in 99,364, the noise is small enough to triage.
-
Do not treat them as a dependency audit
Figure 5 is the reason. A syntax matcher loses to a bound it cannot see.
-
Reject the "Rust is a scam" conclusion
The number that matters is memory-safety defect rates in real Rust against real C and C++, not the existence of one open compiler bug.