Testland
Browse all skills & agents

coverage-guided-fuzzing

Coverage-guided fuzzing across every mainstream engine - libFuzzer (C/C++ in-process), AFL++ (out-of-process, QEMU mode for closed-source binaries), cargo-fuzz (Rust), Go native fuzzing (go test -fuzz), Atheris (Python), and Jazzer (JVM, @FuzzTest). Body covers choosing the right fuzzer for the language and build type (the routing tree) plus the engine-generic workflow: writing a small deterministic fuzz target, seed-corpus + dictionary construction, sanitizer selection (ASan + UBSan default, compatibility matrix), corpus minimisation, crash-artifact handling, and CI smoke-fuzz wiring with a cached corpus. Per-engine depth (flags, harness syntax, CI jobs) lives in references, as do the corpus-management and sanitizer-integration catalogs. Use when a project needs fuzz coverage and no fuzzer is chosen yet, or when authoring / running / maintaining a fuzz campaign with any of these engines. For triaging the resulting crashes see crash-triage-reference.

Install with skills.sh (any agent)

npx skills add testland/qa --skill coverage-guided-fuzzing
View source

coverage-guided-fuzzing

Overview

Coverage-guided fuzzers mutate inputs, watch which code paths each input reaches, and keep mutating the inputs that find new coverage. Every mainstream engine implements the same loop; they differ in language, process model, and toolchain integration. This umbrella covers choosing the engine, the engine-generic workflow (target → corpus → sanitizers → CI), and links the per-engine references that carry exact flags and harness syntax.

EngineLanguage / nicheReference
libFuzzerC/C++ callable APIs, in-process (also Swift)references/libfuzzer.md
AFL++File/stdin-driven binaries, closed-source via QEMUreferences/afl-plus-plus.md
cargo-fuzzRust crates (libFuzzer + cargo, nightly)references/cargo-fuzz.md
Go nativeGo packages (go test -fuzz, Go 1.18+)references/go-native-fuzzing.md
AtherisPython libraries + CPython extensionsreferences/atheris.md
JazzerJava / Kotlin / Scala / Groovy (@FuzzTest)references/jazzer.md

Shared catalogs: references/corpus-management.md (seed / evolved corpus, dictionaries, minimisation) and references/sanitizer-integration.md (ASan / UBSan / MSan / TSan / LSan flags + compatibility).

When to use

  • A project needs coverage-guided fuzzing and no fuzzer has been chosen for its language or toolchain yet (routing below).
  • Authoring a fuzz target, bootstrapping its corpus, or picking sanitizers for it.
  • Running or maintaining a fuzz campaign - local, CI smoke, or long-running.
  • Reviewing an existing fuzz target - verify the right engine was selected.

Choosing your fuzzer

Step 1: Identify target language(s).

+-------+----------+--------+--------+-------+--------+--------+
| C/C++ |   Rust   |   Go   | Python |  JVM  | Other  | Binary |
|       |          |        |        |       |        | (no    |
|       |          |        |        |       |        | source)|
+-------+----------+--------+--------+-------+--------+--------+
    ↓        ↓         ↓        ↓        ↓        ↓        ↓
  Step 2:                                            AFL++
  Library    cargo-    go test  Atheris  Jazzer    Choose    -Q mode
  function?   fuzz     -fuzz                       per LLVM
   YES                                              -fsanitize
    ↓                                                support
  libFuzzer
  (in-process)
   OR
  AFL++ (file-driven)
Target characteristicRoute to
C / C++ library with callable function APIlibFuzzer
C / C++ binary processing filesAFL++
C / C++ source unavailableAFL++ (-Q QEMU)
Rust cratecargo-fuzz
Rust binary processing filesAFL++
Go packageGo native fuzzing
Pure Python or CPython native extensionAtheris
Java / Kotlin / Scala / Groovy libraryJazzer
Swift librarylibFuzzer (Swift wraps libFuzzer natively)

Routing rationale:

  • C/C++: prefer libFuzzer for callable APIs (parsers, validators, decoders) - in-process iteration is 10-100x faster. Switch to AFL++ when the target reads stdin/file input, when you need QEMU mode for closed-source binaries, or as a second mutation engine (AFL++ and libFuzzer find different bugs); mature projects run both.
  • Rust: cargo-fuzz integrates with cargo and supports Arbitrary for structured input; requires nightly. Rust binaries (not callable APIs) → AFL++.
  • Go: native fuzzing is built into testing (Go 1.18+); failing inputs auto-save as regression fixtures. CGo dependencies → AFL++ -Q.
  • Python: Atheris (Google's libFuzzer-backed fuzzer) covers pure Python and CPython extensions. For property-based testing without coverage guidance, Hypothesis (hypothesis-testing, qa-property-based plugin) is complementary, not competing.
  • JVM: Jazzer integrates with JUnit 5 via @FuzzTest and ships JVM-level sanitizers (deserialization, SSRF, ReDoS, command injection).
  • Other languages: with libFuzzer-compatible sanitizer-coverage support (Swift, Objective-C) use libFuzzer via FFI; without it (Erlang, OCaml), fuzz the compiled binary with AFL++.
  • OSS overlay: mature, security-relevant open-source projects can additionally onboard to OSS-Fuzz (google.github.io/oss-fuzz (opens in new window)) - it runs existing libFuzzer / AFL++ / Jazzer harnesses 24x7.

The engine-generic workflow

Step 1 - Write a small, deterministic fuzz target

Every engine calls your target repeatedly with mutated bytes (or typed values). The rules are engine-independent:

  • Keep the target small - one function / one format per target; faster iteration, clearer coverage attribution.
  • No global state between runs - cross-input contamination defeats coverage guidance.
  • Use the full input; don't gate on arbitrary size checks.
  • No I/O or network in the hot path.
  • For multi-parameter targets use the engine's structured-input helper: FuzzedDataProvider (libFuzzer / Atheris / Jazzer), Arbitrary (cargo-fuzz), typed f.Fuzz parameters (Go).

Exact harness syntax per engine is in the reference files.

Step 2 - Seed corpus + dictionary

Bootstrap with 5-50 hand-curated diverse inputs (from spec keywords, test fixtures, or PII-scrubbed production samples) and, for structured formats (JSON / XML / SQL / protobuf), a dictionary of grammar tokens - without one the fuzzer slowly rediscovers the grammar. Keep seeds versioned and read-only; let the evolved corpus live in the output directory (CI cache, not the repo). Construction strategies, per-engine directory layouts, and minimisation cadence: references/corpus-management.md.

Step 3 - Sanitizers

A fuzzer without sanitizers catches only hard crashes - 80%+ of memory bugs are silent without them. Defaults per language:

  • C/C++ / Rust: ASan + UBSan in one binary (-fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all); MSan needs a separate whole-program-instrumented binary.
  • Go: the race detector (go test -race) is the TSan-equivalent.
  • JVM: Jazzer's JVM-level sanitizers are on by default.
  • Python: attach ASan to the interpreter only when fuzzing native extensions.

Compatibility matrix, build flags, ASAN_OPTIONS / UBSAN_OPTIONS, and report anatomy: references/sanitizer-integration.md.

Step 4 - Run, minimise, repeat

Run locally until coverage plateaus; minimise the corpus periodically (-merge=1 / afl-cmin) so cycle time stays flat; minimise every crash input before filing it. Crash artifacts (crash-<sha1> etc.) and their handling are cataloged in references/corpus-management.md; classification and exploitability rules live in crash-triage-reference.

Step 5 - CI wiring

CI runs a short smoke fuzz (3-5 min per target) on every PR; long campaigns run outside CI. The engine-generic shape:

      - uses: actions/cache@v4          # evolved corpus accumulates across runs
        with:
          path: fuzz/corpus
          key: fuzz-corpus-${{ github.sha }}
          restore-keys: fuzz-corpus-
      - name: Smoke fuzz (5 min)
        run: ./fuzz_target -max_total_time=300 fuzz/corpus fuzz/seeds
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: fuzz-crashes, path: "crash-* leak-* timeout-* oom-*" }

Complete per-engine CI jobs (AFL++ Docker, cargo-fuzz nightly matrix, Go target loop, Jazzer JAZZER_FUZZ, Atheris) are in each engine's reference file.

Anti-patterns

Anti-patternWhy it failsFix
Picking AFL++ for a callable C/C++ library APIOut-of-process overhead 10-100xlibFuzzer for in-process
Picking libFuzzer for a file-processing binaryAdapter glue is complex; AFL++ handles @@ cleanlyAFL++
Picking cargo-fuzz on stable RustWon't compileNightly toolchain
Fuzzing without sanitizersCatches only crashes; most bugs silentStep 3 defaults
No seed corpus / no dictionary for structured formatsFuzzer wanders; slow path discoveryStep 2
Never minimising the corpusCycle time degrades; coverage redundantWeekly -merge=1 / afl-cmin
Mixing fuzzer corpora without conversionlibFuzzer / AFL++ formats aren't compatibleOne fuzzer's corpus; convert if needed
Routing on language alone, ignoring source availabilityClosed-source needs QEMU regardlessFactor in availability

