From a corrupted file to a fuzzing harness
A handful of broken fixtures finds the obvious bugs. Coverage-guided fuzzing finds the rest. Here is how a seed corpus becomes a real AFL++ or libFuzzer run.
Hand-made broken files are the right place to start testing a parser. They are cheap, they are reproducible, and they find the shallow bugs quickly. What they cannot do is explore the input space, and past a certain point that is the only thing that finds anything new.
That is where fuzzing takes over, and the bridge between the two is shorter than most people expect.
Random input is not fuzzing
Feeding a parser random bytes finds almost nothing. Any real format rejects random input in the first few bytes, so every run terminates in the same place and the same code never executes.
Modern fuzzers are coverage-guided. They instrument the target, watch which branches each input reaches, and keep any mutation that reaches somewhere new. The corpus evolves toward inputs that go deeper, which is why a fuzzer starting from a valid file eventually produces inputs that pass validation and break something interesting further in.
The seed corpus decides everything
Coverage-guided fuzzing needs somewhere to start, and the quality of that seed corpus dominates the results.
Good seeds are:
- Valid, so the fuzzer starts past the front-door checks
- Small, because mutation is more effective on less material
- Diverse, covering different features rather than twenty near-identical files
- Structurally interesting, exercising unusual but legal constructions
Deliberately damaged files earn a place here too. A file with a valid header and a broken index reaches error-handling code that a purely valid corpus never touches, and gives the fuzzer a foothold in exactly the region where bugs concentrate. If you keep a fixture set of the kind described in testing the error path nobody tests, it is already halfway to being a seed corpus. Seed a corpus with damaged files in a couple of minutes and drop them alongside the valid ones.
Minimise before you start
Two steps pay for themselves immediately.
afl-cmin removes seeds that add no new coverage, often cutting a corpus by an
order of magnitude with no loss. afl-tmin shrinks each individual file while
preserving the coverage it provides. A smaller corpus means faster iteration and
more executions per second, and execution rate is the single number that
determines how much ground a fuzzing campaign covers.
The harness
A fuzz target is a function that takes a byte buffer and feeds it to the code under test. With libFuzzer it looks like this:
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
parse_document(data, size); // no assertions, no printing
return 0;
}
Three rules matter. Keep it deterministic, because a target that behaves differently on identical input makes crash triage impossible. Keep it fast, since the harness runs millions of times. And do not write to disk or the network, because that is throughput thrown away.
Sanitizers do the actual detecting
A fuzzer only knows something went wrong if the program tells it. Without instrumentation, a heap overflow that does not happen to segfault is invisible.
Build with AddressSanitizer for memory errors, UndefinedBehaviorSanitizer for integer overflow and invalid casts, and MemorySanitizer for reads of uninitialised memory. Set a memory limit so a runaway allocation is reported rather than swapping the machine to death. That allocation case matters especially for file parsers, where a corrupted length field routinely says a record is three gigabytes long.
Triage and regression
A campaign produces duplicates. Group crashes by stack signature, minimise each representative, then move it into the normal test suite as a regression case. That last step is what converts a fuzzing run into permanent value, rather than a report nobody reads twice.
Running it continuously
Fuzzing rewards time. A single overnight run finds a batch of bugs and then goes quiet; the same target fuzzed continuously keeps producing findings for months as the corpus grows. Running a short campaign per pull request catches regressions, while a longer nightly job does the exploration. Open source projects can hand the whole problem to OSS-Fuzz.
Where fuzzing stops
Fuzzing finds crashes and undefined behaviour. It does not find logic errors, and it does not tell you whether the error message you show a user is appropriate or whether your upload endpoint returns the right status code. Those still need deliberate fixtures and a human deciding what correct looks like, and for uploads specifically the checklist in validating file uploads properly is the place to start.