Fuzzing for Security Bugs in Rust Parsers Step by Step
Fuzz a Rust parser for security bugs: panics, hangs, huge allocations and unsafe memory errors. Set up targets with limits, run with sanitizers and triage crashes.

A parser is the part of a program that meets hostile input first, and parser bugs are where memory-safety advisories, crashes and denial-of-service reports come from. Rust removes the exploitable class (out-of-bounds reads and writes become panics), but a panic that any client can trigger is still an outage, and stack overflow, unbounded allocation and infinite loops are not prevented by the compiler at all. Fuzzing is the cheapest way to find these before an attacker does: a program feeds your code millions of mutated inputs per minute, guided by which inputs reach new code, and keeps the ones that crash it. This guide fuzzes a small binary-message parser with cargo-fuzz. It finds three different bugs, in the order a real project meets them (a truncated header, a length that points past the end, and recursion that only crashes on the stack size you actually run with), fixes each, adds a structured round-trip fuzz target that checks the serializer against the parser, and ends with how to run this in CI. Everything ran on rustc 1.101 nightly with cargo-fuzz 0.13.2 on an Apple M1 Max, and the crash inputs and counts below are from those runs.
What fuzzing finds, and what it needs
A coverage-guided fuzzer (libFuzzer, driven by cargo-fuzz) starts from a set of example inputs, the corpus. It mutates them (flips bits, splices, inserts interesting integers), runs your function on each mutant, and watches which branches execute. An input that reaches a new branch is kept and mutated further. An input that makes the program crash (a panic, an abort, a sanitizer report) is saved to a file, the artifact, which is a ready-made reproducer. It needs three things: a function that takes bytes, a nightly toolchain (the sanitizer-coverage instrumentation is not stable), and a harness that runs the function in conditions that resemble production. The third is the one people skip, and the second bug below shows why it matters.
The code under test
The format is deliberately simple: a version byte, then records of [kind: u8][len: u16 big-endian][payload], where kind 0xFF holds a nested list of records. It is the shape of a hundred real protocols. This is the first version, written the way a first version is written:
//! A toy message format: version byte, then records of [kind: u8][len: u16 big endian][payload].
//! Kind 0xFF holds a nested list of records.
#[derive(Debug, PartialEq)]
pub enum Record {
Text(String),
Number(u32),
Nested(Vec<Record>),
Unknown { kind: u8, len: usize },
}
pub fn parse_message(data: &[u8]) -> Result<Vec<Record>, String> {
if data.first() != Some(&1) {
return Err("unsupported version".into());
}
parse_records(&data[1..])
}
fn parse_records(data: &[u8]) -> Result<Vec<Record>, String> {
let mut records = Vec::new();
let mut pos = 0;
while pos < data.len() {
let kind = data[pos];
let len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
let payload = &data[pos + 3..pos + 3 + len];
pos += 3 + len;
records.push(match kind {
0x01 => Record::Text(String::from_utf8(payload.to_vec()).map_err(|e| e.to_string())?),
0x02 => Record::Number(u32::from_be_bytes(payload.try_into().map_err(|_| "number must be 4 bytes")?)),
0xFF => Record::Nested(parse_records(payload)?),
other => Record::Unknown { kind: other, len },
});
}
Ok(records)
}
Read it as a reviewer and you may spot the problems: indexing with data[pos + 1] assumes bytes exist, slicing with pos + 3 + len trusts the length field, and the recursion has no limit. Read it as the compiler and none of it is an error. Set up the fuzz project:
cargo install cargo-fuzz
rustup toolchain install nightly
cargo fuzz init
mkdir -p fuzz/corpus/fuzz_target_1cargo fuzz init creates fuzz/ with a Cargo project and one target. The target is a closure that receives a byte slice:
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = fuzzlab::parse_message(data);
});
Seed the corpus with a few valid messages (one with a text and a number record, one with a nested list). Valid seeds let the fuzzer start inside the parser instead of spending its first minutes learning that the first byte must be 1. Then run:
cargo +nightly fuzz run fuzz_target_1 -- -max_total_time=30Bug 1: the truncated header
The run stopped within seconds:
thread 'main' panicked at src/lib.rs:24:39:
index out of bounds: the len is 12 but the index is 12
SUMMARY: libFuzzer: deadly signal
Test unit written to fuzz/artifacts/fuzz_target_1/crash-e473d9f54a2407ac960827fbd103fab06bc49b40The artifact is the input that triggered it, 13 bytes (01 ff 00 08 01 00 05 77 7a 6f 72 6c 64): a mutation of the nested seed in which a length field no longer matches the data. The panic location is line 24, the data[pos + 1] read, so the parser read a header that was cut off. Run the reproducer again at any time with cargo +nightly fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-e473..., and shrink it with cargo fuzz tmin, which removes bytes while the crash persists. Save the minimised file in your tests directory as a regression case. The fix is to use the checked accessor, get, which returns None instead of panicking, and to turn that into an error:
let header = data.get(pos..pos + 3).ok_or("truncated record header")?;
let kind = header[0];
let len = u16::from_be_bytes([header[1], header[2]]) as usize;Bug 2: a length that points past the end
Run again. The fuzzer needed a few more seconds to find the second hole, one line below the first:
thread 'main' panicked at src/lib.rs:26:28:
range end index 270 out of range for slice of length 11The reproducer was 12 bytes: a nested record of length zero, then a record of kind 5 whose length field says 264 bytes when 5 remain. The parser added the claimed length to the position and sliced, trusting the sender. This is the most common parser bug in any language, and in C it is a buffer over-read. In Rust it is a panic, which is better and still a remote crash. The fix is again get:
let payload = data.get(pos + 3..pos + 3 + len).ok_or("record longer than the message")?;With both fixed, the same target ran for a minute with inputs up to 100,000 bytes and found nothing: 10,437,565 executions in 61 seconds, about 171,000 per second, with coverage saturated. A clean run proves less than it feels like it does. The fuzzer is exploring the inputs its harness can express, under the conditions the harness provides, and there is a bug that neither covers.
Bug 3: the crash that depends on the stack
The recursion into nested records has no depth limit, so a message that nests 10,000 deep makes the parser recurse 10,000 frames. Whether that crashes depends on the stack of the thread it runs on. A small program builds nested messages of increasing depth and runs the parser on a thread with a 2 MiB stack (the default for Tokio worker threads) and an 8 MiB stack (the default for a main thread), in a subprocess each so that an overflow does not take the harness down:
use std::process::Command;
fn nested(depth: usize) -> Vec<u8> {
let mut payload = Vec::new();
for _ in 0..depth {
let mut rec = vec![0xff];
rec.extend_from_slice(&(payload.len() as u16).to_be_bytes());
rec.extend_from_slice(&payload);
payload = rec;
}
let mut msg = vec![1];
msg.extend_from_slice(&payload);
msg
}
fn main() {
let mut args = std::env::args().skip(1);
if let (Some(depth), Some(stack_kib)) = (args.next(), args.next()) {
let depth: usize = depth.parse().unwrap();
let stack: usize = stack_kib.parse::<usize>().unwrap() * 1024;
let msg = nested(depth);
let h = std::thread::Builder::new().stack_size(stack).spawn(move || fuzzlab::parse_message(&msg).map(|r| r.len())).unwrap();
println!("depth {depth}: {:?}", h.join().unwrap());
return;
}
let exe = std::env::current_exe().unwrap();
for stack_kib in [2048, 8192] {
for depth in [1_000, 5_000, 10_000, 15_000, 21_000] {
let out = Command::new(&exe).args([depth.to_string(), stack_kib.to_string()]).output().unwrap();
let verdict = if out.status.success() { "ok".to_string() } else { format!("CRASH ({})", String::from_utf8_lossy(&out.stderr).lines().find(|l| l.contains("overflow")).unwrap_or("signal")) };
println!("stack {:>4} KiB, nesting depth {:>6}: {verdict}", stack_kib, depth);
}
}
}
stack 2048 KiB, nesting depth 1000: ok
stack 2048 KiB, nesting depth 5000: ok
stack 2048 KiB, nesting depth 10000: ok
stack 2048 KiB, nesting depth 15000: ok
stack 2048 KiB, nesting depth 21000: ok
stack 8192 KiB, nesting depth 1000: ok
stack 8192 KiB, nesting depth 5000: ok
stack 8192 KiB, nesting depth 10000: ok
stack 8192 KiB, nesting depth 15000: ok
stack 8192 KiB, nesting depth 21000: okThe result is the point. On a 2 MiB stack the parser survived 5,000 levels and died at 10,000: a 30 KB message, well inside any request size limit, is a remote crash of the whole process, because a stack overflow is not a panic and cannot be caught. On the 8 MiB main-thread stack, even 21,000 levels (the most a 16-bit length allows) survived. The fuzzer on the main thread, which has the larger stack, never saw it. A fuzz harness that does not match the production environment gives false confidence, in this case by a factor of four in stack size. The target below runs the parser in a thread with the stack size the service uses, and the corpus includes one deeply nested seed so the fuzzer does not have to discover deep nesting from nothing:
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let data = data.to_vec();
// Production runs requests on threads with a 2 MiB stack (Tokio's default), so the harness does too.
std::thread::Builder::new()
.stack_size(2 * 1024 * 1024)
.spawn(move || {
let _ = fuzzlab::parse_message(&data);
})
.unwrap()
.join()
.unwrap();
});
With that harness, the fuzzer reported the failure immediately, from the deep seed, in the form AddressSanitizer uses:
SUMMARY: AddressSanitizer: stack-overflow in fuzzlab::parse_records
Test unit written to fuzz/artifacts/fuzz_target_2/crash-fbabe9c5b5a9324e79fde74a0babc695f4ec302dThe reproducer is 9,001 bytes (3,000 nested records). The fix is a depth limit, which any recursive parser needs; 32 levels is generous for real messages. Pass the depth down and return an error when it is exceeded:
//! A toy message format: version byte, then records of [kind: u8][len: u16 big endian][payload].
//! Kind 0xFF holds a nested list of records.
#[derive(Debug, PartialEq)]
pub enum Record {
Text(String),
Number(u32),
Nested(Vec<Record>),
Unknown { kind: u8, len: usize },
}
pub fn parse_message(data: &[u8]) -> Result<Vec<Record>, String> {
if data.first() != Some(&1) {
return Err("unsupported version".into());
}
parse_records(&data[1..], 0)
}
const MAX_DEPTH: usize = 32;
fn parse_records(data: &[u8], depth: usize) -> Result<Vec<Record>, String> {
if depth > MAX_DEPTH {
return Err("nesting too deep".into());
}
let mut records = Vec::new();
let mut pos = 0;
while pos < data.len() {
let header = data.get(pos..pos + 3).ok_or("truncated record header")?;
let kind = header[0];
let len = u16::from_be_bytes([header[1], header[2]]) as usize;
let payload = data.get(pos + 3..pos + 3 + len).ok_or("record longer than the message")?;
pos += 3 + len;
records.push(match kind {
0x01 => Record::Text(String::from_utf8(payload.to_vec()).map_err(|e| e.to_string())?),
0x02 => Record::Number(u32::from_be_bytes(payload.try_into().map_err(|_| "number must be 4 bytes")?)),
0xFF => Record::Nested(parse_records(payload, depth + 1)?),
other => Record::Unknown { kind: other, len },
});
}
Ok(records)
}
That is the final parser. The crash file now returns an error instead of crashing, and a 60-second run of the stack-matched target completed 1,455,987 executions with no findings; it is slower (about 24,000 per second) because every execution spawns a thread, a cost worth paying for the fidelity. Keep the crash files: copy each artifact into the repository's tests as a regression case, so the bug cannot return unnoticed.
Structured fuzzing: let the fuzzer build values, not bytes
Random bytes spend most of their effort on inputs the parser rejects at the first byte. When the property you care about is "whatever I serialize, I can parse back", generate values instead of bytes. The arbitrary crate turns the fuzzer's bytes into a typed value (derive Arbitrary on the type), and the target serializes it, parses the result and compares:
#![no_main]
use fuzzlab::{parse_message, serialize, Record};
use libfuzzer_sys::fuzz_target;
fn is_encodable(records: &[Record]) -> bool {
records.iter().all(|r| match r {
Record::Unknown { kind, len } => !matches!(kind, 0x01 | 0x02 | 0xFF) && *len <= 1000,
Record::Nested(inner) => is_encodable(inner),
_ => true,
})
}
fn depth(records: &[Record]) -> usize {
records.iter().map(|r| if let Record::Nested(inner) = r { 1 + depth(inner) } else { 0 }).max().unwrap_or(0)
}
fuzz_target!(|records: Vec<Record>| {
if !is_encodable(&records) || depth(&records) > 32 {
return;
}
let bytes = serialize(&records);
let parsed = parse_message(&bytes).expect("a serialized message must parse");
assert_eq!(parsed, records, "round trip changed the data");
});
The first version of this target, without the depth filter, found a bug in the first few seconds: a serialized message must parse: "nesting too deep". The generator produced a tree nested deeper than the parser's new limit, and the serializer happily wrote it. That is a real inconsistency between two halves of the same format, and the fix is a decision, not a patch: either the serializer must refuse to write what the parser refuses to read, or the limit belongs in one shared constant. The target above filters those inputs out so it can look for other problems, and a 60-second run then completed 4,947,955 executions (about 81,000 per second) with none. The filter for Unknown records matters too: a record with kind 1, 2 or 255 cannot round-trip as "unknown" because the parser gives those kinds meanings, and without the filter the fuzzer reports that as a failure, a reminder that a property test is only as good as its stated property.
Structured fuzzing finds logic bugs that byte fuzzing misses (a length encoded in 16 bits that silently truncates a 70,000-byte string, a number that changes sign, an off-by-one at a boundary), at the cost of writing the property. Use both: bytes for "does it crash" and structure for "is it right".
Turn every crash into a test
The artifact file is the most valuable thing the fuzzer produces, and it is a binary file in a directory that gets cleaned. Copy each one into tests/crashes/ (the three here are 13, 12 and 9,001 bytes) and add a test that runs the code on all of them. The test is ordinary cargo test, so it runs on every platform and every CI job without a nightly toolchain, and it carries the stack-size lesson with it:
use std::fs;
/// Every file in tests/crashes once crashed the parser. None may crash it again.
#[test]
fn old_crashes_stay_fixed() {
let mut checked = 0;
for entry in fs::read_dir("tests/crashes").unwrap() {
let path = entry.unwrap().path();
let data = fs::read(&path).unwrap();
// The deep-nesting crash was a stack overflow, so run on the same 2 MiB stack as production.
let outcome = std::thread::Builder::new()
.stack_size(2 * 1024 * 1024)
.spawn(move || fuzzlab::parse_message(&data))
.unwrap()
.join()
.unwrap_or_else(|_| panic!("{} panicked", path.display()));
println!("{:<18} -> {:?}", path.file_name().unwrap().to_string_lossy(), outcome.map(|r| r.len()));
checked += 1;
}
assert_eq!(checked, 3);
}
deep-nesting -> Err("nesting too deep")
length-past-end -> Err("record longer than the message")
truncated-header -> Err("truncated record header")
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outEach old crash now produces a specific, typed error, which also documents the behaviour: the parser rejects these inputs and says why. The deep-nesting case is run on a 2 MiB thread on purpose; on the default test thread (also 2 MiB) it would pass for the same reason, but being explicit means the test keeps its meaning if the test runner's thread size changes.
Reading the fuzzer's status lines
The lines libFuzzer prints while running tell you whether the run is worth continuing. Two from the structured run:
#148 INITED cov: 264 ft: 1216 corp: 134/5862b exec/s: 0 rss: 40Mb
#4947955 DONE cov: 280 ft: 1609 corp: 246/82Kb lim: 4096 exec/s: 81114 rss: 512Mb| Field | Meaning | What to look for |
|---|---|---|
#148, #4947955 | Executions so far | The second line is the final count for the run |
cov | Code blocks (edges) reached | Rose from 264 to 280 over five million runs: nearly saturated |
ft | Features: finer-grained coverage signals such as counters | Still climbing (1216 to 1609) while cov flattens, so still learning about paths |
corp | Corpus size: inputs kept and total bytes | Grows as new coverage is found; shrink with cmin |
lim | Current maximum input length | Increases gradually; raise with -max_len |
exec/s | Executions per second | 81,000 for an in-memory parse; 24,000 when each run spawns a thread |
rss | Resident memory | A steady climb is a leak in the target or the corpus; see -rss_limit_mb |
When cov stops rising and exec/s is healthy, extra time yields little unless you change something: add seeds that reach unexplored branches, add a dictionary of tokens, increase the input length limit, or write a structured target. A run whose exec/s is in the hundreds means the target is doing something slow per input (I/O, a large allocation, a thread per call) and deserves a profile before more CPU time.
Running it in CI and keeping it healthy
- Short runs on every pull request.
cargo +nightly fuzz run target -- -max_total_time=60replays the whole corpus (which includes your saved crash files) and explores for a minute. It catches regressions and cheap new bugs. - Long runs on a schedule. A nightly job of an hour or more per target, caching the corpus between runs, is where deep bugs appear. Commit the corpus or store it as a CI artifact; losing it throws away every hour of earlier exploration.
- Minimise the corpus with
cargo fuzz cmin target, which keeps the smallest set of inputs that still covers the same code, so replays stay fast. - Look at coverage.
cargo fuzz coverage targetproduces a report of which lines the corpus reached. A parser branch that no input ever reaches needs a seed or a dictionary entry (a file of tokens such as magic numbers, passed with-dict=). - Set limits that match production.
-max_lenshould be your request size cap,-rss_limit_mbyour memory budget,-timeoutyour request deadline. A memory limit hit is a finding (unbounded allocation from an attacker-controlled count), and so is a timeout (a quadratic loop). - Keep the harness honest. Run on the stack size, allocator and feature flags of the real service, as the second bug showed.
Free fuzzing infrastructure exists for open-source crates through OSS-Fuzz, which runs your targets continuously and reports crashes privately to maintainers. If you publish a parser crate, a fuzz target is a more convincing quality signal than any badge.
What to fuzz in a real codebase
Fuzz targets are cheap to write and expensive to run forever, so start with the code where untrusted data first becomes structure. A practical ranking:
| Target | Why it pays | Property to assert |
|---|---|---|
| Anything that parses network or file bytes (protocols, archives, images, config) | Attacker-controlled, complex, and the home of length and recursion bugs | No panic, no abort, bounded time and memory |
| Deserialisers for your own wire format | Every request passes through them | Round trip: parse(serialize(x)) equals x |
| Text processors (Markdown, templates, URL and path handling) | Quadratic blow-ups and Unicode edge cases | Runs within a time limit; output is valid |
| Unsafe code and its safe wrappers | The compiler stops checking, the fuzzer starts | Run under Miri or the address sanitizer with arbitrary calls in arbitrary order |
| Differential targets (your implementation against a reference) | Finds disagreement without needing to define "correct" | Both implementations agree on every input they both accept |
A good fuzz target is a thin function: take bytes, call the API, optionally assert one property. Anything slow or non-deterministic in the target (network, clocks, random numbers, global caches) wrecks the fuzzer, because it relies on the same input giving the same coverage. Seed random generators from the input, reset global state at the start of each run, and avoid logging inside the target; printing is the commonest cause of fuzz runs that crawl at a thousand executions a second.
The differential idea deserves a sentence because it needs no specification. If you are replacing a C library with a Rust one, or a hand-rolled parser with a crate, feed the same random input to both and assert that they accept the same inputs and produce the same values. Every disagreement is either a bug in yours or a documented difference to decide on, and finding them with a fuzzer takes an afternoon instead of the year it takes for users to find them. In this guide's terms: the etherparse comparison in the packet parsing guide is a hand-run differential test, and turning it into a fuzz target (assert_eq!(mine(data).ok(), reference(data).ok())) is a ten-line change that would run millions of cases.
What fuzzing does not do
It does not prove the absence of bugs; it samples. It finds crashes, hangs and assertion failures, not wrong answers, unless you write an assertion (the round trip). It struggles with formats guarded by checksums or cryptographic signatures, where almost every mutation is rejected before it reaches the interesting code (build the harness to bypass the check, as the guide to cargo-fuzz shows). And it will not tell you that a successful parse is semantically safe, for instance that a path inside a message does not escape its directory; for that you need the checks in safe path joins and a threat model. For the bounds of what a parser should accept before it does any work, see resource limits for parsers, and for hand-writing a network parser in the style fuzzed here, raw sockets and packet parsing.


