Tools

Testing unsafe Rust: Miri and cargo-fuzz catch what the compiler can't

TL;DR

Real Rust vulnerabilities live in unsafe blocks, which the compiler doesn't check. Miri catches use-after-free, uninitialised reads and data races during tests; cargo-fuzz finds crashing inputs. Put both in CI.

When moving from C/C++ to Rust, the hard part isn't safe code; it's the unsafe blocks: FFI layers calling old C libraries, data structures that manage their own memory, performance-tuned paths. Android's first Rust memory-safety vulnerability and the Linux kernel's first Rust CVE both sat inside unsafe. The compiler can't check this code, so you have to. The two main tools are Miri (detects undefined behaviour while running tests) and cargo-fuzz (generates inputs automatically to find crashes).

Miri: run your tests in a strict interpreter

Miri is an interpreter for Rust that executes the program step by step and checks every memory operation. It catches:

  • Out-of-bounds accesses and use-after-free.
  • Reads of uninitialised data.
  • Misaligned memory accesses and references.
  • Broken basic type invariants, such as a bool that is neither 0 nor 1.
  • Violated intrinsic preconditions, like copy_nonoverlapping on overlapping ranges.
  • Data races and some weak-memory effects.
  • Memory leaks, and (experimentally) Stacked Borrows/Tree Borrows aliasing violations.
rustup +nightly component add miri
cargo miri test
# try several seeds to explore different thread orders and allocation addresses
MIRIFLAGS="-Zmiri-many-seeds=0..16" cargo miri test

cargo-fuzz: let the machine invent bad inputs

cargo-fuzz is not a fuzzer itself but a tool that drives libFuzzer through the libfuzzer-sys crate. You write a "fuzz target" that takes arbitrary bytes and feeds them to the function under test:

#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = my_crate::parse(s);
    }
});
cargo fuzz init          # create the fuzz/ directory
cargo fuzz list          # list targets
cargo fuzz run parse     # run until a panic or crash

When it finds a failure, the crashing input is saved under fuzz/artifacts/ so you can replay it and write a regression test. The docs recommend pairing it with AddressSanitizer for richer crash reports.

Which tool when?

SituationToolWhy
Data structures using raw pointers, pure-Rust unsafeMiriPinpoints UB even when the program "works"
Parsers, decoders, anything handling external datacargo-fuzzFinds unexpected inputs that cause panics, overflows, crashes
FFI layers calling C librariescargo-fuzz + sanitizersMiri's FFI support is limited; sanitizers see the C side too
Complex atomic, lock-free codeMiri + loomThe Miri README itself recommends loom because its weak-memory emulation is incomplete

Limits to know

  • Miri explores one execution per run: bugs that only show up under other thread interleavings can be missed; use many seeds to widen coverage.
  • Miri is slow and limited: as an interpreter it is many times slower; OS API and FFI support is limited, and networking is unsupported.
  • No UB found is not proof: the Miri README is explicit that a clean run does not prove the code is sound.
  • Fuzzing needs runtime: a few minutes is only a start; the value comes from running continuously and keeping the corpus.

How to apply it

  • Dev: Every module with unsafe needs tests that run under Miri. Every function that takes outside data (files, network, FFI) needs a fuzz target.
  • Tech Lead: Add a cargo miri test CI job for crates containing unsafe (pin a nightly for that job), and run fuzzing on a schedule, turning crashes into regression tests. That turns "review unsafe carefully" into a verifiable process.
  • PM: When you hear "we rewrote it in Rust, so it's safe", ask: what percentage is unsafe, and is that part run under Miri and fuzzed?

Sources & references

  1. Miri: an undefined behavior detection tool for Rust — rust-lang (GitHub)
  2. Rust Fuzz Book: cargo-fuzz — rust-fuzz
  3. Rust Fuzz Book: Tutorial — rust-fuzz