Hypothesis & Fuzzing

Writing Your First Atheris Fuzz Target

The first fuzz target usually fails in one of two ways: it reports zero coverage growth because instrumentation was not applied, or it reports a thousand crashes because the oracle treats a documented ValueError as a bug. Both are one-line problems, and getting them right the first time is most of the work of adopting fuzzing.

Prerequisites

Solution

A complete target is about fifteen lines. Every one of them matters.

Python
#!/usr/bin/env python3
"""Fuzz the wire-format decoder. Run: python fuzz_decode.py corpus/"""
import sys
import atheris

# Instrumentation must wrap the import: this is what produces coverage feedback.
with atheris.instrument_imports():
    from myapp.wire import decode_frame, FrameError

def TestOneInput(data: bytes) -> None:
    fdp = atheris.FuzzedDataProvider(data)
    version = fdp.ConsumeIntInRange(0, 3)          # a small, meaningful field
    body = fdp.ConsumeBytes(fdp.remaining_bytes())  # the rest is payload
    try:
        decode_frame(version, body)
    except FrameError:
        return                                      # documented rejection: not a bug

if __name__ == "__main__":
    atheris.Setup(sys.argv, TestOneInput)
    atheris.Fuzz()

The except FrameError line is the oracle, stated precisely: malformed input must raise FrameError and nothing else. A TypeError, an IndexError, a RecursionError or a hang is a finding. Broadening that clause to except Exception would make the target incapable of failing.

Run it against a seeded corpus and a time budget:

