Cyber Security

What is fuzzing? Coverage-guided testing that finds real bugs

Fuzzing means generating inputs a program was not expecting and watching what breaks. How coverage-guided fuzzers work, why sanitizers matter more than the fuzzer, and how to run one.

Fuzzing is automated testing with hostile inputs: generate data the program was not expecting, feed it in, and watch for crashes. The technique dates to 1988, when Barton Miller's students at Wisconsin fed random characters to Unix utilities and crashed roughly a third of them. It is still finding bugs today for the same reason — programs are written against the input the author imagined.

Dumb fuzzing versus coverage-guided fuzzing

The distinction is what separates a curiosity from a practical tool.

Dumb fuzzing generates random bytes. Against a program expecting a PNG, almost every input is rejected in the first few bytes by the header check, so the fuzzer spends its time exercising the same rejection path forever.

Coverage-guided fuzzing instruments the binary so the fuzzer can see which branches an input reached. When a mutated input reaches a branch nothing has reached before, that input is kept and mutated further. The fuzzer effectively hill-climbs through the program's structure, and will discover a valid PNG header on its own given a starting example.

This is why coverage-guided fuzzers such as AFL++, libFuzzer and honggfuzz dominate. They turn an impossible search into a tractable one.

Sanitizers matter more than the fuzzer

A fuzzer detects a bug when the program does something observable — usually a crash. The problem is that most memory-safety bugs do not crash. Reading four bytes past the end of a heap buffer normally returns whatever was next in memory and the program carries on, so the fuzzer sees nothing.

Sanitizers fix that by making the bug loud:

  • ASan (AddressSanitizer) — buffer overflows, use-after-free, double-free. Roughly 2× slowdown, and worth it.
  • UBSan — undefined behaviour: signed overflow, misaligned access, invalid shifts.
  • MSan — reads of uninitialised memory.
  • TSan — data races.

Fuzzing a binary built without sanitizers finds only the bugs that happen to segfault, which is a small fraction of the bugs that are there. The sanitizer, not the fuzzer, is what usually determines the yield.

A worked example with libFuzzer

libFuzzer is built into Clang. You write an entry point that takes a buffer and passes it to the code under test:

bash
// fuzz_parse.c
#include <stdint.h>
#include <stddef.h>
#include "parser.h"

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 4) return 0;           // reject inputs too small to be meaningful
    parse_packet(data, size);          // the function under test
    return 0;                          // non-zero is reserved; always return 0
}

Build it with coverage instrumentation and AddressSanitizer, then run:

bash
clang -g -O1 -fsanitize=fuzzer,address,undefined \
      fuzz_parse.c parser.c -o fuzz_parse

mkdir -p corpus
cp samples/*.bin corpus/        # seed with real, valid inputs

./fuzz_parse corpus/ -max_len=4096 -jobs=8

Seeding matters. A fuzzer starting from an empty corpus must rediscover your input format from nothing; a handful of real files gets it past the parsing preamble immediately.

Reading the output

libFuzzer prints a line per significant event:

bash
#2048   NEW    cov: 412 ft: 891 corp: 37/1204b exec/s: 9182
#4096   REDUCE cov: 412 ft: 891 corp: 37/1198b exec/s: 9044
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60300000eff4
  • cov — basic blocks covered. If this stops rising for a long time, the fuzzer is stuck and needs better seeds or a dictionary.
  • corp — how many inputs are being kept as interesting.
  • exec/s — executions per second. Below a few hundred, something is wrong; check for logging or disk I/O in the harness.

On a crash the input is written to crash-<sha1>, and rerunning the binary against that single file reproduces it deterministically.

When the fuzzer stops finding things

A plateau in coverage is the normal state after a few hours. The usual remedies, in order of effectiveness:

  • Add a dictionary. Magic values and keywords are hard to discover by mutation. -dict=http.dict containing tokens such as "GET" and "Content-Length" can unlock large areas.
  • Remove checksum checks. A CRC at the start of the format rejects every mutation. Compile the harness with verification disabled.
  • Write more harnesses. One entry point covering one function usually beats a single harness trying to cover everything.
  • Minimise the corpus with -merge=1, so cycles are not spent on redundant inputs.

What fuzzing will not find

Fuzzing finds crashes and sanitizer violations. It does not find logic errors, because it has no idea what the correct answer is — a parser that returns the wrong value without crashing looks like success. It does not find authorisation flaws, business-logic mistakes or anything requiring a valid multi-step session. And it will not find a bug in code the harness never reaches, which is why coverage reports matter as much as crash counts.

For continuous coverage of open-source projects, Google's OSS-Fuzz runs this infrastructure for free and has found tens of thousands of bugs across widely used libraries.

Arslan ud Din Shafiq

Founder and lead editor of LearnCybers. Full-stack engineer with expertise in Linux systems, cybersecurity, cloud infrastructure and web development. Writing about practical technology since 2019.

Related reading

Newsletter

Get smarter about security

Practical guides, tooling notes and the developments actually worth your attention — delivered when there is something worth saying.

No spam. Unsubscribe in one click.