bitracker
pwning "safe" Rust into running my code

may 20, 2026 // 5 min read

hey!! :33

so uh. you know how rust people keep saying "memory safe language btw"? i wanted to see if i could get arbitrary code execution in Rust without a single unsafe block. no nightly features, no proc macro tricks, no build.rs cheating. just pure safe stable Rust, edition 2024

turns out... yes. very much yes lol

the bug

rust-lang/rust#135011 - theres a soundness hole in how the type checker handles cyclically-defined associated types. basically the type normalizer gets confused by a chain of trait impls that form a cycle, and you can trick it into treating any type L as any type R

in other words: std::mem::transmute, but in completely safe code.

fn transmute<L, R>(x: L) -> R {
    transmute_inner_1::<L, R, RewriteReflRight<FakeRefl<L, R>>>(x)
}

the full machinery is ~80 lines of trait definitions that construct a fake type equality proof. the compiler accepts it because the cyclic normalization prevents it from ever actually resolving the associated types to verify they match

this compiles on stable. the compiler literally says "yeah this is fine"

so what can you do with this

anything std::mem::transmute can do... forge pointers, create fake vtables, reinterpret arbitrary bytes as any type. its game over basically.

for the demo i went with the classic: forge a function pointer to libc::system() and call it

type SystemFn = extern "C" fn(*const u8) -> i32;

fn main() {
    let system_fn: SystemFn =
        transmute::<usize, SystemFn>(libc::system as *const () as usize);

    system_fn(b"echo 'mrow :3'\0".as_ptr());
}

output:

[*] rustpwn - ACE in safe stable Rust
[*] rust-lang/rust#135011

mrow :3

zero unsafe keywords anywhere. compiles on stable. runs on stable. grep -rn unsafe src/main.rs returns nothing. #[forbid(unsafe_code)] passes

how the type trickery works

it constructs a fake proof of L == R through several layers of traits:

  • TypeIs<T> - trivial identity trait, type Is = T for everything
  • TyEq / TyEqImpl - trait pair representing a "proof" that LHS == RHS
  • Refl<T> - the real deal, legitimate proof that T == T
  • FakeRefl<L, R> - the scam. "proof" that L == R, deriving its TyEq impl from DeriveEqFromImpl which delegates to Proof<()> - creating the cycle
  • RewriteReflRight<P> - rewrites the right side of a proof using another proofs definition, exploiting the fact that the compiler cant fully normalize the cyclic chain

the key: FakeRefl<L, R> claims L == R by saying "my proof is whatever FakeRefl<L, R>::Proof<()> says"... which is itself. the compiler chases this cycle, gives up on normalization, and goes "sure whatever". lol

btw steffahn (the person who reported the bug) managed to minimize the whole thing down to this:

trait Tr<R>: Sized { type Prf: Tr<R, Prf = Self>; }
impl<L, R> Tr<R> for L {
    type Prf = R where L: Tr<R>, R: Tr<R, Prf = <L::Prf as Tr<R>>::Prf>;
}
fn transmute<L: Tr<R>, R>(r: L) -> <L::Prf as Tr<R>>::Prf { r }

4 lines. safe transmute in 4 lines of stable Rust lol

the rabbit hole

so i went digging through the issue thread and uh... it gets worse

this bug is 11 years old

steffahn found a version that compiles all the way back to Rust 1.1 (june 2015). this isnt some recent regression. its a fundamental defect in the trait solver that has existed since basically forever

its actually multiple bugs

lcnr from the types team analyzed the minimized versions and discovered that steffahn accidentally found a second separate unsoundness while minimizing the first one:

that minimization is actually a separate unsoundness (or at least what I intend to do to fix the original is insufficient there)

so the original exploit hits one bug, and the 4-line version hits a different one. great

P-medium. no assignee.

a full transmute in safe stable Rust - arbitrary type confusion, arbitrary code execution, complete destruction of every safety guarantee the language promises - and its labeled P-medium. nobody is assigned to fix it. since january 2025

for context, as of may 2026 there are 100+ open I-unsound issues on the rust repo. this is just one of them

the fix requires rewriting the trait solver

the root cause according to lcnr: the compiler trusts item-bounds of an associated type without verifying that the type actually normalizes to something concrete. when the associated type "diverges" (references itself cyclically), the bound is invalid but the compiler never checks

Nadrieril connected it to classical type theory - its the ω combinator for non-positive recursion. the bug isnt in the implementation, its in the design of the type system itself - it allows non-positive recursion through associated types, which is known to be unsound in dependent type theory

the real fix is the trait-system-refactor-initiative, specifically the new trait solver (-Znext-solver). but thats a massive rewrite thats been in progress for years and isnt ready yet

any crate can silently include this

the scariest part: any crate on crates.io can embed this transmute and break all safety guarantees of downstream code. cargo audit wont flag it. #[forbid(unsafe_code)] wont catch it. theres no unsafe keyword to grep for. clippy wont complain. miri cant help because theres nothing to detect at the source level - the types just quietly lie

but what about shellcode

so i also tried writing actual aarch64 shellcode (mmap RWX page, write instructions, jump to them). it works in theory but on apple silicon, macos enforces W^X for JIT pages via MAP_JIT + pthread_jit_write_protect_np and you need to forge calls to those too. doable but annoying. the system() approach proves the exact same thing with less hassle

on x86_64 linux where you can mmap RWX pages freely - raw shellcode execution should just work

some conclusions

"rewrite it in rust" they said. "memory safe" they said.

11 years. P-medium. no assignee. january 2025. still compiles. still runs.

src: clickme

:33