Skip to content
All posts
pwnrevised March 17, 20266 min read

Coverage-guided fuzzing: making the crash come to you

When a pwn or rev challenge hands you a parser and asks for the bug, you can read every line - or you can let AFL++ find the crash while you sleep. Harnesses, corpus, sanitizers, and what to do when the fuzzer gets stuck on a checksum.

A whole family of pwn and rev challenges hands you a parser - of a file format, a protocol, a config - and asks for the one input that makes it fall over. You can reverse the parser by hand and reason about every bounds check. Or you can point a coverage-guided fuzzer at it and let a few million test cases per minute find the bounds check that is missing.

Fuzzing has a reputation as a heavyweight, industrial thing. For CTF-sized targets it is the opposite: a ten-minute setup that runs while you work on something else and hands you a crashing input to triage. This article is that setup, and the handful of tricks that make the difference between a fuzzer that finds the bug and one that spins.

Why coverage guidance changes everything

A dumb fuzzer flips random bytes and hopes. What it is hoping to find is usually an arithmetic mistake at a boundary. It will never guess a four-byte magic number, so it never gets past the first check, so it explores nothing. A coverage-guided fuzzer instruments the target to record which code paths each input reaches, and keeps any input that reached somewhere new. That feedback loop is the entire trick, and it is astonishingly effective.

Consider a parser that branches on a chunk type: IHDR does one thing, IDAT another, an interlace flag opens a third path. A blind fuzzer cannot know that flipping one byte unlocks a whole new region of code. A coverage-guided one notices that a particular mutation reached new instructions and doubles down on it, effectively learning the format's keywords by watching which byte-runs change the execution path. It builds up the grammar you would otherwise have to hand it.

The minimum viable setup

AFL++ is the standard tool. If you have the source, you compile the target with AFL's instrumenting compiler and turn on a sanitizer while you are there.

# Compile with instrumentation + AddressSanitizer.
export CC=afl-cc CXX=afl-c++
export AFL_USE_ASAN=1
./configure && make          # or: afl-cc -fsanitize=address target.c -o target

# Seed the fuzzer with a few valid inputs - the corpus.
mkdir in && cp samples/*.png in/

# Go.
afl-fuzz -i in -o out -- ./target @@
The @@ is where AFL substitutes the path to each generated input. Omit it for targets that read from stdin.

AddressSanitizer is the second half of the value. Instrumentation finds the crash; ASan makes crashes happen that otherwise would not. A one-byte heap overflow usually does not segfault - it quietly corrupts an adjacent allocation and the program carries on. ASan poisons the bytes around every allocation and aborts the instant one is touched, turning a silent, invisible bug into a loud, located one with a stack trace.

Writing a harness when there is no CLI

Often the target is a library function, not a program that eats a file. A harness is a tiny wrapper that hands one fuzz input to the one function you care about, skipping everything else. It is also where nearly all of the speed comes from: opening a real file and initialising a whole program per test case might get you dozens of executions per second, while a tight harness around a single parse function gets thousands.

// harness.c - the standard libFuzzer entry point, which AFL++ also runs.
#include <stdint.h>
#include <stddef.h>
extern int parse_widget(const uint8_t *data, size_t len);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t len) {
    parse_widget(data, len);   // the one function under test
    return 0;
}
Compile with -fsanitize=fuzzer,address and you have a fast, sanitized fuzzer around exactly the code path you suspect.

The harness is also where you apply judgement. If the function you want sits behind a decompression step or a checksum, do that work inside the harness so the fuzzer's mutations land on the interesting data rather than being rejected at the gate. This is the single biggest lever you have over a fuzzing session's productivity.

When the fuzzer gets stuck

Watch the AFL++ status screen. Two numbers tell you whether it is working: executions per second, which should be in the hundreds or thousands, and the coverage/paths count, which should keep climbing. When coverage plateaus, the fuzzer has hit a wall it cannot mutate past, and the wall is almost always one of two things.

SymptomCauseFix
Coverage flat, execs highA checksum or magic gate rejecting every mutationPatch the check out of the target, or fix it up in the harness
Execs per second very lowThe target does slow work per input (disk, network, init)Move the hot function into a harness; use persistent mode
Stability below 100%The target is non-deterministic (time, PRNG, threads)Neutralise the source of randomness so identical inputs behave identically
Crashes that will not reproduceUsually a stability problem, sometimes ASLRFix stability first; replay with the same environment

The checksum wall is the classic one. A parser that verifies a CRC before doing anything interesting means every mutated input dies at the CRC, and the fuzzer learns nothing beyond it. In a CTF you own the binary, so the clean answer is to patch the check: find the comparison, NOP the branch, and let the fuzzer reach the code the CRC was guarding. Then, once you have a crash, fix the checksum back up in your final input so it survives the real program.

From crash to understanding

AFL++ drops each unique crashing input in out/default/crashes/. A crash file is not yet a solution - it is a lead. Run the target under a debugger on the crashing input and read where it died.

gdb --args ./target out/default/crashes/id:000000*
(gdb) run
# ... SIGSEGV ...
(gdb) backtrace          # the call stack at the moment of death
(gdb) x/i $pc            # the exact instruction that faulted
With ASan compiled in, you often do not even need the debugger - the abort message names the bug class (heap-buffer-overflow, use-after-free), the faulting address, and the allocation it belongs to.

The backtrace tells you which function and which line. A crash inside a checksum routine is a sign the fuzzer is stuck rather than a real find; a crash deep in the parsing logic, on an out-of-bounds read or write, is the bug the challenge wanted. From there the work is the usual pwn progression: understand what you corrupted, then decide whether it is a read you can steer into an info leak or a write you can steer into control flow.

The workflow, condensed

  1. Compile the target with AFL instrumentation and AFL_USE_ASAN=1.
  2. If it is a library, write a one-function harness - this is where the speed lives.
  3. Seed a small corpus of real inputs; minimise it with afl-cmin and afl-tmin.
  4. Run afl-fuzz and watch execs/sec and coverage on the status screen.
  5. If coverage plateaus, find the wall - usually a checksum - and patch it out.
  6. Triage each crash under gdb or read the ASan report, then decide read-vs-write and escalate.