Limitations

  • Per-engine depth lives in the references - flags, harness syntax, and CI jobs differ per engine.
  • Cross-language targets (e.g. JNI) need two campaigns: Jazzer for the Java side, libFuzzer for the C side.
  • Crash triage (classification, dedup, exploitability, verdicts) is the crash-triage-reference sibling's scope, not this skill's.

References

AFL++ (out-of-process, multi-language)

View source (opens in new window)

AFL++ (out-of-process, multi-language)

Per-engine reference for coverage-guided-fuzzing; the shared workflow and fuzzer-choice routing live in ../SKILL.md (opens in new window).

Overview

Distinct from libFuzzer (in-process) - AFL++ (per github.com/AFLplusplus/AFLplusplus (opens in new window)) fits binaries you can't compile-in (closed-source, multi-language) and tools that process inputs end-to-end (parsers reading files, decoders reading stdin).

For sanitiser pairing see sanitizer-integration.md (opens in new window); for corpus discipline see corpus-management.md (opens in new window).

When to use

  • Fuzzing a file-format parser whose API isn't directly callable.
  • Targeting a binary you don't have source for (QEMU mode).
  • Parallel campaigns across many cores via master/slave.
  • Comparison fuzzing - different mutation strategies catch different bugs than libFuzzer.

Authoring

Install

Per AFL++ README:

# Docker
docker pull aflplusplus/aflplusplus
docker run -ti -v /location/of/your/target:/src aflplusplus/aflplusplus

# Or apt (Debian/Ubuntu)
apt-get install -y afl++

For local build see docs/INSTALL.md in the AFL++ repo.

Instrument the target

CC=afl-cc CXX=afl-c++ ./configure --disable-shared
make clean all

Or LLVM-based (recommended):

CC=afl-clang-fast CXX=afl-clang-fast++ make

afl-clang-fast produces ~3x faster instrumented binaries than the legacy afl-cc.

Add sanitisers in the standard way:

AFL_USE_ASAN=1 AFL_USE_UBSAN=1 CC=afl-clang-fast \
  CFLAGS="-fno-sanitize-recover=all -fno-omit-frame-pointer -g" \
  make

AFL_USE_ASAN + AFL_USE_UBSAN env vars pass the sanitiser flags through AFL's compiler driver.

Running

Basic invocation

./afl-fuzz -i seeds/ -o output/ -- ./target @@

Per AFL++ README:

  • -i seeds/ - input seed corpus
  • -o output/ - output directory (created if absent)
  • -- ./target @@ - target binary; @@ is replaced by AFL with the input filename
  • For stdin-driven targets, omit @@: -- ./target (AFL pipes the input)

Common flags

FlagEffect
-M nameMaster process in parallel campaign
-S nameSecondary process (slave)
-x dict.txtUse dictionary
-t NPer-input timeout in ms (default 1000)
-m NMemory limit per child (MB)
-QQEMU mode for non-instrumented binaries
-c pathCMPLog binary (companion mode for collisions)
-d"Quick" mode - deterministic mutations skipped

Parallel fuzzing

# Terminal 1 - master
afl-fuzz -i seeds/ -o output/ -M main -- ./target @@

# Terminals 2..N - slaves
afl-fuzz -i seeds/ -o output/ -S slave1 -- ./target @@
afl-fuzz -i seeds/ -o output/ -S slave2 -- ./target @@

The master runs deterministic mutations; slaves run random mutations. Output corpus is shared via the output/ directory.

QEMU mode

For closed-source binaries:

afl-fuzz -Q -i seeds/ -o output/ -- ./closed_source_target @@

QEMU instruments via dynamic binary translation. ~5x slower than native instrumentation but works on any binary.

Parsing results

Output directory structure:

output/
  fuzzer_stats           # current run metrics
  fuzzer_setup           # configuration
  plot_data              # gnuplot-friendly time series
  queue/
    id:000000,orig:seed1.bin
    id:000001,src:000000,op:...     # mutated from id 0 with op
    ...
  crashes/
    id:000000,sig:11,src:000123,op:havoc,rep:8
    README.txt
  hangs/
    id:000000,...

Crash filename encoding (per AFL++ docs):

  • id:N - sequence number
  • sig:S - signal (11=SEGV, 6=SIGABRT, 9=SIGKILL, etc.)
  • src:M - derived from queue entry M
  • op:X - mutation operator that produced it
  • rep:R - repetition count

Reproduce a crash:

./target output/default/crashes/id:000000,sig:11,...
# Or for stdin-driven:
./target < output/default/crashes/id:000000,sig:11,...

For automated bug filing, parse the sanitiser output (if compiled with AFL_USE_ASAN=1) via the from-CI-failure workflow in bug-report-template (qa-bug-repro plugin).

Triaging crashes

afl-tmin minimises a single crash input:

afl-tmin -i output/default/crashes/id:000000,sig:11,... \
         -o minimised.bin \
         -- ./target @@

afl-cmin minimises the entire queue (corpus minimisation):

afl-cmin -i output/default/queue/ -o minimised_queue/ -- ./target @@

CI integration

- name: Install AFL++
  run: docker pull aflplusplus/aflplusplus
- name: Build instrumented target
  run: |
    docker run -v $PWD:/src aflplusplus/aflplusplus bash -c \
      "cd /src && CC=afl-clang-fast AFL_USE_ASAN=1 make"
- name: Smoke fuzz (5 min)
  run: |
    timeout 300 docker run -v $PWD:/src aflplusplus/aflplusplus \
      afl-fuzz -i /src/seeds -o /src/output -- /src/target @@ || true
- name: Upload crashes
  uses: actions/upload-artifact@v4
  with:
    name: afl-crashes
    path: output/default/crashes/

Anti-patterns

Anti-patternWhy it failsFix
Target compiled without AFL instrumentationNo coverage signal; fuzzer is blindUse afl-clang-fast or -Q (QEMU)
-i - (continuation) without checking output stateHard to resume; lost queue progressUse canonical -i seeds/ on fresh runs
Single AFL slave on a multi-core machine80% of cores idleRun N-1 slaves in parallel
No AFL_USE_ASAN=1Only catches crashes, not memory bugsAlways set sanitiser env vars
Treating hangs/ as bugsOften false positives (slow targets, infinite loops in test data)Investigate; raise -t for slow targets
Long-running campaign without afl-cmin cycleQueue bloats; cycle time degradesRun afl-cmin weekly
Mixing AFL++ + libFuzzer corporaFormat incompatibilityConvert via afl-fuzz -i corpus -E ... round-trip

Limitations

  • Out-of-process overhead. Fork-per-input is slower than libFuzzer's in-process iteration (~100-1000 execs/sec vs 10,000+).
  • Persistent mode partially mitigates. LLVMFuzzerTestOneInput-like persistent harnesses approach libFuzzer speed but require source instrumentation.
  • Queue format isn't libFuzzer-compatible. Cross-fuzzer corpus sharing requires conversion.
  • hangs/ reports many false positives. Slow but valid inputs sit there until manually triaged.
  • QEMU mode is slow (~5x). Use only when source isn't available.
  • Linux-first. macOS / Windows support exists but less mature.

References

Atheris (Python)

Per-engine reference for coverage-guided-fuzzing; the shared workflow and fuzzer-choice routing live in ../SKILL.md (opens in new window).

Overview

Atheris (per github.com/google/atheris (opens in new window)) supports both pure-Python and native-extension targets (CPython C extensions).

For sanitiser pairing on native extensions, see sanitizer-integration.md (opens in new window); for corpus discipline see corpus-management.md (opens in new window).

When to use

  • Fuzz testing a Python library (parser, serialiser, validator).
  • Native CPython extensions where the C/C++ code is reachable from Python.
  • Quick fuzz pass during development on Python projects already using pytest.

Authoring

Install

pip install atheris

Per the Atheris README, prebuilt wheels include libFuzzer for pure-Python fuzzing. Native-extension fuzzing may require building from source so the Clang and libFuzzer versions match.

Basic fuzz target

# fuzz_parser.py
import sys
import atheris

with atheris.instrument_imports():
    from my_library import parser

def TestOneInput(data):
    parser.parse(data)

atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()