Bash
$ mkdir -p corpus && cp tests/data/frames/*.bin corpus/
$ python fuzz_decode.py corpus/ -max_total_time=120 -print_final_stats=1
INFO: Running with entropic power schedule (0xFF, 100).
#512    NEW    cov: 188 ft: 291 corp: 12/842b exec/s: 6300
#2048   NEW    cov: 241 ft: 402 corp: 23/1.9Kb exec/s: 6100
==12345== ERROR: libFuzzer: deadly signal
   ... Python traceback ...
artifact_prefix='./'; Test unit written to ./crash-8f14e45fceea167a

The crash artefact is the payoff: a file containing the exact bytes that broke the decoder, replayable forever.

The four parts of a fuzz target A vertical breakdown of a fuzz target: instrumented imports that enable coverage feedback, an input decoder that turns bytes into typed arguments, the call into the code under test, and the oracle that decides which exceptions are findings. The four parts of a fuzz target instrumented import coverage feedback exists Outside the block means no coverage FuzzedDataProvider bytes become typed values Keeps mutations meaningful call the code one input, one call No I/O, no setup, no logging the oracle which exceptions are bugs Narrow, documented exceptions only
Getting the first and last parts right is the difference between a fuzzer that searches and one that spins.

Why this works

Atheris rewrites the bytecode of instrumented modules to record which edges execute, and hands that trace to libFuzzer after every input. libFuzzer keeps any input that reached a previously unseen edge, and mutates the corpus preferentially toward inputs that increase coverage. FuzzedDataProvider matters because mutation happens on the raw buffer: consuming a bounded integer from the front means a single bit flip changes a version field rather than corrupting a length prefix that causes immediate rejection, so more mutations reach interesting code.

FuzzedDataProvider methods worth knowing A table of four FuzzedDataProvider methods - ConsumeIntInRange, ConsumeUnicodeNoSurrogates, ConsumeBytes and ConsumeProbability - describing what each yields and the typical use. FuzzedDataProvider methods worth knowing Criterion Yields Use for ConsumeIntInRange(a, b) a bounded int enums, versions, counts ConsumeUnicodeNoSurrogates(n) valid text names, keys, headers ConsumeBytes(n) a raw slice payload bodies ConsumeProbability() a float in 0..1 branch selection
Consume the structured fields first and the free-form payload last, so mutations to the tail cannot shift the meaning of the head.

Choosing what to fuzz first

Not every function benefits. Three properties make a good first target: it takes untrusted or externally supplied input, it is pure enough to run thousands of times per second, and it has a clearly documented set of exceptions it is allowed to raise.

Parsers, deserializers, decompressors, template renderers, URL and path handlers, and anything reading a binary format score on all three. Business logic operating on already-validated objects scores on none — its inputs are structured, its failure mode is a wrong answer rather than a crash, and property-based testing is the better tool for it.

Where a function performs I/O, fuzz the pure core and leave the shell alone:

Python
# Bad target: opens a file per input, at maybe 40 executions per second.
def TestOneInput(data: bytes) -> None:
    with open("/tmp/f", "wb") as fh:
        fh.write(data)
    load_config_file("/tmp/f")

# Good target: the parsing core, called directly, thousands per second.
def TestOneInput(data: bytes) -> None:
    try:
        parse_config(data.decode("utf-8", errors="replace"))
    except ConfigError:
        return

Refactoring a function into "read bytes" and "parse bytes" purely so it can be fuzzed is usually a design improvement anyway — the same seam makes it testable without temporary files.

Debugging a target that finds nothing

A target that runs for an hour with no crashes is either exercising well-tested code or not exercising it at all, and the statistics distinguish the two in seconds.

Bash
$ python fuzz_decode.py corpus/ -max_total_time=60 -print_final_stats=1
#1048576  pulse  cov: 34 ft: 41 corp: 3/21b exec/s: 17000

Three numbers are wrong here. cov: 34 is far too low for anything but a trivial function — real parsers reach hundreds of edges. corp: 3 means the corpus never grew past its seeds. And exec/s: 17000 is suspiciously high for Python, which usually means the input is being rejected before any real work happens.

The diagnosis follows. Add a print inside the target to confirm the code is reached at all, then check the rejection path:

Python
import sys

def TestOneInput(data: bytes) -> None:
    try:
        decode_frame(data)
    except FrameError as exc:
        # Temporarily: which rejection is swallowing every input?
        print(exc, file=sys.stderr)
        return

Running that for a few seconds usually shows the same message repeatedly — bad magic bytes, length too short, unsupported version — which names the check the fuzzer cannot get past. The fix is a seed input that satisfies it, so mutation starts on the other side of the gate.

Where the gate is a checksum, no amount of mutation will pass it. Either compute the checksum inside the target after mutating the body, or add a build flag that disables verification for fuzzing. Both are standard practice; leaving the checksum in place means fuzzing the checksum function and nothing else.

Reading the libFuzzer status line A table of four libFuzzer status fields - cov, ft, corp and exec/s - explaining what each measures and what an unhealthy value indicates. Reading the libFuzzer status line Criterion Measures Unhealthy when cov edges covered flat, or implausibly low ft coverage features not growing with cov corp corpus size and bytes stuck at the seed count exec/s executions per second very high: early rejection
A high execution rate with flat coverage is the classic signature of inputs being rejected before they reach anything interesting.

Edge cases and failure modes

  • instrument_all() instruments everything, including dependencies. It finds deeper bugs and slows execution substantially; start with instrument_imports() on your own modules.
  • A target that mutates global state accumulates it across inputs. Reset per call, or the crash will not reproduce from the artefact alone.
  • RecursionError from deeply nested input is usually a real finding for a parser, and usually not for a general library; decide deliberately which side of the oracle it falls on.
  • Timeouts need -timeout=N. An input that hangs is a denial-of-service bug, and without the flag the fuzzer waits indefinitely instead of reporting it.
  • Crashes are written to the current directory unless -artifact_prefix= is set, which in CI means they land somewhere that is not collected.
  • Python's own exceptions can mask native ones. When fuzzing a C extension, build it with AddressSanitizer or the crash arrives as an unexplained signal.

Keeping the target maintainable

A fuzz target is code that nobody reads until it fails, so it benefits from the same discipline as a test — and from three conventions specific to fuzzing.

One target per entry point. A target that fuzzes three functions makes attribution ambiguous when it crashes and dilutes the coverage signal. Separate files, separate corpora, separate budgets.

Keep setup out of TestOneInput. Anything constructed per input is paid thousands of times per second. Build clients, load schemas and compile patterns at module scope, where the cost is paid once.

Document the oracle in a comment. The except clause encodes a contract — "malformed input raises FrameError and nothing else" — and that sentence belongs above it in words, because the next person will otherwise widen the clause to silence a crash rather than fixing it.

Python
# Contract: decode_frame must raise FrameError for any malformed input.
# Any other exception, or a hang, is a defect. Do NOT widen this clause.
try:
    decode_frame(version, body)
except FrameError:
    return

Finally, run the targets in the normal test suite as a smoke check: calling TestOneInput(b"") and TestOneInput(b"\x00" * 64) from an ordinary pytest case proves the target still imports and runs after a refactor. A fuzz target that silently stopped working is indistinguishable from one that is finding nothing.

Frequently Asked Questions

Why does my fuzzer report no coverage growth? Because the code under test was imported outside atheris.instrument_imports(). Only modules imported inside that context manager, or wrapped with instrument_func, report edges to libFuzzer.

Should TestOneInput catch exceptions? Only the ones that are part of the documented contract, such as ValueError for malformed input. Catching Exception broadly hides every bug the fuzzer would otherwise find.

How do I turn raw bytes into typed arguments? Use atheris.FuzzedDataProvider, which consumes the buffer into ints, strings and byte slices. It keeps mutations meaningful instead of being spent on structure the parser rejects immediately. How do I fuzz a function that takes several arguments? Decode them all from one buffer with FuzzedDataProvider, in a fixed order. The order matters: consume the small structured fields first and the free-form payload last, so a mutation near the start changes a field rather than shifting the meaning of everything after it.

Can a fuzz target use pytest fixtures? Not directly — the target runs under libFuzzer, not pytest. Build whatever the target needs at module scope, and if the setup is shared with the test suite, factor it into a plain function that both call. That factoring is usually an improvement to the tests as well.

← Back to Coverage-Guided Fuzzing with Atheris