Issue
rust-lang/rust#25860
Filed
May 2015
Status
Open
Checked on
rustc 1.96.0 stable

Safe Rust that prints freed memory.

This program contains no unsafe. It compiles on stable rustc 1.96.0 under #![forbid(unsafe_code)]. It reads a heap buffer the allocator already reclaimed.

01
The program

It compiles. It should not.

exploit.rsno unsafe
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.

FIG 1

Where the pointer ends up

A · inside the blocklive
Before the inner block ends: dangling points at the String header on the stack, and the String header points at a live five-byte heap buffer. STACK HEAP dangling &'static String { INNER BLOCK } POINTS AT local: String ptr len5 cap5 5 BYTES · LIVE H e l l o dangling = fake_static(&local) THE BORROW IS NOW TYPED &'STATIC STRING
B · after the closing bracedangling
After the block ends: the String header slot is popped and the heap buffer is freed, but dangling still points at both. STACK HEAP dangling &'static String { BLOCK HAS ENDED } STILL POINTS AT POPPED local: String ptr len5 cap5 5 BYTES · FREED ? ? ? ? ? println!("{}", dangling) READS A POPPED SLOT, THEN A FREED BUFFER
live memory reclaimed by the allocator a pointer that outlived its target
The read takes two hops. dangling points at the String header, which lived in the inner block's stack frame. That header holds the pointer to the heap buffer. When the block ends, both are gone. Neither hop is checked at runtime, so println! reads whatever the process put there next.
02
Four steps

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.

FIG 2

The coercion, with the skipped check marked

Variance analysis of the fn-pointer coercion, showing that contravariance in argument position forces the lifetime parameter down to the caller's short lifetime, producing a type that is not well-formed. SOURCE · WHAT `EXPLOIT` REALLY IS fn exploit<'a, T>(_: [&'static &'a (); 0], v: &'a T) -> &'static T 1 2 3 1 argument position → contravariant array element → covariant 2 argument position → contravariant this is what pins 'a to the caller 3 return position → covariant WF NOT RE-CHECKED HERE TARGET · THE FN-POINTER TYPE IN THE `LET` fn([&'static &'static (); 0], &T) -> &'static T ELIDED RUSTC PICKS 'a := 'short THE LIFETIME OF `INPUT` [&'static &'short (); 0] NOT WELL-FORMED requires 'short: 'static, which is false

Drag sideways to pan →

Read it top to bottom. The fn item is generic over 'a. The let binding fixes a concrete fn-pointer type. Callout 2 does the damage: an argument is contravariant, so 'a is dragged down to the caller's lifetime. The red cut line marks the coercion, which is where rustc stops asking whether the instantiated parameter types are still well-formed.

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).

the whole remaining gapone character
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.

FIG 3

Where rustc checks well-formedness, and where it stops

01

Definition site

Checked
fn exploit<'a, T>( _: [&'static &'a (); 0], …

WF of the parameter type is proved. The body is handed 'a: 'static as an implied bound.

02

Direct call

Checked
exploit([], input)

Rejected with E0521. The caller cannot prove 'a: 'static, so there is no way through.

03

Fn-pointer coercion

Checked since Sept 2024 Gap 2015–2024
let f: fn(_, &'s T) -> &'static T = foo;

PR #129021 added the WF check on the source signature. This shape is now rejected.

04

Higher-ranked coercion

Still open
let f: fn(_, &T) -> &'static T = foo;

The elided lifetime is late-bound, so this is the for<'x> case. PR #129021 left it as future work.

Three of four gates hold. The one that does not is reached only through a coercion to a higher-ranked fn-pointer type. That is why the direct call is rejected and the laundered call is not.
FIG 4

Eleven years, one shape closed

Timeline from May 2015 to July 2026. The non-higher-ranked shape was exploitable until September 2024 and is now rejected. The higher-ranked shape has been exploitable the whole time and still is. MAY 2015 reported as #25860 6 SEP 2024 PR #129021 merged JUL 2026 still open NON-HIGHER-RANKED SHAPE fn(_, &'s T) -> &'static T EXPLOITABLE REJECTED HIGHER-RANKED SHAPE for<'x> fn(&'x T) -> &'static T EXPLOITABLE 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026

Drag sideways to pan →

exploitable on stable rejected by rustc
The axis is linear in time. The fix lands at roughly five sixths of the way across, which is the shape of the delay. The lower lane is the shape that still compiles today.
03
Measured, not assumed

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

Silent

Run with pedantic and nursery enabled. No lint exists for this.

Miri

Caught it
constructing invalid value of type &String: encountered a dangling reference (use-after-free)
04
The question people ask

Is 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.

05
The experiment

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.

FIG 6

Every hit, drawn against everything scanned

2,279crates
99,364.rs files
637hits
226files with a hit
99,364 files scanned100%
226 files with any hit · magnified ≈440×0.23%
Type declaration with an outlives bound struct W<'x, 'y: 'x>0.164% of files scanned
163
Nested reference, explicit outer lifetime0.131% of files scanned
130
Type given both 'static and a shorter lifetime0.016% of files scanned
16
&'static Foo<'a>0.003% of files scanned
3
Fn item coerced to a lifetime-pinning fn pointer0.001% of files scanned
1
Returns &'static T from a shorter borrow0% of files scanned
0
A coercion and any bound source in the same filethe combination that would be actionable
0
The top track is drawn to scale. The red mark at its left edge is 226 files out of 99,364, which is about two pixels wide on a laptop. The lower bars are that mark magnified. The last row is the finding: no file anywhere in the corpus contains both a coercion and a bound source.

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.

06
The honest limit

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.

evade.rscompiles · equally unsound
// 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.

FIG 5

Detection against evasion

Published exploit exploit.rs
4hits
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.

Evaded no 'static · bound in another crate
1hit
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.

Four hits become one. The rule that survives is the one the rule set already marks as not actionable by itself. The bound that makes the code unsound is a declaration in a crate the matcher never reads, so no amount of pattern tuning recovers the other three.

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:

crate Bholds the bug
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.

07
Practice

What to do about it.

  1. Run Miri

    cargo +nightly miri test. That is the defense that works. It caught this program, and it catches the variants.

  2. 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.

  3. Do not treat them as a dependency audit

    Figure 5 is the reason. A syntax matcher loses to a bound it cannot see.

  4. 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.