Atheris
Atheris is a coverage-guided fuzzer for Python, built on LLVM’s libFuzzer engine: it instruments your Python code (adds probes that record which branches ran), calls a function you write, the harness, millions of times with generated bytes, mutates the inputs that reach new branches, and stops at the first uncaught exception [1].
The quality question it answers: does any input make this code fail in a way it should not? Unlike Hypothesis’s strategies, a fuzzer starts from raw bytes, steered by coverage.
Run for this card with Atheris 3.1.0, Python 3.12.16 (linux/amd64 container), Podman 6.1.4, October 2026.
Install and quick start
Atheris 3.1.0 ships wheels only for Linux on x86-64; elsewhere, run it in a container (Podman card). Containerfile.fuzz:
FROM python:3.12-slim
RUN pip install --no-cache-dir atheris==3.1.0
WORKDIR /work
podman build --platform linux/amd64 -t fuzz:0.1 -f Containerfile.fuzz .
# --platform linux/amd64: on an ARM machine (Apple silicon), emulate x86-64
podman pull docker.io/library/python:3.12-slim # ARM only, after the build: restore the native base image (Common mistakes)
A parser for the stay printed on a ticket, src/parking/ticket.py (constructed example):
"""Read a stay such as '08:30-10:05' from a ticket line."""
def parse_stay(text: str) -> int:
"""Minutes between entry and exit; ValueError for a malformed stay."""
entry, leave = text.split("-")
return _minutes(leave) - _minutes(entry)
def _minutes(clock: str) -> int:
hours, mins = clock.split(":")
h, m = int(hours), int(mins)
if not (0 <= h < 24 and 0 <= m < 60):
raise ValueError(f"not a time: {clock!r}")
return 60 * h + m
The harness, fuzz/fuzz_ticket.py, turns the bytes into a string; ValueError, the parser’s documented rejection, counts as correct:
import sys
import atheris
with atheris.instrument_imports():
from parking.fee import fee # the fee function from the pytest card
from parking.ticket import parse_stay
def test_one_input(data: bytes) -> None:
text = atheris.FuzzedDataProvider(data).ConsumeUnicodeNoSurrogates(32)
try:
minutes = parse_stay(text)
except ValueError:
return # rejecting a malformed stay is correct
fee(minutes) # any stay we accept must have a fee
atheris.Setup(sys.argv, test_one_input)
atheris.Fuzz()
mkdir -p fuzz/crashes
podman run --rm --platform linux/amd64 --network none \
-v ./src:/work/src:ro,Z -v ./fuzz:/work/fuzz:Z -e PYTHONPATH=/work/src \
fuzz:0.1 python fuzz/fuzz_ticket.py -max_total_time=120 -artifact_prefix=fuzz/crashes/
# --network none: no network; src read-only. libFuzzer flags take one dash:
# -max_total_time: seconds to fuzz; -artifact_prefix: where to save a crashing input
Reading the output
INFO: A corpus is not provided, starting from an empty corpus
#2 INITED cov: 1 ft: 1 corp: 1/1b exec/s: 0 rss: 76Mb
...
#141274 REDUCE cov: 9 ft: 14 corp: 6/29b lim: 1360 exec/s: 70637 rss: 76Mb L: 9/9 MS: 1 EraseBytes-
=== Uncaught Python exception: ===
ValueError: minutes must be >= 0
Traceback (most recent call last):
File "/work/fuzz/fuzz_ticket.py", line 16, in test_one_input
fee(minutes) # any stay we accept must have a fee
File "/work/src/parking/fee.py", line 7, in fee
raise ValueError("minutes must be >= 0")
...
]0:49-0:9
artifact_prefix='fuzz/crashes/'; Test unit written to fuzz/crashes/crash-4a8bdf2ba573fff8d985c1645db232e65c947905
# #141274: inputs run so far; cov: branches reached in instrumented code; ft: finer
# coverage features; corp: the corpus, the inputs kept, because each reached something new; exec/s:
# inputs per second. NEW: an input reached something new; REDUCE: a shorter input
# reaches the same; MS: the mutations that made it
The saved bytes are ]0:49-0:9; the data provider uses the first byte to choose a decoding, so the parser received 0:49-0:9: entry 0:49, exit 0:09, −40 minutes, which fee rejects. Crashing inputs differ between runs and are often unreadable; print(repr(text)) in the harness shows them on replay. Four runs crashed within seconds, after 10,000 to 460,000 inputs (46,000 to 70,000 per second, emulated).
python fuzz/fuzz_ticket.py fuzz/crashes/crash-4a8b… (same podman run prefix) replays that input: same failure, exit code 77.
Whether an overnight stay (23:00-01:00) is valid is the rule owner’s decision. Here it is rejected, replacing the return line of parse_stay:
minutes = _minutes(leave) - _minutes(entry)
if minutes < 0:
raise ValueError(f"exit before entry: {text!r}")
return minutes
After the fix the replay passed, and a 120-second run ended Done 6039305 runs in 121 second(s), exit code 0.
Common mistakes
pip install atherison a Mac or on ARM. On ARM Linux, pip found only older source releases, which need a Clang with libFuzzer (Apple’s has none). Use an x86-64 container or runner.- Reading a quiet run as “no bugs”. With
assert text.isascii()added afterfee(minutes), two 3-minute runs (15 million inputs, ~42,000 per second) found nothing, yetparse_stay("٠٨:٣٠-١٠:٠٥")returns 95:int()accepts Arabic-Indic digits (also" 8","1_0", which the check cannot catch). That logic is C code, uninstrumented, so no coverage signal led there. State the format in the parser: addimport reand, at the top of_minutes,if not re.fullmatch(r"\d\d:\d\d", clock, re.ASCII): raise ValueError(...)rejected all three. - Building other images as amd64 afterwards.
--platform linux/amd64also repointed the localpython:3.12-slimtag; later builds on an ARM Mac warnedimage platform (linux/amd64) does not match the expected platformand ran emulated, ten times slower.podman pullof the base image restores it. - Catching too much in the harness.
except Exception: returnhides every crash. - Losing the crash. Without
-artifact_prefix, the crash file lands in the discarded directory. Keep it as a test:parse_stay("00:49-00:09")must raiseValueError.
In CI
On GitHub’s Ubuntu runners (x86-64) the wheel installs directly:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install atheris==3.1.0
- run: mkdir -p fuzz/crashes && python fuzz/fuzz_ticket.py -max_total_time=60 -artifact_prefix=fuzz/crashes/
env:
PYTHONPATH: src
- uses: actions/upload-artifact@v7
if: failure()
with:
name: crashes
path: fuzz/crashes/
Atheris exits non-zero on a finding (77 here). Fuzz briefly per push, longer nightly. (Not run on GitHub.)
Go deeper
- Fuzz Testing: generations of fuzzers and what each finds.
- Coverage-Guided Fuzzing: the loop this tool runs, and why it plateaus.
References
- Google,“Atheris: A Coverage-Guided, Native Python Fuzzer.” 2026. [Online]. Available: https://github.com/google/atheris
Disclaimer: AI is used for text summarization, polishing and explaining. Authors have verified all facts and claims. In case of an error, feel free to file an issue.