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 atheris on 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 after fee(minutes), two 3-minute runs (15 million inputs, ~42,000 per second) found nothing, yet parse_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: add import re and, 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/amd64 also repointed the local python:3.12-slim tag; later builds on an ARM Mac warned image platform (linux/amd64) does not match the expected platform and ran emulated, ten times slower. podman pull of the base image restores it.
  • Catching too much in the harness. except Exception: return hides 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 raise ValueError.

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


References

  1. 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.


This site uses Just the Docs, a documentation theme for Jekyll.