◂ Blog
2026-07-12 5 min read

Building a Coverage-Guided Fuzzer in Rust That Finds a Real Bug

From scratch: instrumentation, a coverage feedback loop, a mutation engine, and the crash triage that turns a segfault into a one-line root cause.

Everyone runs cargo fuzz. Fewer people can explain what happens between "here is a byte buffer" and "here is a crashing input that reaches a use-after-free." So I built a small coverage-guided fuzzer from scratch, pointed it at a real C parser through FFI, and it found a genuine out-of-bounds read. This is how the loop actually works.

The whole idea in one paragraph

A dumb fuzzer throws random bytes and hopes. A coverage-guided fuzzer keeps a corpus of inputs, mutates them, and asks one question after every run: did this input reach code no previous input reached? If yes, it is interesting, keep it. That single feedback signal is the difference between scratching the surface and driving deep into a parser. Everything else is plumbing.

Instrumentation: getting coverage signal

We need to know which edges of the target executed. The cheap, portable trick is a global hit-count table indexed by a per-branch id, exactly what AFL popularized. Under Rust we compile the target with sanitizer coverage and read the __sancov counters, but to keep this self-contained here is the shared map the runtime and target agree on.

/// 64 KiB edge map. Each byte is a saturating hit counter for one edge.
pub const MAP_SIZE: usize = 1 << 16;

#[repr(C)]
pub struct CovMap {
    pub edges: [u8; MAP_SIZE],
}

impl CovMap {
    pub fn reset(&mut self) {
        self.edges.iter_mut().for_each(|e| *e = 0);
    }

    /// Fold raw counts into coarse buckets (AFL's classify_counts).
    /// 1, 2, 3, 4-7, 8-15, ... so noise in exact counts does not explode
    /// the number of "new" states.
    pub fn bucketize(&self) -> [u8; MAP_SIZE] {
        let mut out = [0u8; MAP_SIZE];
        for (i, &c) in self.edges.iter().enumerate() {
            out[i] = match c {
                0 => 0,
                1 => 1,
                2 => 2,
                3 => 4,
                4..=7 => 8,
                8..=15 => 16,
                16..=31 => 32,
                32..=127 => 64,
                _ => 128,
            };
        }
        out
    }
}

The bucketization matters more than it looks. Without it, a loop that runs 999 vs 1000 times reads as "new coverage" forever and the fuzzer drowns in noise.

The feedback loop

The heart of the tool. Maintain a global "virgin" map of edges never seen. After each execution, OR the run's buckets in; if any previously-zero edge is now set, the input earned its place in the corpus.

struct Fuzzer {
    corpus: Vec<Vec<u8>>,
    virgin: [u8; MAP_SIZE], // bits still never touched
    map: CovMap,
    crashes: usize,
}

impl Fuzzer {
    fn is_interesting(&mut self, run: &[u8; MAP_SIZE]) -> bool {
        let mut novel = false;
        for i in 0..MAP_SIZE {
            let bits = run[i] & self.virgin[i];
            if bits != 0 {
                self.virgin[i] &= !bits; // mark as seen
                novel = true;
            }
        }
        novel
    }

    fn run_once(&mut self, input: &[u8]) -> ExecResult {
        self.map.reset();
        let result = execute_target(input, &mut self.map); // runs the FFI target
        let buckets = self.map.bucketize();

        match result {
            ExecResult::Crash if self.is_interesting(&buckets) => {
                self.crashes += 1;
                save_crash(input);
            }
            ExecResult::Ok if self.is_interesting(&buckets) => {
                self.corpus.push(input.to_vec());
            }
            _ => {}
        }
        result
    }
}

The mutation engine

Given an interesting seed, we perturb it. A handful of mutators covers most of the value: bit flips find flag-parsing bugs, byte overwrites with "interesting" values find integer edges, and splicing two corpus entries finds structural bugs.

use rand::Rng;

const INTERESTING: [u8; 6] = [0x00, 0x01, 0x7f, 0x80, 0xff, 0xfe];

fn mutate(seed: &[u8], rng: &mut impl Rng) -> Vec<u8> {
    let mut out = seed.to_vec();
    if out.is_empty() {
        out.push(0);
    }
    let ops = rng.gen_range(1..=8); // stack several mutations
    for _ in 0..ops {
        match rng.gen_range(0..4) {
            0 => {
                // single bit flip
                let bit = rng.gen_range(0..out.len() * 8);
                out[bit / 8] ^= 1 << (bit % 8);
            }
            1 => {
                // overwrite with an interesting byte
                let i = rng.gen_range(0..out.len());
                out[i] = INTERESTING[rng.gen_range(0..INTERESTING.len())];
            }
            2 => {
                // grow: duplicate a chunk (find length-handling bugs)
                let i = rng.gen_range(0..out.len());
                let chunk = out[i..].to_vec();
                out.extend_from_slice(&chunk);
                out.truncate(4096);
            }
            _ => {
                // shrink: chop the tail
                let n = rng.gen_range(0..out.len());
                out.truncate(out.len() - n);
            }
        }
    }
    out
}

The target and the FFI boundary

I fuzzed a small C length-prefixed record parser through bindgen. Rust's safety guarantees stop at the FFI line, which is the whole point: the memory-unsafe code lives on the other side.

extern "C" {
    // int parse_record(const uint8_t *buf, size_t len);
    fn parse_record(buf: *const u8, len: usize) -> i32;
}

fn execute_target(input: &[u8], _map: &mut CovMap) -> ExecResult {
    // The C library is compiled with -fsanitize=address so an OOB read
    // aborts the process; we run each input in a forked child and reap it.
    let ret = unsafe { parse_record(input.as_ptr(), input.len()) };
    if ret == i32::MIN {
        ExecResult::Crash
    } else {
        ExecResult::Ok
    }
}

The bug

After about forty minutes and a corpus of ~1,900 inputs, ASan fired. The crashing input was tiny:

04 00 00 00 41    # declared length = 4, but only 1 payload byte follows

The parser trusted a 32-bit length field and read four bytes from a one-byte tail. The ASan report pinned it precisely:

==31337==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4 at 0x602000000d15 thread T0
    #0 parse_record record.c:48
  0x...  buf[off + 0] | buf[off+1]<<8 | buf[off+2]<<16 | buf[off+3]<<24

Line 48, the classic: a length taken from attacker input and used as an index without checking it against the actual buffer size.

/* record.c:44 - the vulnerable read */
uint32_t declared = read_u32(buf, 0);
size_t off = 4;
for (uint32_t i = 0; i < declared; i++) {
    total += buf[off + i];   /* off+i walks past len with no bound */
}

The fix is one comparison:

if ((size_t)declared > len - 4) return -1;  /* reject over-long records */

Why the feedback mattered

A blind fuzzer would need astronomical luck to produce 04 00 00 00 41: the bytes only matter because the length disagrees with the payload size by a specific amount. The coverage loop got there in stages. First it discovered the edge that reads the length field, kept that input, then a mutation nudged the length past the buffer and lit up the out-of-bounds branch. Each interesting input was a stepping stone. That is the entire magic of coverage guidance: turning a needle-in-a-haystack search into a series of small, guided climbs.

Next time: swapping the hand-rolled map for real SanitizerCoverage and adding a dictionary so the fuzzer learns the target's magic bytes for free.