Skip to content
rusthacks.dev
Testing

Fuzzing Rust Code With Cargo Fuzz and Libfuzzer Step by Step

Fuzz a Rust function with cargo-fuzz and libFuzzer: init, write a target, run, read crashes, minimize inputs, add a corpus and turn findings into regression tests.

14 min read
Summarize in AI
Flat illustration for the post “Fuzzing Rust Code With Cargo Fuzz and Libfuzzer Step by Step”, composed around cargo.

Unit tests check the inputs you thought of. A parser's bugs live in the inputs you did not, and the only practical way to find them is to try millions of inputs and watch for the one that misbehaves. That is fuzzing: a program that generates inputs, runs your code on each, and uses coverage feedback to steer toward inputs that reach new code. In Rust, cargo-fuzz wraps libFuzzer, and the whole setup takes a few minutes. This guide fuzzes a small binary message parser, a type of code that handles untrusted bytes and so rewards the attention, and walks through everything the fuzzer found.

It found three bugs in 40 lines. Two were panics that the compiler's safe indexing converted into crashes rather than memory corruption, and the third was a logic error that no panic would ever reveal, found by asserting a property in the fuzz target. All the output below is real, from cargo-fuzz on a nightly toolchain (1.101.0-nightly) driving a crate built with Rust 1.99, edition 2024, on Apple silicon.

What the fuzzer needs

Fuzzing in Rust uses sanitizer instrumentation that only nightly provides, so install the tool and a nightly toolchain:

Shell
rustup toolchain install nightly --profile minimal
cargo install cargo-fuzz --locked

You fuzz a library crate. cargo fuzz init adds a fuzz/ directory with its own Cargo.toml and a target file, and the fuzz crate depends on yours. The code under test is a message format with a length-prefixed name, a count, and that many 32-bit values:

Text
+--------+-----------------+----------------+-----------------------+
| len u8 | name (len bytes)| count u16 (BE) | count * u32 (BE) vals |
+--------+-----------------+----------------+-----------------------+

Here is the first version of the parser. It compiles, and its one unit test, a well-formed message, passes:

src/lib.rsrust
pub fn parse(input: &[u8]) -> Result<Message, ParseError> {
    let name_len = *input.first().ok_or(ParseError::Truncated)? as usize;
    let name_bytes = &input[1..1 + name_len];
    let name = std::str::from_utf8(name_bytes).map_err(|_| ParseError::NameNotUtf8)?.to_string();

    let rest = &input[1 + name_len..];
    let count = u16::from_be_bytes([rest[0], rest[1]]) as usize;

    let mut values = Vec::with_capacity(count);
    for chunk in rest[2..].chunks_exact(4).take(count) {
        values.push(u32::from_be_bytes(chunk.try_into().unwrap()));
    }
    Ok(Message { name, values })
}

The author checked that the input is not empty before reading the length byte. Read it again with an attacker's eye before looking at what the fuzzer says: what if the length byte says 200 and the input has 5 bytes?

The fuzz target

A fuzz target is a function that takes arbitrary bytes and runs the code. The simplest useful property is "does not panic", so the target does nothing but call the parser:

fuzz/fuzz_targets/parse.rsrust
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    // The only property: parsing arbitrary bytes must return, never panic.
    let _ = msgparse::parse(data);
});

Run it for 20 seconds. The command compiles an instrumented binary and starts generating inputs:

Shell
cargo +nightly fuzz run parse -- -max_total_time=20

Round one: the first crash arrives instantly

It did not take 20 seconds. On the first input it tried, the fuzzer crashed the parser:

Text
INFO: Running with entropic power schedule (0xFF, 100).
INFO: A corpus is not provided, starting from an empty corpus
thread '<unnamed>' panicked at src/lib.rs:23:28:
range end index 11 out of range for slice of length 1
==30874== ERROR: libFuzzer: deadly signal
...
Failing input:

	fuzz/artifacts/parse/crash-adc83b19e793491b1c6ea0fd8b46cd9f32e592fc

Output of `std::fmt::Debug`:

	[10]

Reproduce with:

	cargo fuzz run parse fuzz/artifacts/parse/crash-adc83b19e793491b1c6ea0fd8b46cd9f32e592fc

Minimize test case with:

	cargo fuzz tmin parse fuzz/artifacts/parse/crash-adc83b19e793491b1c6ea0fd8b46cd9f32e592fc