Per Atheris README:

  • TestOneInput(data: bytes) is the fuzz callback - invoked with mutated input bytes each iteration.
  • atheris.Setup(sys.argv, TestOneInput) initialises the fuzzer with libFuzzer flags from sys.argv.
  • atheris.Fuzz() starts the fuzz loop (doesn't return until the campaign ends).

Coverage instrumentation

Atheris needs to instrument the modules under test:

with atheris.instrument_imports():
    from my_library import parser, decoder

The instrument_imports() context manager monkey-patches the import system so subsequent imports are coverage-instrumented. Module-level imports above this context manager are NOT instrumented - the fuzzer is blind to their code.

Alternative: per-function instrumentation:

import my_library
my_library.parser.parse = atheris.instrument_func(my_library.parser.parse)

Or instrument-all (heavyweight):

atheris.instrument_all()

FuzzedDataProvider

For structured input, use the Python equivalent of libFuzzer's helper:

def TestOneInput(data):
    fdp = atheris.FuzzedDataProvider(data)
    port = fdp.ConsumeInt(4)              # signed 4-byte int
    is_https = fdp.ConsumeBool()
    host = fdp.ConsumeUnicode(64)         # up to 64 chars
    body_size = fdp.ConsumeIntInRange(0, 1024)
    body = fdp.ConsumeBytes(body_size)
    parser.parse_request(host, port, is_https, body)

Per Atheris README, the provider exposes ConsumeInt, ConsumeUnicode, ConsumeFloat, ConsumeBool, PickValueInList, and related methods.

Running

Basic run

python fuzz_parser.py

Atheris by default runs indefinitely. Pass libFuzzer-style flags:

python fuzz_parser.py -max_total_time=300 corpus/

The trailing directory is the corpus (read + write). Subsequent directories are read-only seeds.

Common flags

Per Atheris README, all libFuzzer flags pass through:

FlagEffect
-max_total_time=NStop after N seconds
-atheris_runs=NRun N iterations then stop (also enables coverage report)
-dict=pathUse dictionary file
-seed=NRandom seed
-runs=NlibFuzzer runs (use -atheris_runs for Atheris-specific)

Coverage report

python fuzz_parser.py -atheris_runs=100000 corpus/
# At end: prints coverage statistics

Reproducing a crash

python fuzz_parser.py crash-<sha1>
# Same crash with full traceback

Parsing results

Python tracebacks instead of sanitiser reports (unless instrumenting a CPython extension built with ASan):

[+] Loading binary contents from crash-abc123
=== Uncaught Python exception: ===
ValueError: invalid syntax
Traceback (most recent call last):
  File "fuzz_parser.py", line 12, in TestOneInput
    parser.parse(data)
  File "/path/my_library/parser.py", line 47, in parse
    return json.loads(text)
  ...

Map the traceback to a bug spec via the from-CI-failure workflow in bug-report-template (qa-bug-repro plugin).

CI integration

- uses: actions/setup-python@v6
  with: { python-version: '3.12' }
- run: pip install atheris
- name: Smoke fuzz (3 min)
  run: timeout 180 python fuzz_parser.py -max_total_time=180 corpus/ || true
- uses: actions/upload-artifact@v4
  with:
    name: atheris-crashes
    path: crash-*

Anti-patterns

Anti-patternWhy it failsFix
Module imports above instrument_imports()Coverage signal absent for those modulesAlways import via with atheris.instrument_imports(): ...
No exception handling in TestOneInputExpected exceptions (ValueError on bad input) count as crashesCatch expected exceptions; only let unexpected ones propagate
Pure-Python target without instrumentationCoverage is blind; fuzzer flailingAlways instrument
Missing atheris.Fuzz() callFuzz loop never startsAlways end with atheris.Fuzz()
Treating every traceback as a bugMany tracebacks are spec-compliant (raising ValueError on invalid input is correct)Use assert for invariants; let spec-defined exceptions through
Native extension without ASanC bugs silent (segfault crashes Python interpreter)Build CPython + extension with ASan for native fuzzing

Limitations

  • GIL bottleneck. Python single-thread iteration; no multi-process fuzzing without -jobs (and libFuzzer's job flag works imperfectly with Python).
  • Slower than libFuzzer / cargo-fuzz. Pure-Python iteration is ~1000-10000 execs/sec - orders of magnitude slower than C.
  • Coverage instrumentation overhead. ~5x slowdown for instrumented modules.
  • Native-extension fuzzing requires C/C++ toolchain. Build CPython + the extension with matching ASan / libFuzzer versions.
  • No structured-input mutation beyond FuzzedDataProvider. For structured-aware mutation (typed records, custom grammars), pair with Hypothesis (the hypothesis-testing skill in the qa-property-based plugin) for property-based-style structured input.

References

cargo-fuzz (Rust)

Per-engine reference for coverage-guided-fuzzing; the shared workflow and fuzzer-choice routing live in ../SKILL.md (opens in new window).

Overview

cargo-fuzz (per github.com/rust-fuzz/cargo-fuzz (opens in new window)) requires Rust nightly because libFuzzer integration depends on unstable compiler features.

For sanitiser pairing: cargo-fuzz auto-enables ASan by default (per the cargo-fuzz README). See sanitizer-integration.md (opens in new window) for ASan + UBSan composition. For corpus discipline see corpus-management.md (opens in new window).

When to use

  • Fuzz testing a Rust crate (parser, decoder, format converter).
  • Targets that benefit from structured input mutation via the Arbitrary trait.
  • CI smoke fuzz alongside cargo test.

For raw libFuzzer in C/C++ with Rust FFI see libfuzzer.md (opens in new window).

Authoring

Install

Per the cargo-fuzz README:

# Rust nightly is required
rustup install nightly

# Install cargo-fuzz
cargo install cargo-fuzz

Initialise

In your crate root:

cargo fuzz init

This creates a fuzz/ subdirectory:

fuzz/
  Cargo.toml
  fuzz_targets/
    fuzz_target_1.rs       # generated default target

Add a fuzz target

cargo fuzz add parse_query

Creates fuzz/fuzz_targets/parse_query.rs:

#![no_main]
use libfuzzer_sys::fuzz_target;
use my_crate::parser;

fuzz_target!(|data: &[u8]| {
    let _ = parser::parse_query(data);
});

Per the cargo-fuzz docs, fuzz_target! is the macro that wires up the libFuzzer entry point (LLVMFuzzerTestOneInput under the hood). The closure body is what runs per input.

Structured input via Arbitrary

Raw byte slices work for binary formats; for structured inputs use the arbitrary crate:

#![no_main]
use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;

#[derive(Debug, Arbitrary)]
struct Request {
    host: String,
    port: u16,
    body: Vec<u8>,
}

fuzz_target!(|req: Request| {
    let _ = handle_request(&req.host, req.port, &req.body);
});

Add arbitrary = { version = "1", features = ["derive"] } to fuzz/Cargo.toml.

Running

Basic run

# Nightly toolchain required
cargo +nightly fuzz run parse_query

This builds the target with libFuzzer instrumentation + ASan and runs indefinitely.

Common options

OptionEffect
--releaseRelease-mode build (faster, less debug info)
--debug-assertionsKeep debug assertions in release mode
--sanitizer=<name>address (default), leak, memory, thread, none
--jobs=NParallel workers
--no-default-featuresDisable default cargo-fuzz features
-- <libFuzzer-flag>Pass through to libFuzzer (e.g., -max_total_time=300)
cargo +nightly fuzz run parse_query -- -max_total_time=300

Sanitiser variants

UBSan (via --sanitizer=none + custom RUSTFLAGS) and MSan variants: see "Sanitiser variants" below.

Reproducing a crash

cargo +nightly fuzz run parse_query \
  fuzz/artifacts/parse_query/crash-<sha1>

Or:

cargo +nightly fuzz fmt parse_query \
  fuzz/artifacts/parse_query/crash-<sha1>
# Prints the crash input in a Rust-readable format

Crash artefacts location

Per cargo-fuzz convention:

fuzz/
  corpus/
    parse_query/            # evolved corpus
  artifacts/
    parse_query/
      crash-<sha1>          # crash artefacts
      leak-<sha1>
      timeout-<sha1>

Parsing results

Sanitiser report format is identical to libFuzzer / ASan - see sanitizer-integration.md (opens in new window) "Reading a sanitiser report", and the "Reading a sanitiser report" section below for the ASan report anatomy (bug class, access, stack, allocation site).

cargo fuzz fmt decodes binary inputs into a Rust-readable form (useful when using Arbitrary - recovers the struct).

CI integration

Smoke-fuzz every target for 5 min on each PR (nightly toolchain, cached corpus, uploaded artifacts): see "Full CI job" below.

Anti-patterns

Anti-patternWhy it failsFix
Using stable toolchaincargo-fuzz needs nightlyrustup install nightly; use cargo +nightly fuzz
Raw &[u8] for structured inputMutation hits format errors more than logicUse Arbitrary + a custom struct
Empty seed corpusFuzzer wanders; slow path discoveryDrop a few representative inputs in fuzz/corpus/<target>/
Ignoring --releaseDebug builds slow iterationUse --release for long campaigns
No cargo fuzz fmt on crashHard-to-read crash inputsAlways cargo fuzz fmt before filing a bug
Committing fuzz/artifacts/ to repoRepo bloat.gitignore artifacts; persist via CI cache
Mixing fuzz targets in one fileCargo treats each fuzz_targets/*.rs as one binaryOne file per target

Limitations

  • Nightly-only. Stable Rust doesn't support the required unstable features. Pin a known-good nightly date to avoid drift.
  • Build time. First build is slow (rebuilds dependencies with sanitiser instrumentation).
  • Arbitrary derive is shallow. Custom types need manual impl Arbitrary for non-trivial mutation strategies.
  • Cross-target corpus is per-target. fuzz/corpus/<target>/ - no sharing across targets.
  • macOS / Windows partial support. Linux is the primary platform; some features (MSan) Linux-only.

References

Sanitiser variants

# UBSan via none sanitiser + custom RUSTFLAGS
RUSTFLAGS="-Cpasses=sancov-module -Cllvm-args=-sanitizer-coverage-level=4 -Zsanitizer=undefined" \
  cargo +nightly fuzz run --sanitizer=none parse_query

# MSan
cargo +nightly fuzz run --sanitizer=memory parse_query

Reading a sanitiser report

Report format is identical to libFuzzer / ASan (per clang.llvm.org/docs/AddressSanitizer.html (opens in new window)):

==1234==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7f...
READ of size 4 at 0x7f... thread T0
    #0 0x4015a3 in process_input src/parser.rs:42:5
    #1 0x4012f0 in rust_fuzzer_test_input parse_query.rs:8:5
0x7f... is located 0 bytes to the right of 16-byte region [0x7f..., 0x7f...)
allocated by thread T0 here:
    #0 0x40e7c0 in __interceptor_malloc
  • Bug class: heap-buffer-overflow, stack-use-after-return, use-after-free, double-free, memory-leak.
  • Access: READ or WRITE, plus size.
  • Stack: top frame is the crash site; frames below are the call chain down to the fuzz target entry point.
  • Allocation site: where the corrupted memory was allocated.
  • Freed site: on use-after-free, where it was freed.

Full CI job

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@nightly
      - run: cargo install cargo-fuzz
      - uses: actions/cache@v4
        with:
          path: |
            fuzz/corpus
            ~/.cargo/registry
            target
          key: fuzz-${{ github.sha }}
          restore-keys: fuzz-
      - name: Smoke fuzz (5 min per target)
        run: |
          for target in $(cargo fuzz list); do
            timeout 300 cargo +nightly fuzz run $target -- -max_total_time=300 || true
          done
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: fuzz-artifacts
          path: fuzz/artifacts/

Corpus management

Shared reference for coverage-guided-fuzzing; the per-engine references and the umbrella workflow live alongside this file.

Overview

Pure-reference catalog of corpus-management practices across libFuzzer, AFL++, native Go fuzz, cargo-fuzz, Atheris, Jazzer, and OSS-Fuzz. Consumed by the per-engine references and the fuzz-target-author agent.

When to use

  • Bootstrapping a new fuzz target - what should the seed corpus contain?
  • Running a long-running fuzz campaign - when to minimise, where to back up, how to share across CI runs.
  • Debugging a crash - locating the crash artefact, reproducing it.
  • Migrating between fuzzers (libFuzzer ↔ AFL++) - corpus format compatibility.

Corpus components

A fuzzing corpus has three roles:

RoleWhat it isWhere it lives
Seed corpusHand-curated initial inputs covering known interesting pathsVersioned in repo (fuzz/seeds/)
Evolved corpusInputs the fuzzer added because they hit new coverageOutput directory (fuzz/corpus/) - typically not committed
Crash artefactsInputs that triggered a sanitiser / crash / timeoutOutput directory + bug-report attachments

Directory layout per fuzzer

libFuzzer

Per llvm.org/docs/LibFuzzer.html (opens in new window):

fuzz/
  fuzz_target.cc       # the harness
  seeds/               # initial inputs (read on startup)
  corpus/              # evolved corpus (read + written)
  fuzz_target          # compiled binary

Invocation:

./fuzz_target -max_total_time=3600 corpus/ seeds/

The first directory listed is the output corpus (writable); subsequent directories are read-only seeds.

Crash artefacts saved to current directory as crash-<sha1>, leak-<sha1>, timeout-<sha1> per LLVM docs.

AFL++

fuzz/
  inputs/              # seed corpus
  output/              # AFL++ output (queue/, crashes/, hangs/)
  fuzz_target          # AFL-instrumented binary (afl-clang-fast)

Invocation:

afl-fuzz -i inputs/ -o output/ -- ./fuzz_target @@

Crash format: output/default/crashes/id:<num>,sig:<signal>,src:<id>,op:<mutator>,....

AFL++ corpora are not directly compatible with libFuzzer - the queue files have a different on-disk format. Convert via afl-fuzz -i + afl-cmin round-trip.

Go native (go test -fuzz)

Per go.dev/doc/security/fuzz (opens in new window):

package/
  fuzz_test.go         # contains FuzzXxx functions
  testdata/
    fuzz/
      FuzzXxx/
        seedfile1      # seed inputs (hand-curated)
        seedfile2

Failures auto-write to testdata/fuzz/FuzzXxx/ with a generated filename - they're meant to be committed as regression cases.

cargo-fuzz

fuzz/
  Cargo.toml
  fuzz_targets/
    fuzz_target_1.rs   # the harness (one per fuzz target)
  corpus/
    fuzz_target_1/     # per-target corpus
  artifacts/
    fuzz_target_1/
      crash-<sha1>

Invocation:

cargo fuzz run fuzz_target_1

Atheris (Python)

fuzz/
  fuzz_target.py       # uses atheris.Setup + atheris.Fuzz
  corpus/

Invocation (Atheris uses libFuzzer's CLI under the hood):

python fuzz_target.py corpus/

Jazzer (JVM)

fuzz/
  FuzzTarget.java      # with @FuzzTest annotation
  corpus/

Invocation:

jazzer --cp=target/test-classes \
       --target_class=com.example.FuzzTarget \
       corpus/

OSS-Fuzz

OSS-Fuzz aggregates corpora across Google's infrastructure:

oss-fuzz/
  projects/<project>/
    Dockerfile         # build the fuzzer
    build.sh           # produces $OUT/fuzz_target_1, $OUT/fuzz_target_1_seed_corpus.zip

Per google.github.io/oss-fuzz (opens in new window).

The corpus syncs to gs://<project>-corpus.clusterfuzz-external.appspot.com/.

Seed corpus construction strategies

StrategyWhenExample
From spec keywordsNew target, no inputs existExtract JSON keywords from a JSON parser spec, write each as a tiny file
From test fixturesExisting unit-test inputs cover pathsCopy fixtures from tests/fixtures/*.json to seeds/
From production dataMature target, prod logs availableSample 1000 prod requests, strip PII per qa-test-data-privacy's pii-categories catalog (pii-masking-pipeline-builder references/), seed
From corpus minimisationReduce a large corpus to its coverage-equivalent coreRun afl-cmin or libFuzzer -merge=1
From OSS-Fuzz cousinSame format, different targetReuse <format>_seed_corpus.zip from a related OSS-Fuzz project

A seed corpus of 5-50 hand-curated diverse inputs is typically enough to bootstrap. Bigger isn't always better - the fuzzer finds new paths via mutation.

Dictionary files

A dictionary lists tokens the fuzzer prefers when mutating. For a JSON parser:

# fuzz.dict
"{"
"}"
"["
"]"
"true"
"false"
"null"
"\":\""

Invocation: libFuzzer -dict=fuzz.dict or afl-fuzz -x fuzz.dict.

Dictionaries dramatically improve fuzzer effectiveness on structured formats (JSON, XML, protobuf, SQL).

Corpus minimisation

A corpus that grows over time becomes redundant - many inputs hit the same coverage. Minimise periodically:

# libFuzzer merge mode
mkdir minimised/
./fuzz_target -merge=1 minimised/ corpus/

# AFL++ minimisation
afl-cmin -i corpus/ -o minimised/ -- ./fuzz_target @@

# Per-input minimisation (find smallest input triggering same coverage)
afl-tmin -i crash-input -o min-crash-input -- ./fuzz_target @@

Minimisation reduces fuzz cycle time + reproducibility surface.

Crash artefact handling

When the fuzzer finds a crash:

  1. Reproduce locally:
    ./fuzz_target crash-<sha1>
    # Triggers the same sanitiser report
    
  2. Minimise the crash input (see above) so the bug report is small.
  3. File the bug via the from-CI-failure workflow in bug-report-template (qa-bug-repro plugin) with the minimised crash as an attachment.
  4. Add the original (non-minimised) crash to the seed corpus as a regression test - re-runs will catch reintroduction.

CI integration

Long-running fuzz campaigns run continuously; CI runs short "smoke fuzz" campaigns (~5 min):

- name: Smoke fuzz
  run: ./fuzz_target -max_total_time=300 corpus/ seeds/

Persist evolved corpus to a CI cache so coverage accumulates across runs:

- uses: actions/cache@v4
  with:
    path: corpus/
    key: fuzz-corpus-${{ github.sha }}
    restore-keys: fuzz-corpus-

Anti-patterns

Anti-patternWhy it failsFix
No seed corpusFuzzer wanders randomly; takes hours to find shallow bugsAlways provide 5-50 hand-curated seeds
Committing evolved corpus to repoRepo bloats; PR diffs hide signalPersist via CI cache or object storage
Mixing seed and evolved corpus in one directoryLoses provenanceSeparate directories; seeds read-only
No dictionary for structured formatsFuzzer spends cycles re-discovering keywordsAlways provide a dict for JSON / XML / protobuf / SQL
Never minimisingCycle time grows; coverage redundantMinimise weekly or per major change
Crash artefact deleted after fixLose regression coverageAdd minimised crash to seed corpus
Single fuzzer assumedDifferent fuzzers find different bugsRun libFuzzer + AFL++ on same target periodically
Sharing prod-sourced corpus without PII reviewGDPR / HIPAA leakPass corpus through PII detection before sharing

Limitations

  • Corpus format incompatibility. libFuzzer / AFL++ / cargo-fuzz corpora aren't directly swap-compatible; cross-fuzzer testing needs conversion.
  • Corpus rot. Evolved corpora are tied to a specific binary's coverage instrumentation; rebuilding the target may invalidate some coverage information.
  • Dictionary maintenance. Dictionaries should evolve with the target's grammar - manual upkeep.
  • Corpus storage. Long campaigns produce GB-scale corpora; needs deliberate storage strategy (S3 / GCS / artifacts).
  • No semantic seeding for opaque formats. Custom binary formats may need a generator to bootstrap coverage.

References

Go native fuzzing (go test -fuzz)

View source (opens in new window)

Go native fuzzing (go test -fuzz)

Per-engine reference for coverage-guided-fuzzing; the shared workflow and fuzzer-choice routing live in ../SKILL.md (opens in new window).

Overview

Per go.dev/doc/security/fuzz (opens in new window), unlike libFuzzer / AFL++, Go's native fuzzer is:

  • Native: no separate toolchain
  • Typed: f.Fuzz(func(t *testing.T, s string, n int) { ... })
  • Integrated: failing inputs auto-saved as test fixtures

For sanitiser pairing: Go uses the race detector (-race) as its TSan-equivalent; ASan-equivalent comes via gcflags (limited).

For corpus discipline see corpus-management.md (opens in new window).

When to use

  • Fuzzing a Go library function (parser, encoder, validator).
  • A function with typed scalar / string / byte-slice inputs (Go's native fuzzer handles these without a FuzzedDataProvider).
  • Lightweight CI fuzz pass alongside go test.

For binary-level fuzzing of Go programs, AFL++ in -Q mode also works.

Authoring

Define a fuzz target

In any _test.go file alongside your unit tests:

package parser

import "testing"

func FuzzParseQuery(f *testing.F) {
    f.Add("SELECT * FROM users WHERE id = 1")
    f.Add("INSERT INTO foo VALUES (1, 'bar')")
    f.Add("")

    f.Fuzz(func(t *testing.T, q string) {
        result, err := ParseQuery(q)
        if err != nil {
            return
        }
        if result == nil {
            t.Fatalf("ParseQuery returned nil result with no error for %q", q)
        }
    })
}

Per go.dev/doc/security/fuzz (opens in new window):

  • The function name must start with Fuzz
  • The parameter is *testing.F
  • f.Add(seed1, seed2, ...) adds seed inputs
  • f.Fuzz(fn) registers the fuzz callback; fn takes *testing.T followed by typed parameters

Supported parameter types

Go's fuzzer supports these types as fuzz parameters:

TypeNotes
[]byteVariable-length byte slices
stringVariable-length strings
boolSingle byte
byte, runeIntegers
int, int8, int16, int32, int64Signed integers
uint, uint8, uint16, uint32, uint64Unsigned integers
float32, float64Floats

Multi-parameter functions are fuzzed jointly:

f.Fuzz(func(t *testing.T, port int, host string, body []byte) {
    handleRequest(host, port, body)
})

The fuzzer mutates all parameters together.

Seed corpus

Two sources for seeds:

  1. Inline f.Add(...) calls - versioned in code, executed on every test run.
  2. Files in testdata/fuzz/FuzzXxx/ - versioned text files, one per seed, in the same package.

The seed file format (per Go docs):

go test fuzz v1
string("SELECT * FROM users WHERE id = 1")
int(42)

For multi-parameter targets, each line corresponds to a parameter in order.

Failure auto-save

When go test -fuzz=Xxx finds a failure, it writes the failing input to testdata/fuzz/FuzzXxx/<sha256>. On the next go test run, this file becomes a regular regression test that must pass - no more -fuzz flag needed.

This is the unique strength of Go's approach: failing inputs become permanent regression coverage as part of the test fixture.

Running

Fuzz a target

# Run the unit tests + seeds (no exploration)
go test ./parser/

# Fuzz a specific target for 30 seconds
go test -fuzz=FuzzParseQuery -fuzztime=30s ./parser/

# Fuzz indefinitely (CI long-running)
go test -fuzz=FuzzParseQuery ./parser/

Common flags

FlagEffect
-fuzz=NAMERun the fuzz target with the given name
-fuzztime=DURATIONStop after duration (e.g., 30s, 1h) or -1 for indefinite
-fuzzminimizetime=DURATIONTime spent minimising failures (default 1m)
-fuzzcachedir=PATHWhere to cache mutations (default $GOCACHE/fuzz/)
-parallel=NConcurrent workers
-raceEnable race detector (TSan-equivalent)

Race detector

Pair fuzzing with the race detector for thread-safety bugs:

go test -race -fuzz=FuzzConcurrentAccess -fuzztime=10m ./...

Parsing results

When a failure occurs, Go prints:

--- FAIL: FuzzParseQuery (3.45s)
    --- FAIL: FuzzParseQuery/c1d4e1...
    fuzz: minimizing 50-byte failing input file
    --- FAIL: FuzzParseQuery (0.00s)
        parser_test.go:18: ParseQuery returned nil result with no error for "..."

    Failing input written to testdata/fuzz/FuzzParseQuery/c1d4e1abc...

    To re-run:
    go test -run=FuzzParseQuery/c1d4e1abc... ./parser/

Per the Go docs, the failing input lives at testdata/fuzz/FuzzXxx/<hash> - commit it as part of the fix to lock in regression coverage.

Reproducing a saved failure

go test -run=FuzzParseQuery/c1d4e1abc... ./parser/

This treats the saved file as a regular t.Run sub-test, no fuzzing.

CI integration

- uses: actions/setup-go@v5
  with: { go-version: '1.22' }
- name: Run tests (incl. seeds)
  run: go test -race ./...
- name: Smoke fuzz (3 min per target)
  run: |
    for target in $(grep -rh "^func Fuzz" --include="*_test.go" | \
                    awk '{print $2}' | sed 's/(.*//'); do
      echo "Fuzzing $target"
      go test -fuzz=$target -fuzztime=180s ./... || true
    done
- name: Commit any new regression fixtures
  if: always()
  run: |
    if git diff --quiet testdata/; then exit 0; fi
    git config user.name "fuzz-bot"
    git config user.email "fuzz-bot@example.com"
    git add testdata/
    git commit -m "Add fuzz failure fixtures"
    # PR or push - per team convention

Anti-patterns

Anti-patternWhy it failsFix
Missing f.Add callsFuzzer starts from empty corpus; slow path discoveryAdd 3-10 representative seeds
Skipping t.Fatalf for invariant violationsFuzzer can't detect logical bugsAssert invariants explicitly
Not committing testdata/fuzz/Lose regression coverage on next runCommit alongside the fix
-fuzztime=10s in CIToo short to find anything newUse 1-5 min smoke; long campaigns separate
One huge fuzz targetSlow iteration; unclear coverage attributionSplit per function
Fuzz target without -raceMisses concurrency bugs in concurrent codego test -race -fuzz=...
Ignoring testdata/ after CI fuzzNew regression fixtures lostCommit + PR them automatically

Limitations

  • Go-only. Doesn't fuzz CGo or C dependencies; for those, use AFL++ or libFuzzer with a Go wrapper.
  • Typed parameter only. No []byte → struct mutation beyond the supported types; complex inputs need manual unmarshalling in the fuzz body.
  • No native ASan equivalent. Go's GC + memory safety make many ASan-style bugs impossible; race detector covers concurrency.
  • Slower than libFuzzer for tight loops - coverage instrumentation is per-block via gcflags.
  • -fuzz is single-target. Can't fuzz multiple FuzzXxx simultaneously in one go test invocation; loop or use -jobs.

References

Jazzer (JVM)

Per-engine reference for coverage-guided-fuzzing; the shared workflow and fuzzer-choice routing live in ../SKILL.md (opens in new window).

Overview

Distinct from C/C++ fuzzers: Jazzer (per github.com/CodeIntelligenceTesting/jazzer (opens in new window)) ships JVM-level sanitisers that detect security-sensitive misuse of standard APIs (deserialization gadgets, SSRF, ReDoS) - not memory-safety bugs (the JVM handles those).

For corpus discipline see corpus-management.md (opens in new window).

When to use

  • Fuzz testing Java / Kotlin / Scala / Groovy libraries.
  • Targets handling user input - parsers, deserialisers, HTTP handlers, URL constructors (Jazzer's JVM sanitisers catch injection bugs).
  • JUnit 5-anchored projects - Jazzer integrates as a JUnit test type.

Authoring

Install (Maven)

Per Jazzer README:

<dependency>
    <groupId>com.code-intelligence</groupId>
    <artifactId>jazzer-junit</artifactId>
    <version>${jazzer.version}</version>
    <scope>test</scope>
</dependency>

Install (Gradle)

dependencies {
    testImplementation "com.code-intelligence:jazzer-junit:$jazzerVersion"
}

Pin the version in one place - jazzer.version (Maven property) or jazzerVersion (Gradle ext) - to the latest release from Maven Central (search.maven.org/artifact/com.code-intelligence/jazzer-junit (opens in new window)); 0.22.1 was current at time of writing.

Install (standalone)

Download binary release from GitHub; invoke jazzer --cp=<classpath>.

Fuzz target with JUnit 5

import com.code_intelligence.jazzer.junit.FuzzTest;
import org.jetbrains.annotations.NotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ParserFuzzTest {

    @FuzzTest
    void fuzzDecode(@NotNull String input) {
        assertEquals(input, SomeScheme.decode(SomeScheme.encode(input)));
    }
}

Per Jazzer docs, @FuzzTest is the annotation that registers a fuzz target. The method parameters become fuzzer-mutated typed inputs.

Supported parameter types

Per Jazzer README, @FuzzTest parameters support:

  • Primitive types (int, long, boolean, byte, char, short, float, double)
  • String
  • Arrays of primitives + arrays of String
  • Many standard library classes via auto-marshalling

Annotations refine mutation:

AnnotationEffect
@NotNullParameter never null
@WithUtf8Length(min=N, max=M)String byte-length bound
@InRange(min=N, max=M)Integer range
@FuzzTest
void fuzzWithBounds(@NotNull @WithUtf8Length(max = 256) String host,
                    @InRange(min = 1, max = 65535) int port) {
    handleRequest(host, port);
}

FuzzedDataProvider (advanced)

For complex input shapes:

import com.code_intelligence.jazzer.api.FuzzedDataProvider;

@FuzzTest
void fuzzComplex(FuzzedDataProvider data) {
    int n = data.consumeInt(0, 100);
    String s = data.consumeString(64);
    byte[] body = data.consumeRemainingAsBytes();
    process(n, s, body);
}

Running

Modes - regression vs fuzzing

Per Jazzer docs:

  • Regression mode (default): runs the test against any saved inputs in src/test/resources/<TestClass>/<methodName>/ - fast, deterministic.
  • Fuzzing mode: set JAZZER_FUZZ=1 env var; explores new inputs.
# Regression
mvn test

# Fuzzing
JAZZER_FUZZ=1 mvn test -Dtest=ParserFuzzTest#fuzzDecode

# Bounded time
JAZZER_FUZZ=300 mvn test -Dtest=ParserFuzzTest
# (specific seconds)

Standalone invocation

./jazzer \
  --cp=target/test-classes:target/classes \
  --target_class=com.example.ParserFuzzTest \
  --target_method=fuzzDecode \
  -max_total_time=300

Common flags

Jazzer accepts libFuzzer-style flags:

FlagEffect
-max_total_time=NStop after N seconds
-runs=NNumber of iterations
-dict=pathDictionary file
--keep_going=NKeep fuzzing after first crash (find N total)
--instrumentation_includes=PKG.*Limit coverage instrumentation to a package

JVM sanitisers

Jazzer's built-in detectors fire automatically on security-relevant misuse (deserialization gadgets, SSRF, path traversal, OS command injection, ReDoS, LDAP / JNDI / SQL injection) - no extra config; disable selectively via --disabled_hooks=.... Full catalogue with what each catches: see "JVM sanitiser catalogue" below.

Parsing results

When Jazzer finds a crash, output:

== Java Exception: java.lang.AssertionError: expected: <foo> but was: <bar>
    at com.example.ParserFuzzTest.fuzzDecode(ParserFuzzTest.java:12)
    ...
== libFuzzer crashing input ==
artifact_prefix='./'; Test unit written to ./crash-<sha1>
Base64: <encoded-input>
Reproducer input written to: src/test/resources/com/example/ParserFuzzTest/fuzzDecode/<sha1>

The reproducer is saved to src/test/resources/... as part of the test fixtures - commit it for regression coverage.

CI integration

Run regression inputs with mvn test, then a bounded smoke-fuzz (JAZZER_FUZZ=180) over every @FuzzTest class and upload crashes: see "Full CI job" below.

Anti-patterns

Anti-patternWhy it failsFix
Untyped byte[] parameter for structured inputForegoes Jazzer's typed-mutation advantageUse typed parameters or FuzzedDataProvider
Catching Throwable in targetHides real bugsLet exceptions propagate; use assertXxx for invariants
Skipping @NotNull annotationSpurious NPE crashesAlways annotate @NotNull unless null is legitimate
Not committing reproducer filesLose regression coverageCommit src/test/resources/<test-class>/<method>/
Disabling all JVM sanitisersLoses Jazzer's biggest advantage over plain libFuzzerKeep sanitisers enabled; disable selectively if false positives
Single-target campaignOther targets not exercisedRun all @FuzzTest methods in CI

Limitations

  • JVM startup cost. Each fuzz iteration shares the JVM, so startup is amortised - but JIT warmup still affects early iterations.
  • Allocation-heavy targets slow. GC dominates iteration time for allocation-heavy code.
  • Native (JNI) code not coverage-instrumented. For native bug-hunting in JNI libraries use libFuzzer + JNI wrappers.
  • @FuzzTest on Kotlin works but parameter mutation respects Kotlin nullability - null-tolerant Kotlin parameters fuzz with null values too.
  • Distinguishes "test failure" from "fuzz finding" loosely - any AssertionError is a finding; tune assertions deliberately.

References

JVM sanitiser catalogue

Per Jazzer README, built-in detectors fire on security-relevant misuse:

SanitiserWhat it catches
DeserializationUntrusted ObjectInputStream / XStream / Kryo input → gadget execution
SSRFURL constructed from untrusted input pointing at internal infrastructure
Path traversal.. / encoded variants in file path arguments
OS command injectionRuntime.exec / ProcessBuilder with concatenated input
ReDoSCatastrophic-backtracking regex constructed from untrusted input
LDAP injectionLDAP query string concatenation
Naming contextJNDI lookup with untrusted name
SQL injection (via Hibernate / direct JDBC)Query string concatenation

These run automatically - no additional configuration. Disable selectively via --disabled_hooks=....

Full CI job

- uses: actions/setup-java@v5
  with: { java-version: '17', distribution: 'temurin' }
- name: Run unit tests + regression fuzz inputs
  run: mvn test
- name: Smoke fuzz (3 min per target)
  run: |
    for cls in $(grep -rl "@FuzzTest" src/test/java/ | \
                 sed 's|src/test/java/||; s|/|.|g; s|.java||'); do
      JAZZER_FUZZ=180 mvn test -Dtest=$cls || true
    done
- uses: actions/upload-artifact@v4
  with:
    name: jazzer-crashes
    path: |
      crash-*
      src/test/resources/**/*

|| true is continue-on-crash: a finding does not fail the job, so the loop still fuzzes every target - triage findings from the uploaded jazzer-crashes artifact. To hard-fail the build on new findings instead, drop || true (the first crash then fails the step).

libFuzzer (C/C++, in-process)

View source (opens in new window)

libFuzzer (C/C++, in-process)

Per-engine reference for coverage-guided-fuzzing; the shared workflow and fuzzer-choice routing live in ../SKILL.md (opens in new window).

Overview

This reference wraps LLVM's libFuzzer (per llvm.org/docs/LibFuzzer.html (opens in new window)) for C/C++ targets. Composes with:

When to use

  • Fuzzing a C / C++ library function (parser, decoder, validator).
  • Targeting a specific function - in-process fuzzing is faster than out-of-process AFL.
  • Pairing with ASan + UBSan for memory-safety + UB detection.

Authoring

The fuzz target

Define the entry point LLVMFuzzerTestOneInput:

#include <cstddef>
#include <cstdint>
#include "your_library.h"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
    your_parser(Data, Size);
    return 0;
}

Per LLVM docs, the function signature is fixed: takes a const byte buffer + size, returns int (must return 0 for normal execution; non-zero values are reserved).

The fuzzer calls this function repeatedly with mutated Data. The target's job is to drive the library code under test and let sanitisers + asserts catch bugs.

Initialisation

Optional one-time setup:

extern "C" int LLVMFuzzerInitialize(int *argc, char ***argv) {
    your_library_init();
    return 0;
}

Build

Standard build flag:

clang -g -O1 \
  -fsanitize=fuzzer,address,undefined \
  -fno-sanitize-recover=all \
  -fno-omit-frame-pointer \
  fuzz_target.cc your_library.cc -o fuzz_target

Per sanitizer-integration.md (opens in new window): ASan + UBSan is the default pair; add MSan in a separate binary if needed.

Tips for an effective target

TipWhy
Keep the target smallFaster iteration; clearer coverage
Avoid global state between runsCross-input contamination defeats coverage guidance
Use the full inputDon't if (Size < 100) return 0; unless the lib requires
Avoid expensive I/O / network in the targetSlows iterations
Use FuzzedDataProvider for structured inputsSplits Data into typed sub-values

FuzzedDataProvider (from LLVM's compiler-rt/include/fuzzer/FuzzedDataProvider.h):

#include <fuzzer/FuzzedDataProvider.h>

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
    FuzzedDataProvider fdp(Data, Size);
    int port = fdp.ConsumeIntegralInRange(1, 65535);
    std::string host = fdp.ConsumeRandomLengthString(64);
    std::vector<uint8_t> body = fdp.ConsumeRemainingBytes<uint8_t>();
    your_request_handler(host, port, body);
    return 0;
}

Running

Basic run

mkdir corpus/ seeds/
# Populate seeds/ with hand-curated inputs
./fuzz_target -max_total_time=3600 corpus/ seeds/

The first directory is writable (evolved corpus); subsequent are read-only seeds (per corpus-management.md (opens in new window)).

Common flags

Most-used: -max_total_time=N, -runs=N (-1 = infinite), -dict=path, -fork=N, -workers=N, -merge=1 (corpus minimisation), -rss_limit_mb=N (default 2048), -timeout=N (per-input, default 1200). Full table: see "Full flag table" below (per llvm.org/docs/LibFuzzer.html (opens in new window)).

Parallel fuzzing

./fuzz_target -fork=8 -max_total_time=3600 corpus/ seeds/

-fork=N spawns N processes, each with its own corpus subset. Combine corpora periodically with -merge=1.

Reproducing a crash

./fuzz_target crash-<sha1>
# Sanitiser report prints to stderr; same as the original crash

Verify: confirm the replay prints the same sanitiser bug class and top stack frame as the original finding before minimising. If it does not reproduce, the artefact is stale against the current build (target or library rebuilt) - rebuild the target and re-run before proceeding.

Minimise the crash input:

./fuzz_target -minimize_crash=1 -runs=10000 crash-<sha1>
# Writes minimized-from-crash-<sha1> with the smallest reproducer

Dictionary file

For structured formats:

# fuzz.dict
"{"
"}"
"["
"]"
"true"
"false"
"null"
"\":\""

Invoke: ./fuzz_target -dict=fuzz.dict corpus/.

Parsing results

libFuzzer crash artefacts are saved as:

  • crash-<sha1> - segfault / sanitiser-detected error
  • leak-<sha1> - memory leak detected by LSan
  • timeout-<sha1> - exceeded -timeout
  • oom-<sha1> - RSS exceeded -rss_limit_mb

Each file's contents are the input bytes that triggered the crash. Pair with the sanitiser report (stderr) for stack + allocation site.

For automated parsing (e.g., file as a bug), feed the sanitiser-report output to the from-CI-failure workflow in bug-report-template (qa-bug-repro plugin):

./fuzz_target crash-<sha1> 2> sanitiser-report.txt
python scripts/file-bug-from-asan.py sanitiser-report.txt crash-<sha1>

CI integration

Short smoke fuzz (5 min) on every PR: build with -fsanitize=fuzzer,address,undefined, cache fuzz/corpus, run ./fuzz_target -max_total_time=300 fuzz/corpus fuzz/seeds, and upload crash-* / leak-* / timeout-* / oom-* artifacts. Full workflow: see "Full CI job" below.

For long-running campaigns, OSS-Fuzz (google.github.io/oss-fuzz (opens in new window)) is the canonical infrastructure.

Anti-patterns

Anti-patternWhy it failsFix
LLVMFuzzerTestOneInput with global state mutationCross-input contamination breaks coverage signalReset state per call or use LLVMFuzzerInitialize
Fuzz target without ASan + UBSanCatches only crashes; 80%+ of bugs missedAlways compose with sanitisers
No corpus minimisation everCorpus grows unbounded; cycle time degradesWeekly -merge=1
Crash committed without minimisationLarge bug-report attachmentsAlways -minimize_crash=1
Single huge fuzz targetSlow iterations; coverage attribution opaqueSplit into multiple targets per function
Ignoring -rss_limit_mb OOMsFalse crash classSet limit explicit; or disable allocator-related target paths
No dictionary for structured formatsFuzzer slowly rediscovers grammarAlways supply -dict= for JSON / XML / SQL / proto

Limitations

  • In-process only. Doesn't fuzz inter-process boundaries; for network protocols see AFL++ in -Q (QEMU) mode or specialised tools.
  • C / C++ + Rust + Swift only. Other languages have their own fuzzers (Atheris, Jazzer, Go native).
  • Coverage instrumentation overhead. Hot inner loops slow significantly under -fsanitize=fuzzer.
  • Crash uniqueness heuristic. libFuzzer dedup is sha1-based on the input; the same bug from two inputs creates two artefacts - pair with crash-stack-deduplication tooling.
  • No coverage report by default. Use -coverage flag or external llvm-profdata + llvm-cov for line-level coverage.

References

Full flag table

Per llvm.org/docs/LibFuzzer.html (opens in new window):

FlagEffect
-max_total_time=NStop after N seconds
-runs=NStop after N executions (-1 = infinite)
-dict=pathUse dictionary file
-seed=NRandom seed
-fork=NRun N parallel fork-mode workers
-workers=NNumber of parallel worker processes
-jobs=NTotal number of jobs to run across workers
-merge=1Corpus minimisation mode
-print_final_stats=1Print stats summary on exit
-rss_limit_mb=NRSS memory limit (default 2048)
-timeout=NPer-input timeout in seconds (default 1200)
-only_ascii=1Restrict to ASCII bytes

Full CI job

Short smoke fuzz on every PR:

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Install clang
        run: sudo apt-get install -y clang lld
      - name: Build fuzz target
        run: |
          clang++ -g -O1 \
            -fsanitize=fuzzer,address,undefined \
            -fno-sanitize-recover=all \
            -fno-omit-frame-pointer \
            fuzz/fuzz_target.cc lib/parser.cc -o fuzz_target
      - uses: actions/cache@v4
        with:
          path: fuzz/corpus
          key: fuzz-corpus-${{ github.sha }}
          restore-keys: fuzz-corpus-
      - name: Smoke fuzz (5 min)
        run: ./fuzz_target -max_total_time=300 fuzz/corpus fuzz/seeds
      - name: Upload crashes
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: crashes
          path: |
            crash-*
            leak-*
            timeout-*
            oom-*

Sanitiser integration

Shared reference for coverage-guided-fuzzing; the per-engine references and the umbrella workflow live alongside this file.

Overview

Pure-reference catalog of the five clang sanitisers (ASan, UBSan, MSan, TSan, LSan) used with coverage-guided fuzz targets - what each detects, build flags, runtime options, compatibility matrix, performance overhead. Consumed by the per-engine references and fuzz-target authoring. For corpus discipline see corpus-management.md (opens in new window).

When to use

  • Choosing which sanitisers to enable for a fuzz target.
  • Interpreting a crash artefact's sanitiser report.
  • Tuning sanitiser runtime options for false positives / performance.
  • CI-gating builds with sanitisers enabled.

How to use

  1. Identify the fuzz target's language and threat model (memory safety, undefined behavior, uninitialised reads, or data races).
  2. Pick sanitisers from the summary table below; default to ASan + UBSan for most C / C++ targets.
  3. Check the compatibility matrix before combining - ASan + UBSan is fine, but ASan + MSan and anything + TSan are not, so split those into separate binaries.
  4. Build with -fsanitize=fuzzer,<sanitisers> plus -fno-sanitize-recover=all and -fno-omit-frame-pointer -g.
  5. Set runtime options (ASAN_OPTIONS, UBSAN_OPTIONS) so the fuzzer aborts on first error.
  6. When a crash lands, read the sanitiser report top-down: bug class, access, crash-site frame, then allocation / free site.
  7. For MSan-required libraries, build a separate MSan-only binary with all dependencies instrumented and run it as a second campaign.

The five sanitisers

SanitiserDetects (summary)Build flagSlowdown
ASanheap / stack / global OOB, use-after-free, double-free-fsanitize=address -fno-omit-frame-pointer -g~2x
UBSansigned overflow, div-by-zero, null deref, misaligned access-fsanitize=undefined -fno-sanitize-recover=all~10%
MSanuninitialised memory reads-fsanitize=memory -fno-omit-frame-pointer -fsanitize-memory-track-origins3x
TSandata races, deadlocks, thread-safety violations-fsanitize=thread -O1 -g5 - 15x
LSanmemory leaks at program exit-fsanitize=leak (or embedded in ASan)small

Full per-sanitiser detail - complete detect lists, the ASAN_OPTIONS / UBSAN_OPTIONS runtime-option tables, the MSan whole-program requirement, and LSan's embedded vs standalone modes: see "Per-sanitiser catalog" below.

Compatibility matrix

Can multiple sanitisers run in the same binary?

SanitiserASanUBSanMSanTSan
ASan-
UBSan-
MSan-
TSan-

The standard fuzzing pair is ASan + UBSan (catches most memory + UB issues, manageable slowdown):

clang -g -O1 -fsanitize=fuzzer,address,undefined \
      -fno-sanitize-recover=all \
      -fno-omit-frame-pointer fuzz_target.cc -o fuzz_target

For MSan-required projects (e.g., crypto libraries), build a separate MSan-only binary and run it as a second fuzzing campaign.

libFuzzer + sanitiser composition

The -fsanitize=fuzzer,address,undefined flag composes the libFuzzer engine with ASan + UBSan in one binary. Each sanitiser contributes its instrumentation.

Per llvm.org/docs/LibFuzzer.html (opens in new window):

# Build
clang -g -O1 \
  -fsanitize=fuzzer,address,undefined \
  -fno-sanitize-recover=all \
  fuzz_target.cc -o fuzz_target

# Run
ASAN_OPTIONS=abort_on_error=1:halt_on_error=1 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
  ./fuzz_target -max_total_time=3600 corpus/

Reading a sanitiser report

ASan output structure:

==1234==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7f...
READ of size 4 at 0x7f... thread T0
    #0 0x4015a3 in process_input src/parser.c:42:5
    #1 0x4012f0 in LLVMFuzzerTestOneInput fuzz_target.cc:10:3
    ...
0x7f... is located 0 bytes to the right of 16-byte region 0x7f..., 0x7f...)
allocated by thread T0 here:
    #0 0x40e7c0 in __interceptor_malloc
    #1 0x4015a3 in process_input src/parser.c:39:9

Key fields:

  • Bug class: heap-buffer-overflow, stack-use-after-return, use-after-free, double-free, memory-leak
  • Access: READ or WRITE, size
  • Stack: Top frame = crash site; below = call chain
  • Allocation site: Where the corrupted memory was allocated
  • Freed site (UAF): Where the memory was freed

Parse this for the from-CI-failure workflow in bug-report-template (qa-bug-repro plugin) to extract the failure assertion.

Per-language sanitiser support

LanguageASanUBSanMSanTSanLSan
C / C++ (clang / GCC)✓ (clang)
Rust (nightly)
Gopartial (race detector for TSan-equivalent)---
Swift-
Objective-C-

Java / Kotlin (Jazzer) uses JVM-level sanitisers (sanitisers for unsafe-API misuse, deserialisation gadgets, ReDoS) rather than clang's; see jazzer.md (opens in new window).

Python (Atheris) uses per-module instrumentation + the host process's libFuzzer; you can attach ASan to the Python interpreter itself.

Worked example

A team fuzzes a C++ PNG parser. They choose ASan + UBSan (the standard pair) and build with:

clang -g -O1 -fsanitize=fuzzer,address,undefined \
      -fno-sanitize-recover=all -fno-omit-frame-pointer \
      png_fuzzer.cc -o png_fuzzer

Running under ASAN_OPTIONS=abort_on_error=1:halt_on_error=1, the fuzzer trips within minutes. The report opens with heap-buffer-overflow ... READ of size 4, top frame process_input src/parser.c:42, allocated at src/parser.c:39 (a 16-byte region). The bug class plus the allocation site pin it to an off-by-one in the chunk-length handling. The parser has no MSan dependency requirement, so they skip the separate MSan binary and hand the report to the from-CI-failure workflow in bug-report-template.

Anti-patterns

Anti-patternWhy it failsFix
Fuzzing without sanitisersCatches only crashes; misses 80%+ of memory bugsAlways build with ASan + UBSan minimum
-fsanitize=address,memory togetherMSan + ASan incompatiblePick one; run separate binaries
MSan with non-MSan dependenciesFalse positives flood the reportBuild all dependencies with MSan or skip MSan
UBSan without -fno-sanitize-recover=allUBSan logs but doesn't abort; fuzzer never sees the bugAlways add -fno-sanitize-recover=all
ASan without -fno-omit-frame-pointerStack traces are uselessAlways add -fno-omit-frame-pointer -g
detect_leaks=0 in fuzz CILeak bugs go unnoticedDefault ASan settings (Linux LSan-enabled)
TSan + a non-thread-safe targetSlow + noisy; data races are everywherePick targets where thread-safety claims are made

Limitations

  • Performance trade-offs. ASan + UBSan ≈ 2-3x slowdown; TSan ≈ 10-15x. Long fuzz campaigns need budget planning.
  • Sanitiser incompatibility. No single binary catches everything; multiple campaigns required.
  • Heap-buffer-overflow on rdtsc edges. Some sanitisers have per-architecture false-positive surfaces.
  • Build system integration. Pre-existing build systems may need invasive changes (CMake -DCMAKE_C_FLAGS=-fsanitize=...).
  • Library compatibility. Some C / C++ libraries hand-roll memory tricks (custom allocators, intrusive lists) that sanitisers misflag.

References

Per-sanitiser catalog

AddressSanitizer (ASan)

What it detects (per clang.llvm.org/docs/AddressSanitizer.html (opens in new window)):

  • Out-of-bounds accesses to heap, stack, and globals
  • Use-after-free
  • Double-free, invalid free
  • Memory leaks (experimental; LSan integrated)

Build flag: -fsanitize=address -fno-omit-frame-pointer -g

Performance: "Typical slowdown introduced by AddressSanitizer is 2x" per the docs.

Runtime options (ASAN_OPTIONS=key=value:...):

OptionEffect
detect_leaks=1Enable leak detection (default on Linux)
detect_stack_use_after_return=0Disable use-after-return checks (faster)
detect_container_overflow=0Disable container-overflow detection
symbolize=0Disable online symbolization (use post-mortem)
check_initialization_order=1Init-order checking
halt_on_error=1Stop on first error
abort_on_error=1SIGABRT on error (for fuzzers)

UndefinedBehaviorSanitizer (UBSan)

What it detects: signed integer overflow, division by zero, null pointer deref, misaligned access, float-int conversion overflow, invalid enum / bool, vptr corruption, function-pointer type mismatch, etc.

Build flag: -fsanitize=undefined -fno-sanitize-recover=all

The -fno-sanitize-recover=all is important for fuzzing - without it, UBSan logs but doesn't abort, so the fuzzer doesn't see the bug.

Performance: ~10% slowdown - much lighter than ASan.

Runtime options (UBSAN_OPTIONS):

  • print_stacktrace=1 - include stack trace in reports
  • halt_on_error=1 - abort on first error

MemorySanitizer (MSan)

What it detects: Uninitialised memory reads.

Build flag: -fsanitize=memory -fno-omit-frame-pointer -fsanitize-memory-track-origins

Performance: 3x slowdown.

Critical: MSan requires the entire program (and all dependencies) to be built with -fsanitize=memory. Linking against non-MSan-instrumented libraries produces false positives.

Compatibility: MSan is incompatible with ASan; cannot combine.

ThreadSanitizer (TSan)

What it detects: Data races, deadlocks, thread-safety violations.

Build flag: -fsanitize=thread -O1 -g

Performance: 5 - 15x slowdown + 5 - 10x memory.

Compatibility: TSan is incompatible with ASan and MSan.

LeakSanitizer (LSan)

What it detects: Memory leaks at program exit.

Modes:

  • Embedded in ASan: -fsanitize=address enables LSan by default on Linux. Toggle via detect_leaks=1.
  • Standalone: -fsanitize=leak - leak detection only, no other checks. Smaller overhead.