Skip to content
rusthacks.dev
Security

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.

16 min read
Summarize in AI
Flat illustration for the post “Fuzzing for Security Bugs in Rust Parsers Step by Step”, composed around testing.

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:

src/lib.rsrust
//! 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:

Shell
cargo install cargo-fuzz
rustup toolchain install nightly
cargo fuzz init
mkdir -p fuzz/corpus/fuzz_target_1

cargo fuzz init creates fuzz/ with a Cargo project and one target. The target is a closure that receives a byte slice:

fuzz/fuzz_targets/fuzz_target_1.rsrust
#![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:

Shell
cargo +nightly fuzz run fuzz_target_1 -- -max_total_time=30

Bug 1: the truncated header

The run stopped within seconds:

Text
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-e473d9f54a2407ac960827fbd103fab06bc49b40

The 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:

rust
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:

Text
thread 'main' panicked at src/lib.rs:26:28:
range end index 270 out of range for slice of length 11

The 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:

rust
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:

examples/deep.rsrust
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);
        }
    }
}
Text
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: ok

The 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:

fuzz/fuzz_targets/fuzz_target_2.rsrust
#![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:

Text
SUMMARY: AddressSanitizer: stack-overflow in fuzzlab::parse_records
Test unit written to fuzz/artifacts/fuzz_target_2/crash-fbabe9c5b5a9324e79fde74a0babc695f4ec302d

The 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:

src/lib.rsrust
//! 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:

fuzz/fuzz_targets/roundtrip.rsrust
#![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:

tests/regressions.rsrust
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);
}
Text
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 out

Each 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:

Text
#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
FieldMeaningWhat to look for
#148, #4947955Executions so farThe second line is the final count for the run
covCode blocks (edges) reachedRose from 264 to 280 over five million runs: nearly saturated
ftFeatures: finer-grained coverage signals such as countersStill climbing (1216 to 1609) while cov flattens, so still learning about paths
corpCorpus size: inputs kept and total bytesGrows as new coverage is found; shrink with cmin
limCurrent maximum input lengthIncreases gradually; raise with -max_len
exec/sExecutions per second81,000 for an in-memory parse; 24,000 when each run spawns a thread
rssResident memoryA 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=60 replays 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 target produces 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_len should be your request size cap, -rss_limit_mb your memory budget, -timeout your 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:

TargetWhy it paysProperty to assert
Anything that parses network or file bytes (protocols, archives, images, config)Attacker-controlled, complex, and the home of length and recursion bugsNo panic, no abort, bounded time and memory
Deserialisers for your own wire formatEvery request passes through themRound trip: parse(serialize(x)) equals x
Text processors (Markdown, templates, URL and path handling)Quadratic blow-ups and Unicode edge casesRuns within a time limit; output is valid
Unsafe code and its safe wrappersThe compiler stops checking, the fuzzer startsRun 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.

Also inTesting
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.