Four parts of that output are worth reading in order. The panic message and location (src/lib.rs:23:28, "range end index 11 out of range for slice of length 1") is the bug: a one-byte input whose only byte, 10, claims a ten-byte name, so &input[1..11] runs off the end. The stack trace below it, mostly sanitizer noise, is mostly not needed because the panic line already names the file. The Failing input is saved as a file in fuzz/artifacts/, and [10] is its content. And the last two lines are the workflow: rerun that exact input to reproduce, or tmin to shrink it.

The fuzzer needed no clever mutation. The empty corpus produces the byte 10 almost immediately, and one byte is the smallest input with a non-empty length field. This is why fuzzing parsers so often finds a crash in under a second: the checks that a human forgot are the first thing random input hits.

The fix is to use get, which returns None instead of panicking when the range is out of bounds:

src/lib.rsrust
let name_bytes = input.get(1..1 + name_len).ok_or(ParseError::Truncated)?;

Round two: the next unchecked index

Rerunning the fuzzer finds the next one at once:

Text
thread '<unnamed>' panicked at src/lib.rs:27:36:
index out of bounds: the len is 0 but the index is 0
Failing input:
	[0]

Input [0] is a zero-length name and then nothing, so the two count bytes the parser indexes with rest[0] and rest[1] do not exist. The fix reads the two bytes with split_first_chunk, which returns None if fewer than two remain, and carries the remaining slice forward:

src/lib.rsrust
let (count_bytes, rest) = rest.split_first_chunk::<2>().ok_or(ParseError::Truncated)?;
let count = u16::from_be_bytes(*count_bytes) as usize;

Notice what Rust did for you. In C, both of these bugs are out-of-bounds reads and the fuzzer would need a memory sanitizer to notice them. In Rust the indexing panics, so the program is memory safe and the bugs surface as crashes you can see. They are still bugs: in a service, a panic on input from the network is a denial of service, and in a library it takes down the caller's thread.

Round three: 12.9 million inputs, no crash

With both fixed, the fuzzer ran for its full 30 seconds:

Text
#8388608	pulse  cov: 61 ft: 97 corp: 23/1166b lim: 4096 exec/s: 419430 rss: 174Mb
#12887115	DONE   cov: 61 ft: 97 corp: 23/1166b lim: 4096 exec/s: 415713 rss: 175Mb
Done 12887115 runs in 31 second(s)

The status line is how you judge whether a clean run means anything. cov: 61 is the number of code blocks the inputs have reached, and ft: 97 counts distinct coverage features, which includes how often a branch was taken. corp: 23/1166b says the fuzzer kept 23 interesting inputs totaling 1,166 bytes; each is an input that reached something new. exec/s: 415713 is throughput, over 400,000 runs per second, which is what makes fuzzing effective: a unit test suite never runs this many cases. A run is only convincing when cov and ft flatten out, meaning the fuzzer stopped finding new code, and when the throughput is high. A target that does 20 executions per second, because it reads a file or allocates huge buffers, is not exploring much.

A clean run does not mean the code is correct. It means the code does not panic on any of the 12.9 million inputs tried. Whether it computes the right answer is a different question, and that is where the third bug was hiding.

Asserting a property: the bug that is not a crash

A panic is the only signal the fuzzer has by default. To find wrong answers, state what must be true. The parser has an inverse, an encode function, so the natural property is: whatever parses must re-encode to the same bytes. The target now checks it:

fuzz/fuzz_targets/parse.rsrust
fuzz_target!(|data: &[u8]| {
    // Property 1: parsing arbitrary bytes must return, never panic.
    let Ok(msg) = msgparse::parse(data) else { return };

    // Property 2: whatever parses must re-encode to exactly the bytes it was parsed from.
    // (Bytes after the message are ignored, so compare only the encoded prefix.)
    let encoded = msgparse::encode(&msg);
    assert_eq!(&data[..encoded.len().min(data.len())], &encoded[..], "parse then encode changed the message");
});
Text
thread '<unnamed>' panicked at fuzz_targets/parse.rs:12:5:
assertion `left == right` failed: parse then encode changed the message
  left: [0, 1, 0]
 right: [0, 0, 0]

Input [0, 1, 0] is an empty name, then a count of 256, then nothing. The parser read zero values because there were none to read, and returned Ok with an empty list, silently accepting a message that promised 256 values and delivered none. Re-encoding wrote a count of zero. The bug is real, not cosmetic: a truncated message was treated as valid, and a protocol bug of exactly this kind lets an attacker smuggle a short message past a validator. No panic, no sanitizer report, and the only thing that found it was the property.

