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
- atheris 2.3+, Python 3.8+, Linux or macOS.
- A function that consumes untrusted input — a parser, decoder, or validator.
- The loop model in coverage-guided fuzzing with Atheris.
Solution
A complete target is about fifteen lines. Every one of them matters.
#!/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:
$ 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.
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.
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:
# 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.
$ 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:
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.
Edge cases and failure modes
instrument_all()instruments everything, including dependencies. It finds deeper bugs and slows execution substantially; start withinstrument_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.
RecursionErrorfrom 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.
# 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.
Related
- Coverage-guided fuzzing with Atheris — the search loop and how coverage guides it.
- Running fuzz targets in CI with time budgets — budgets, corpus caching and crash regression tests.
- Advanced property-based testing with Hypothesis — the right tool for validated, structured inputs.
- Debugging async code and event loops — when the crash artefact only reproduces inside a running service.
← Back to Coverage-Guided Fuzzing with Atheris