algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

fuzzing a parser with AFL++, harness first

2026-03-283 min readfuzzing tooling

People treat AFL++ like a magic box: aim it at a binary, wait for crashes. The real leverage is upstream of that — a harness that drives the interesting code fast, with instrumentation so the fuzzer can see coverage and a sanitizer so shallow corruption actually crashes instead of silently limping on. Get the harness right and the fuzzer does the rest.

target./parse_config (library function: parse_config(buf, len))
goal
reach crashes in the parser
strategy
persistent-mode harness + afl-clang-fast + ASAN + a seed corpus

the harness is the target#

Don't fuzz the whole program through argv and a fresh process per input — that's slow and mostly exercises startup. Wrap the function under test in a persistent-mode loop so one process handles thousands of inputs:

harness.cc
#include <unistd.h>
extern int parse_config(const unsigned char *buf, long len);

__AFL_FUZZ_INIT();
int main(void) {
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;     // shared-memory input
    while (__AFL_LOOP(10000)) {                         // 10k iterations / fork
        long len = __AFL_FUZZ_TESTCASE_LEN;
        parse_config(buf, len);                        // <- the code we care about
    }
    return 0;
}

__AFL_LOOP resets and re-runs without a fresh execve each time — often a 10–100× throughput win. Build it instrumented, with AddressSanitizer so out-of-bounds and use-after-free turn into loud aborts the fuzzer counts as crashes:

build + runtxt
1
2
3
$ AFL_USE_ASAN=1 afl-clang-fast harness.c parser.o -o harness
$ mkdir corpus && printf 'key = value\n' > corpus/seed   # real, valid inputs
$ afl-fuzz -i corpus -o out -- ./harness

feed it well, then watch the right number#

  • Seed corpus. Start from valid inputs — real config files, sample packets. The fuzzer mutates from these; garbage seeds waste hours rediscovering basic structure. Then minimise: afl-cmin -i corpus -o corpus_min -- ./harness drops seeds that add no coverage.
  • Dictionary. Hand AFL++ the grammar's keywords (-x config.dict) so it splices in tokens like include/{/; it would take ages to mutate into existence.
  • The number that matters is coverage, not exec/s. A fast fuzzer stuck at flat map density is finding nothing. If coverage plateaus, your harness isn't reaching deep code — add a -d (deterministic-off) run, more seeds, or split a monster function so the magic-byte checks stop gating everything.

triage what falls out#

Crashes land in out/default/crashes/. They're raw inputs; reproduce and minimise before you read them:

reproduce + shrinktxt
1
2
3
$ ./harness < out/default/crashes/id:000003,sig:06,...     # ASAN prints the bug
==12345==ERROR: AddressSanitizer: heap-buffer-overflow ... in parse_config
$ afl-tmin -i out/default/crashes/id:000003,... -o min.bin -- ./harness

afl-tmin shrinks a 4KB crashing input down to the handful of bytes that actually trip it — usually the difference between "no idea" and "oh, an unbounded memcpy on a length field." ASAN's stack trace points at the exact line.

the bug afl-tmin handed me

A length field read from the input, trusted, and used straight as a memcpy size — three bytes of input, one heap overflow:

txt
segfault{the_harness_did_the_hard_part}

The fuzzer is the cheap part — it's a mutation loop. The work is the harness that drives the right code at speed, the corpus that gives it a running start, and the triage that turns a folder of crashing blobs into one root cause. Do those three well and AFL++ finds bugs in an afternoon that a week of manual review would walk straight past.