The fix compares what was read with what was promised:

src/lib.rsrust
if values.len() != count {
    return Err(ParseError::Truncated);
}

Shrink it, then pin it

The fuzzer's crash input was already short, but tmin finds the smallest input that still fails, and it is the habit to follow, because small inputs are easier to reason about:

Shell
cargo +nightly fuzz tmin parse fuzz/artifacts/parse/crash-62571891215b4efc1ceab744ce59dd0b66ea6f73

The three-byte input could not be reduced further. Turn it into an ordinary unit test, so the knowledge outlives the artifact file, which you will probably delete:

src/lib.rsrust
#[test]
fn fewer_values_than_declared_is_truncated() {
    // Found by the fuzzer: an empty name, a count of 0x0100 (256), and no value bytes at all.
    assert_eq!(parse(&[0, 1, 0]), Err(ParseError::Truncated));
}

With the fix and the new property in place, a longer run went clean over a larger space:

Text
#25182264	DONE   cov: 106 ft: 221 corp: 38/3612b lim: 4096 exec/s: 412824 rss: 200Mb
Done 25182264 runs in 61 second(s)

Compare the coverage with the earlier run: cov rose from 61 to 106 and the corpus grew to 38 inputs. The property made more code reachable, because inputs that parse successfully now continue into encode and the comparison, and so the fuzzer found and kept inputs that exercise both.

Structured inputs with Arbitrary

Random bytes spend most of their time failing the first length check. If you want to exercise the happy path deeply, let the fuzzer generate typed values and run them through the code in the other direction. The arbitrary crate derives a generator from a struct:

fuzz/fuzz_targets/roundtrip.rsrust
#![no_main]

use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use msgparse::{encode, parse, Message};

/// The fuzzer builds well-typed messages directly, so every run exercises the interesting path
/// instead of spending most of its time on bytes that fail the length checks.
#[derive(Debug, Arbitrary)]
struct Input {
    name: String,
    values: Vec<u32>,
}

fuzz_target!(|input: Input| {
    // The wire format can only carry names up to 255 bytes and 65535 values.
    if input.name.len() > 255 || input.values.len() > u16::MAX as usize {
        return;
    }
    let msg = Message { name: input.name, values: input.values };
    let bytes = encode(&msg);
    assert_eq!(parse(&bytes), Ok(msg), "encode then parse changed the message");
});

This target builds a valid Message, encodes it, parses the bytes back and asserts equality. It cannot hit the truncation checks, because it only produces well-formed bytes, so it complements the first target and does not replace it. Run both: the byte-level target for robustness against hostile input, and the structured one for correctness of the round trip. In 30 seconds it ran 7.26 million cases without a failure:

Text
Done 7264891 runs in 31 second(s)

How the fuzzer decides what to try

It helps to know what is happening during those 12.9 million runs, because it explains the tuning advice. libFuzzer is coverage-guided. It starts with an empty or seeded corpus of inputs, picks one, mutates it (flips a bit, inserts or deletes a byte, copies a chunk, replaces a value with an interesting constant like 0, 255 or 65535), and runs the target. The compiler has instrumented every branch in your code, so after each run libFuzzer knows which branches executed. If the input reached a branch no earlier input reached, it is added to the corpus, since it must be doing something new. If not, it is discarded. Repeating that loop evolves a corpus that covers more and more of the program.

This explains why the length-prefixed parser fell so quickly: a mutation that changes the first byte to a different value changes which branch of the name-length check executes, and libFuzzer keeps it. It also explains why some code resists fuzzing. A check such as if checksum(data) == data[0..4] has branches that random mutation will essentially never satisfy, because a coverage signal appears only when the whole comparison passes. The standard remedies are to give the fuzz target a way to fix the checksum itself (compute it in the harness and write it into the input before calling the parser), or to disable the check under a cfg(fuzzing) flag, which cargo-fuzz sets, so the fuzzer reaches the code behind it.

Fuzzing unsafe code

The sanitizers make fuzzing the best tool for finding memory errors in unsafe. As an experiment, the crate gained a function that sums the first n bytes of a slice using get_unchecked, with a deliberate off-by-one: it clamps n to data.len() + 1, one more than the slice holds. A tiny target feeds it arbitrary bytes, using the first as n. The fuzzer found it on the first input, and the build printed the precondition check from the standard library, enabled because fuzz builds keep debug assertions on:

Text
thread '<unnamed>' panicked at src/lib.rs:78:43:
unsafe precondition(s) violated: slice::get_unchecked requires that the index is within the slice

This indicates a bug in the program. This Undefined Behavior check is optional, and cannot be relied on for safety.

The input was [10]: a count of 10 and an empty slice. The clamp allowed one element, so the loop read index 0 of an empty slice. In a release build without that check, the same read would silently return whatever sat past the end of the allocation. Two lessons. Fuzz builds are a good place to find this class of bug precisely because assertions are on, so do not turn them off to run faster. And the message itself says the check is optional and cannot be relied on: it is a debugging aid, not protection. The same bug under the address sanitizer, with the check compiled out, is reported as a heap buffer overflow with the allocation site, which is more informative when the slice came from a larger structure.

Reading a plateau

The status lines tell you when to stop. In the early seconds cov and ft climb quickly and then flatten. A plateau means the fuzzer has stopped finding new code with its current strategies. Before declaring the target done, ask three questions. Is the plateau low? A parser with 500 branches stuck at cov: 40 is hitting an early rejection; look at which check, and seed the corpus with an input that passes it. Does the coverage include the code you care about? cargo fuzz coverage parse followed by llvm-cov produces a report showing which lines were never executed, and unexecuted lines in a function you wanted to test are the to-do list. And is throughput healthy? A drop from 400,000 to 4,000 executions per second means the target started doing something expensive on some inputs, such as allocating a vector sized by an attacker-controlled count. That is itself a finding: Vec::with_capacity(count) with a count read from the input is a denial-of-service bug in a parser whenever the count can be large. Here it was bounded by u16 at 65,535 entries and so only 256 KB, which is why throughput held, but a u32 count would have been a 16 GB allocation request.

Triage: from crash to fix

A fuzzing session often produces several crashes, and the habit is to treat them in a fixed order. Reproduce each with cargo fuzz run <target> <artifact>, which runs a single input and takes a second. Minimize with tmin so the input is as small as possible. Read the panic location, not the stack trace; the first line names the file and the expression. Decide whether it is a bug in your code or in the harness, for instance a harness that asserts something the specification does not promise. Fix it, add the minimized input as a unit test with a comment recording that the fuzzer found it, and rerun the fuzzer from the existing corpus to confirm the crash is gone and no new one appeared. Crashes in a fixed program that look different but share a root cause usually disappear together, so fix one at a time and rerun between fixes, as the three rounds above did.

Making fuzzing a habit

A one-off session found three bugs. Keeping the benefit takes a little structure.

Commit the corpus. The inputs in fuzz/corpus/<target>/ are the fuzzer's accumulated knowledge, and a seeded run starts from them instead of from nothing. Add a few real-world examples by hand, such as sample messages from a test fixture; seeds with valid structure help the fuzzer reach deep code quickly. cargo fuzz cmin minimizes a corpus to the smallest set with the same coverage.

Run it in CI, briefly. A 60-second run on every pull request catches regressions, and a nightly job with a time budget of an hour explores further. Cache the corpus between runs so progress accumulates. Always replay the saved crash artifacts as part of the normal test suite so a fixed bug stays fixed.

Add a dictionary for formats with magic tokens. A file of keywords ("HTTP/1.1", "\x89PNG") passed with -dict= lets the fuzzer splice them into inputs, which helps it cross checks that random bytes would almost never satisfy.

Pick the right targets. Fuzz code that parses, decodes or interprets untrusted input: protocol parsers, file formats, deserializers, query languages, regular expression compilers. Fuzz anything with an inverse, using the round-trip property. And fuzz your unsafe code, with the address sanitizer that cargo-fuzz enables by default, since a fuzzer that reaches an out-of-bounds write reports it immediately.

What fuzzing does not do

A fuzzer cannot tell you code is correct, only that it survived the inputs it tried. It will not find bugs that need a precise 32-byte value matching a checksum, unless a dictionary or a custom mutator helps. It is slow to reach states that need long, valid sequences, such as a protocol that must complete a handshake first; the answer is a structured target that drives the state machine. It cannot test concurrency bugs well. And a fuzz target that panics on its own harness errors, or allocates unboundedly, produces false alarms that train people to ignore the tool. Keep targets small, deterministic and fast.

For the other half of the testing toolbox, see testing in Rust: unit, integration, doc and property tests, and for fuzzing aimed at finding security flaws specifically, fuzzing for security bugs in Rust parsers.

Also inSecurity
Taggedfuzzing
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.