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-fuzzingcoverage-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.
| Engine | Language / niche | Reference |
|---|---|---|
| libFuzzer | C/C++ callable APIs, in-process (also Swift) | references/libfuzzer.md |
| AFL++ | File/stdin-driven binaries, closed-source via QEMU | references/afl-plus-plus.md |
| cargo-fuzz | Rust crates (libFuzzer + cargo, nightly) | references/cargo-fuzz.md |
| Go native | Go packages (go test -fuzz, Go 1.18+) | references/go-native-fuzzing.md |
| Atheris | Python libraries + CPython extensions | references/atheris.md |
| Jazzer | Java / 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
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 characteristic | Route to |
|---|---|
| C / C++ library with callable function API | libFuzzer |
| C / C++ binary processing files | AFL++ |
| C / C++ source unavailable | AFL++ (-Q QEMU) |
| Rust crate | cargo-fuzz |
| Rust binary processing files | AFL++ |
| Go package | Go native fuzzing |
| Pure Python or CPython native extension | Atheris |
| Java / Kotlin / Scala / Groovy library | Jazzer |
| Swift library | libFuzzer (Swift wraps libFuzzer natively) |
Routing rationale:
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:
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:
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-pattern | Why it fails | Fix |
|---|---|---|
| Picking AFL++ for a callable C/C++ library API | Out-of-process overhead 10-100x | libFuzzer for in-process |
| Picking libFuzzer for a file-processing binary | Adapter glue is complex; AFL++ handles @@ cleanly | AFL++ |
| Picking cargo-fuzz on stable Rust | Won't compile | Nightly toolchain |
| Fuzzing without sanitizers | Catches only crashes; most bugs silent | Step 3 defaults |
| No seed corpus / no dictionary for structured formats | Fuzzer wanders; slow path discovery | Step 2 |
| Never minimising the corpus | Cycle time degrades; coverage redundant | Weekly -merge=1 / afl-cmin |
| Mixing fuzzer corpora without conversion | libFuzzer / AFL++ formats aren't compatible | One fuzzer's corpus; convert if needed |
| Routing on language alone, ignoring source availability | Closed-source needs QEMU regardless | Factor in availability |
Limitations
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
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 allOr LLVM-based (recommended):
CC=afl-clang-fast CXX=afl-clang-fast++ makeafl-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" \
makeAFL_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:
Common flags
| Flag | Effect |
|---|---|
-M name | Master process in parallel campaign |
-S name | Secondary process (slave) |
-x dict.txt | Use dictionary |
-t N | Per-input timeout in ms (default 1000) |
-m N | Memory limit per child (MB) |
-Q | QEMU mode for non-instrumented binaries |
-c path | CMPLog 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):
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-pattern | Why it fails | Fix |
|---|---|---|
| Target compiled without AFL instrumentation | No coverage signal; fuzzer is blind | Use afl-clang-fast or -Q (QEMU) |
-i - (continuation) without checking output state | Hard to resume; lost queue progress | Use canonical -i seeds/ on fresh runs |
| Single AFL slave on a multi-core machine | 80% of cores idle | Run N-1 slaves in parallel |
No AFL_USE_ASAN=1 | Only catches crashes, not memory bugs | Always set sanitiser env vars |
Treating hangs/ as bugs | Often false positives (slow targets, infinite loops in test data) | Investigate; raise -t for slow targets |
Long-running campaign without afl-cmin cycle | Queue bloats; cycle time degrades | Run afl-cmin weekly |
| Mixing AFL++ + libFuzzer corpora | Format incompatibility | Convert via afl-fuzz -i corpus -E ... round-trip |
Limitations
References
Atheris (Python)
View source (opens in new window)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
Authoring
Install
pip install atherisPer 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:
Coverage instrumentation
Atheris needs to instrument the modules under test:
with atheris.instrument_imports():
from my_library import parser, decoderThe 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.pyAtheris 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:
| Flag | Effect |
|---|---|
-max_total_time=N | Stop after N seconds |
-atheris_runs=N | Run N iterations then stop (also enables coverage report) |
-dict=path | Use dictionary file |
-seed=N | Random seed |
-runs=N | libFuzzer runs (use -atheris_runs for Atheris-specific) |
Coverage report
python fuzz_parser.py -atheris_runs=100000 corpus/
# At end: prints coverage statisticsReproducing a crash
python fuzz_parser.py crash-<sha1>
# Same crash with full tracebackParsing 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-pattern | Why it fails | Fix |
|---|---|---|
Module imports above instrument_imports() | Coverage signal absent for those modules | Always import via with atheris.instrument_imports(): ... |
No exception handling in TestOneInput | Expected exceptions (ValueError on bad input) count as crashes | Catch expected exceptions; only let unexpected ones propagate |
| Pure-Python target without instrumentation | Coverage is blind; fuzzer flailing | Always instrument |
Missing atheris.Fuzz() call | Fuzz loop never starts | Always end with atheris.Fuzz() |
| Treating every traceback as a bug | Many tracebacks are spec-compliant (raising ValueError on invalid input is correct) | Use assert for invariants; let spec-defined exceptions through |
| Native extension without ASan | C bugs silent (segfault crashes Python interpreter) | Build CPython + extension with ASan for native fuzzing |
Limitations
References
cargo-fuzz (Rust)
View source (opens in new window)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
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-fuzzInitialise
In your crate root:
cargo fuzz initThis creates a fuzz/ subdirectory:
fuzz/
Cargo.toml
fuzz_targets/
fuzz_target_1.rs # generated default targetAdd a fuzz target
cargo fuzz add parse_queryCreates 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_queryThis builds the target with libFuzzer instrumentation + ASan and runs indefinitely.
Common options
| Option | Effect |
|---|---|
--release | Release-mode build (faster, less debug info) |
--debug-assertions | Keep debug assertions in release mode |
--sanitizer=<name> | address (default), leak, memory, thread, none |
--jobs=N | Parallel workers |
--no-default-features | Disable 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=300Sanitiser 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 formatCrash 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-pattern | Why it fails | Fix |
|---|---|---|
| Using stable toolchain | cargo-fuzz needs nightly | rustup install nightly; use cargo +nightly fuzz |
Raw &[u8] for structured input | Mutation hits format errors more than logic | Use Arbitrary + a custom struct |
| Empty seed corpus | Fuzzer wanders; slow path discovery | Drop a few representative inputs in fuzz/corpus/<target>/ |
Ignoring --release | Debug builds slow iteration | Use --release for long campaigns |
No cargo fuzz fmt on crash | Hard-to-read crash inputs | Always cargo fuzz fmt before filing a bug |
Committing fuzz/artifacts/ to repo | Repo bloat | .gitignore artifacts; persist via CI cache |
| Mixing fuzz targets in one file | Cargo treats each fuzz_targets/*.rs as one binary | One file per target |
Limitations
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_queryReading 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_mallocFull 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
View source (opens in new window)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
Corpus components
A fuzzing corpus has three roles:
| Role | What it is | Where it lives |
|---|---|---|
| Seed corpus | Hand-curated initial inputs covering known interesting paths | Versioned in repo (fuzz/seeds/) |
| Evolved corpus | Inputs the fuzzer added because they hit new coverage | Output directory (fuzz/corpus/) - typically not committed |
| Crash artefacts | Inputs that triggered a sanitiser / crash / timeout | Output 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 binaryInvocation:
./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)
seedfile2Failures 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_1Atheris (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.zipPer google.github.io/oss-fuzz (opens in new window).
The corpus syncs to gs://<project>-corpus.clusterfuzz-external.appspot.com/.
Seed corpus construction strategies
| Strategy | When | Example |
|---|---|---|
| From spec keywords | New target, no inputs exist | Extract JSON keywords from a JSON parser spec, write each as a tiny file |
| From test fixtures | Existing unit-test inputs cover paths | Copy fixtures from tests/fixtures/*.json to seeds/ |
| From production data | Mature target, prod logs available | Sample 1000 prod requests, strip PII per qa-test-data-privacy's pii-categories catalog (pii-masking-pipeline-builder references/), seed |
| From corpus minimisation | Reduce a large corpus to its coverage-equivalent core | Run afl-cmin or libFuzzer -merge=1 |
| From OSS-Fuzz cousin | Same format, different target | Reuse <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:
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-pattern | Why it fails | Fix |
|---|---|---|
| No seed corpus | Fuzzer wanders randomly; takes hours to find shallow bugs | Always provide 5-50 hand-curated seeds |
| Committing evolved corpus to repo | Repo bloats; PR diffs hide signal | Persist via CI cache or object storage |
| Mixing seed and evolved corpus in one directory | Loses provenance | Separate directories; seeds read-only |
| No dictionary for structured formats | Fuzzer spends cycles re-discovering keywords | Always provide a dict for JSON / XML / protobuf / SQL |
| Never minimising | Cycle time grows; coverage redundant | Minimise weekly or per major change |
| Crash artefact deleted after fix | Lose regression coverage | Add minimised crash to seed corpus |
| Single fuzzer assumed | Different fuzzers find different bugs | Run libFuzzer + AFL++ on same target periodically |
| Sharing prod-sourced corpus without PII review | GDPR / HIPAA leak | Pass corpus through PII detection before sharing |
Limitations
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:
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
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):
Supported parameter types
Go's fuzzer supports these types as fuzz parameters:
| Type | Notes |
|---|---|
[]byte | Variable-length byte slices |
string | Variable-length strings |
bool | Single byte |
byte, rune | Integers |
int, int8, int16, int32, int64 | Signed integers |
uint, uint8, uint16, uint32, uint64 | Unsigned integers |
float32, float64 | Floats |
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:
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
| Flag | Effect |
|---|---|
-fuzz=NAME | Run the fuzz target with the given name |
-fuzztime=DURATION | Stop after duration (e.g., 30s, 1h) or -1 for indefinite |
-fuzzminimizetime=DURATION | Time spent minimising failures (default 1m) |
-fuzzcachedir=PATH | Where to cache mutations (default $GOCACHE/fuzz/) |
-parallel=N | Concurrent workers |
-race | Enable 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-pattern | Why it fails | Fix |
|---|---|---|
Missing f.Add calls | Fuzzer starts from empty corpus; slow path discovery | Add 3-10 representative seeds |
Skipping t.Fatalf for invariant violations | Fuzzer can't detect logical bugs | Assert invariants explicitly |
Not committing testdata/fuzz/ | Lose regression coverage on next run | Commit alongside the fix |
-fuzztime=10s in CI | Too short to find anything new | Use 1-5 min smoke; long campaigns separate |
| One huge fuzz target | Slow iteration; unclear coverage attribution | Split per function |
Fuzz target without -race | Misses concurrency bugs in concurrent code | go test -race -fuzz=... |
Ignoring testdata/ after CI fuzz | New regression fixtures lost | Commit + PR them automatically |
Limitations
References
Jazzer (JVM)
View source (opens in new window)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
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:
Annotations refine mutation:
| Annotation | Effect |
|---|---|
@NotNull | Parameter 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
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:
| Flag | Effect |
|---|---|
-max_total_time=N | Stop after N seconds |
-runs=N | Number of iterations |
-dict=path | Dictionary file |
--keep_going=N | Keep 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-pattern | Why it fails | Fix |
|---|---|---|
Untyped byte[] parameter for structured input | Foregoes Jazzer's typed-mutation advantage | Use typed parameters or FuzzedDataProvider |
Catching Throwable in target | Hides real bugs | Let exceptions propagate; use assertXxx for invariants |
Skipping @NotNull annotation | Spurious NPE crashes | Always annotate @NotNull unless null is legitimate |
| Not committing reproducer files | Lose regression coverage | Commit src/test/resources/<test-class>/<method>/ |
| Disabling all JVM sanitisers | Loses Jazzer's biggest advantage over plain libFuzzer | Keep sanitisers enabled; disable selectively if false positives |
| Single-target campaign | Other targets not exercised | Run all @FuzzTest methods in CI |
Limitations
References
JVM sanitiser catalogue
Per Jazzer README, built-in detectors fire on security-relevant misuse:
| Sanitiser | What it catches |
|---|---|
| Deserialization | Untrusted ObjectInputStream / XStream / Kryo input → gadget execution |
| SSRF | URL constructed from untrusted input pointing at internal infrastructure |
| Path traversal | .. / encoded variants in file path arguments |
| OS command injection | Runtime.exec / ProcessBuilder with concatenated input |
| ReDoS | Catastrophic-backtracking regex constructed from untrusted input |
| LDAP injection | LDAP query string concatenation |
| Naming context | JNDI 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
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
| Tip | Why |
|---|---|
| Keep the target small | Faster iteration; clearer coverage |
| Avoid global state between runs | Cross-input contamination defeats coverage guidance |
| Use the full input | Don't if (Size < 100) return 0; unless the lib requires |
| Avoid expensive I/O / network in the target | Slows iterations |
Use FuzzedDataProvider for structured inputs | Splits 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:
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-pattern | Why it fails | Fix |
|---|---|---|
LLVMFuzzerTestOneInput with global state mutation | Cross-input contamination breaks coverage signal | Reset state per call or use LLVMFuzzerInitialize |
| Fuzz target without ASan + UBSan | Catches only crashes; 80%+ of bugs missed | Always compose with sanitisers |
| No corpus minimisation ever | Corpus grows unbounded; cycle time degrades | Weekly -merge=1 |
| Crash committed without minimisation | Large bug-report attachments | Always -minimize_crash=1 |
| Single huge fuzz target | Slow iterations; coverage attribution opaque | Split into multiple targets per function |
Ignoring -rss_limit_mb OOMs | False crash class | Set limit explicit; or disable allocator-related target paths |
| No dictionary for structured formats | Fuzzer slowly rediscovers grammar | Always supply -dict= for JSON / XML / SQL / proto |
Limitations
References
Full flag table
Per llvm.org/docs/LibFuzzer.html (opens in new window):
| Flag | Effect |
|---|---|
-max_total_time=N | Stop after N seconds |
-runs=N | Stop after N executions (-1 = infinite) |
-dict=path | Use dictionary file |
-seed=N | Random seed |
-fork=N | Run N parallel fork-mode workers |
-workers=N | Number of parallel worker processes |
-jobs=N | Total number of jobs to run across workers |
-merge=1 | Corpus minimisation mode |
-print_final_stats=1 | Print stats summary on exit |
-rss_limit_mb=N | RSS memory limit (default 2048) |
-timeout=N | Per-input timeout in seconds (default 1200) |
-only_ascii=1 | Restrict 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
View source (opens in new window)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
How to use
The five sanitisers
| Sanitiser | Detects (summary) | Build flag | Slowdown |
|---|---|---|---|
| ASan | heap / stack / global OOB, use-after-free, double-free | -fsanitize=address -fno-omit-frame-pointer -g | ~2x |
| UBSan | signed overflow, div-by-zero, null deref, misaligned access | -fsanitize=undefined -fno-sanitize-recover=all | ~10% |
| MSan | uninitialised memory reads | -fsanitize=memory -fno-omit-frame-pointer -fsanitize-memory-track-origins | 3x |
| TSan | data races, deadlocks, thread-safety violations | -fsanitize=thread -O1 -g | 5 - 15x |
| LSan | memory 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?
| Sanitiser | ASan | UBSan | MSan | TSan |
|---|---|---|---|---|
| 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:
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
| Language | ASan | UBSan | MSan | TSan | LSan |
|---|---|---|---|---|---|
| C / C++ (clang / GCC) | ✓ | ✓ | ✓ (clang) | ✓ | ✓ |
| Rust (nightly) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Go | partial (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-pattern | Why it fails | Fix |
|---|---|---|
| Fuzzing without sanitisers | Catches only crashes; misses 80%+ of memory bugs | Always build with ASan + UBSan minimum |
-fsanitize=address,memory together | MSan + ASan incompatible | Pick one; run separate binaries |
| MSan with non-MSan dependencies | False positives flood the report | Build all dependencies with MSan or skip MSan |
UBSan without -fno-sanitize-recover=all | UBSan logs but doesn't abort; fuzzer never sees the bug | Always add -fno-sanitize-recover=all |
ASan without -fno-omit-frame-pointer | Stack traces are useless | Always add -fno-omit-frame-pointer -g |
detect_leaks=0 in fuzz CI | Leak bugs go unnoticed | Default ASan settings (Linux LSan-enabled) |
| TSan + a non-thread-safe target | Slow + noisy; data races are everywhere | Pick targets where thread-safety claims are made |
Limitations
References
Per-sanitiser catalog
AddressSanitizer (ASan)
What it detects (per clang.llvm.org/docs/AddressSanitizer.html (opens in new window)):
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:...):
| Option | Effect |
|---|---|
detect_leaks=1 | Enable leak detection (default on Linux) |
detect_stack_use_after_return=0 | Disable use-after-return checks (faster) |
detect_container_overflow=0 | Disable container-overflow detection |
symbolize=0 | Disable online symbolization (use post-mortem) |
check_initialization_order=1 | Init-order checking |
halt_on_error=1 | Stop on first error |
abort_on_error=1 | SIGABRT 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):
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: