Fuzzing C++ Code
Fuzz testing C++ with libFuzzer and AFL++ — writing fuzz targets, corpus management, coverage-guided fuzzing, structured fuzzing with libprotobuf-mutator, and CI integration.
TL;DR
Fuzzing feeds pseudo-random byte sequences into your code and watches for crashes, sanitizer violations, and hangs. It reliably finds memory corruption bugs, integer overflows, and malformed-input crashes that unit tests miss — especially in parsers, serializers, codecs, and anything that touches untrusted data.
# Minimal libFuzzer target, compiled and run in three commands
clang++ -fsanitize=fuzzer,address -g -O1 -o fuzz_target fuzz_target.cpp
./fuzz_target corpus/ # run against an existing corpus dir
./fuzz_target crash-abc123 # reproduce a saved crashThe two dominant engines are libFuzzer (in-process, Clang-only, fast) and AFL++ (out-of-process fork model, compiler-agnostic, huge ecosystem). Use libFuzzer for unit-fuzz targets; use AFL++ when you need to fuzz a whole binary or can't modify source.
libFuzzer
Writing a fuzz target
A libFuzzer target is a single C function with a fixed signature. libFuzzer calls it repeatedly with mutated byte buffers and looks for crashes or sanitizer reports.
// fuzz_target.cpp
#include <cstdint>
#include <cstddef>
// The function under test (e.g., your parser)
bool ParseInput(const uint8_t* data, size_t size);
// libFuzzer entry point — called thousands of times per second
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
// Do NOT call exit(), abort(), or longjmp() except via a crash/assert.
// Do NOT use global mutable state that persists between calls.
ParseInput(data, size);
return 0; // 0 = keep going; non-zero = discard this input
}Rules for a well-behaved target:
- Must be deterministic for a given input.
- Must not call
exit()on expected bad inputs — let it throw or return an error. - Keep global state minimal; reinitialize any state inside the function.
- Avoid printing to stdout — it slows the fuzzer dramatically.
Compile flags
# Basic: fuzzer + ASan (catches heap/stack overflows, use-after-free)
clang++ -fsanitize=fuzzer,address \
-g -O1 -fno-omit-frame-pointer \
-o fuzz_parser fuzz_parser.cpp parser.cpp
# With UBSan as well (catches UB before it causes a crash)
clang++ -fsanitize=fuzzer,address,undefined \
-fno-sanitize-recover=undefined \
-g -O1 \
-o fuzz_parser fuzz_parser.cpp parser.cpp
# Coverage-only build (measure coverage without crash detection)
clang++ -fsanitize=fuzzer \
-fsanitize-coverage=trace-pc-guard \
-g -O1 \
-o fuzz_parser_cov fuzz_parser.cpp parser.cppThe -fsanitize=fuzzer flag links the libFuzzer runtime and replaces main() with libFuzzer's driver. You do not write a main() function.
Running libFuzzer
# Create a corpus directory with at least one seed input
mkdir -p corpus
echo '{}' > corpus/seed_empty_json.txt
# Run with corpus dir — libFuzzer reads seeds and writes new interesting inputs
./fuzz_parser corpus/
# Useful flags
./fuzz_parser corpus/ \
-max_len=4096 \ # discard inputs longer than 4096 bytes
-timeout=10 \ # treat runs > 10s as hangs
-jobs=8 \ # run 8 parallel workers
-workers=8 \
-max_total_time=3600 # stop after 1 hour
# Run a specific crash to reproduce it
./fuzz_parser crash-abc123def456Reading libFuzzer output
INFO: Seed: 3175592822
INFO: Loaded 1 modules (1234 inline 8-bit counters): 1234 [0x5555..., 0x5556...)
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: A corpus of 1 units, 6 b in total
#1 INITED cov: 42 ft: 53 corp: 1/6b exec/s: 0 rss: 28Mb
#128 NEW cov: 51 ft: 61 corp: 2/14b exec/s: 0 rss: 28Mb L: 8/8 MS: 1 InsertByte-
#512 NEW cov: 58 ft: 70 corp: 3/22b exec/s: 0 rss: 29Mb L: 8/8 MS: 3 ChangeBit-
...
==12345== ERROR: AddressSanitizer: heap-buffer-overflow
SUMMARY: AddressSanitizer: heap-buffer-overflow parser.cpp:87 in ParseToken
artifact_prefix='./'; Test unit written to ./crash-abc123def456Key fields:
| Field | Meaning |
|---|---|
cov: | Number of unique coverage edges seen so far |
ft: | Feature count (more granular than edges) |
corp: | Corpus size (count / total bytes) |
exec/s: | Executions per second |
NEW | Input added to corpus — new coverage found |
REDUCE | Existing corpus entry shrunk |
Crash files are named crash-<sha1>. Feed them back to reproduce:
./fuzz_parser crash-abc123def456
# prints the ASan stack trace, then exits non-zeroFuzzing a parser — concrete example
Here is a complete fuzz target for a simple CSV parser:
// fuzz_csv.cpp
#include <cstdint>
#include <cstddef>
#include <string>
#include <vector>
#include <stdexcept>
// The function under test
std::vector<std::vector<std::string>> ParseCSV(std::string_view input);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
// Convert raw bytes to a string_view — do NOT assume null termination
std::string_view sv(reinterpret_cast<const char*>(data), size);
try {
auto result = ParseCSV(sv);
// Optionally re-serialize and compare (differential fuzzing)
} catch (const std::exception&) {
// Expected — malformed input; don't crash the fuzzer
}
return 0;
}Build and seed:
# Seed corpus: valid CSV files from your test suite
mkdir -p corpus_csv
cp tests/fixtures/*.csv corpus_csv/
clang++ -fsanitize=fuzzer,address -g -O1 \
-o fuzz_csv fuzz_csv.cpp csv_parser.cpp
./fuzz_csv corpus_csv/ -max_len=65536 -jobs=4 -workers=4AFL++
AFL++ is a fork of AFL with many improvements: better mutation strategies, persistent mode, QEMU/Frida support for black-box targets, and CMPLOG for solving comparisons automatically.
Installation
# Ubuntu / Debian
sudo apt install afl++
# From source (for latest features)
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus && make && sudo make installInstrumenting and running
# Compile with AFL++ instrumentation
export CC=afl-clang-fast
export CXX=afl-clang-fast++
./configure --disable-shared # for autotools projects
make -j$(nproc)
# Or for CMake projects
cmake -B build \
-DCMAKE_C_COMPILER=afl-clang-fast \
-DCMAKE_CXX_COMPILER=afl-clang-fast++ \
-DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j$(nproc)# Run afl-fuzz
mkdir -p afl_in afl_out
echo '{"key":"value"}' > afl_in/seed.json
afl-fuzz -i afl_in -o afl_out -- ./build/myparser @@
# ^^ replaced with input file pathPersistent mode (much faster)
AFL++'s fork model has overhead per execution. Persistent mode runs the target in a loop inside the same process — 10-100x faster.
// main_fuzz.cpp — persistent mode shim
#include <unistd.h>
#include <string.h>
// AFL++ persistent mode macro
__AFL_FUZZ_INIT();
int main() {
// Optional: set up any global state here (once)
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
unsigned char* buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(10000)) { // loop up to 10000 times before fork
size_t len = __AFL_FUZZ_TESTCASE_LEN;
ParseInput(buf, len); // your function under test
}
return 0;
}afl-clang-fast++ -o fuzz_persistent main_fuzz.cpp parser.cpp
afl-fuzz -i afl_in -o afl_out -- ./fuzz_persistentShared memory mode
For maximum speed, AFL++ can pass inputs via shared memory instead of files. Combine with persistent mode:
# Tell AFL++ to use shared memory (enabled automatically when __AFL_FUZZ_INIT is used)
AFL_USE_ASAN=1 afl-fuzz -i afl_in -o afl_out -- ./fuzz_persistentCorpus management
A good corpus is the difference between finding bugs in minutes and finding nothing in days.
Seeding the corpus
# Start from real-world inputs
cp tests/fixtures/*.json corpus/
cp tests/fixtures/*.xml corpus/
# Or generate minimal valid inputs by hand
printf '{"a":1}' > corpus/min_json.txt
printf '<r/>' > corpus/min_xml.txtMinimizing corpus entries (libFuzzer)
libFuzzer can merge a corpus and remove redundant entries:
./fuzz_parser -merge=1 corpus_merged/ corpus/ extra_findings/
# corpus_merged/ now contains the minimal set covering the same edgesMinimizing a crash input
A crash file produced by fuzzing is often hundreds of bytes. Minimize it to the smallest input that still crashes:
# libFuzzer: minimize a crash
./fuzz_parser -minimize_crash=1 -max_total_time=60 crash-abc123
# AFL++: minimize a crash
afl-tmin -i crash-abc123 -o crash-min -- ./build/myparser @@Measuring coverage
# Build with coverage instrumentation
clang++ -fprofile-instr-generate -fcoverage-mapping \
-g -O1 -o fuzz_cov fuzz_parser.cpp parser.cpp
# Run against corpus to collect profiles
LLVM_PROFILE_FILE="corpus-%m.profraw" ./fuzz_cov corpus/*
# Merge profiles and generate report
llvm-profdata merge -sparse corpus-*.profraw -o corpus.profdata
llvm-cov report ./fuzz_cov -instr-profile=corpus.profdata
llvm-cov show ./fuzz_cov -instr-profile=corpus.profdata \
-format=html -output-dir=coverage_report/Structured fuzzing with FuzzedDataProvider
Raw bytes are great for binary formats but awkward for APIs that take typed arguments. FuzzedDataProvider (part of libFuzzer's support library) consumes bytes from the fuzzer and converts them to typed values.
#include <fuzzer/FuzzedDataProvider.h>
#include <cstdint>
#include <string>
void ProcessRequest(int status_code, const std::string& body, bool compress);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
FuzzedDataProvider fdp(data, size);
// Consume typed values — each call advances the internal cursor
int status = fdp.ConsumeIntegralInRange<int>(100, 599);
bool compress = fdp.ConsumeBool();
// Consume remaining bytes as a string
std::string body = fdp.ConsumeRemainingBytesAsString();
ProcessRequest(status, body, compress);
return 0;
}Useful FuzzedDataProvider methods:
| Method | Returns |
|---|---|
ConsumeIntegral<T>() | Random integer of type T |
ConsumeIntegralInRange<T>(min, max) | Integer in [min, max] |
ConsumeFloatingPoint<T>() | Float or double |
ConsumeBool() | true or false |
ConsumeBytes<T>(n) | std::vector<T> of n bytes |
ConsumeRandomLengthString(max) | std::string up to max bytes |
ConsumeRemainingBytesAsString() | All remaining bytes as std::string |
remaining_bytes() | How many bytes are left |
Structured fuzzing with libprotobuf-mutator
For APIs that naturally take structured inputs, libprotobuf-mutator (LPM) generates valid protobuf messages as fuzz inputs. The fuzzer mutates fields individually rather than raw bytes — it can explore a much larger semantic space.
Setup
git clone https://github.com/google/libprotobuf-mutator
cd libprotobuf-mutator && mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DLIB_PROTO_MUTATOR_DOWNLOAD_PROTOBUF=ON
make -j$(nproc)Define a protobuf schema
// request.proto
syntax = "proto3";
message HttpRequest {
string method = 1;
string path = 2;
int32 content_length = 3;
bytes body = 4;
}Write the fuzz target
// fuzz_http.cpp
#include "libprotobuf-mutator/src/libfuzzer/libfuzzer_macro.h"
#include "request.pb.h"
void HandleRequest(const std::string& method,
const std::string& path,
int content_length,
const std::string& body);
DEFINE_PROTO_FUZZER(const HttpRequest& req) {
HandleRequest(req.method(), req.path(),
req.content_length(), req.body());
}clang++ -fsanitize=fuzzer,address -g -O1 \
-I libprotobuf-mutator \
-o fuzz_http \
fuzz_http.cpp request.pb.cc \
-L libprotobuf-mutator/build/src/libfuzzer -lprotobuf-mutator-libfuzzer \
-L libprotobuf-mutator/build/src -lprotobuf-mutator \
$(pkg-config --libs protobuf)
./fuzz_http corpus_http/Coverage-guided fuzzing internals
libFuzzer and AFL++ both use coverage feedback to guide mutation: an input is kept in the corpus only if it exercises a new edge in the control-flow graph. The compiler instruments every branch.
# Trace-PC-guard: the standard coverage instrumentation for libFuzzer
clang++ -fsanitize=fuzzer -fsanitize-coverage=trace-pc-guard \
-g -O1 -o fuzz_target fuzz_target.cpp
# Inline 8-bit counters (more precise, slightly more memory)
clang++ -fsanitize=fuzzer \
-fsanitize-coverage=inline-8bit-counters,indirect-calls \
-g -O1 -o fuzz_target fuzz_target.cpp
# CMPLOG (AFL++ extension): instrument comparisons so the mutator
# can learn magic bytes, checksums, and format constants automatically
AFL_USE_CMPLOG=1 afl-clang-fast++ -o fuzz_cmplog fuzz_target.cpp
afl-fuzz -c ./fuzz_cmplog -i in/ -o out/ -- ./fuzz_target @@The mutation strategy is also coverage-guided: mutations that increase coverage are weighted more heavily in future rounds.
CI integration
OSS-Fuzz
For open-source projects, OSS-Fuzz provides free continuous fuzzing infrastructure. It runs your targets 24/7, reports bugs, and tracks coverage.
# Minimal OSS-Fuzz integration: add these files to your repo
# oss-fuzz/project.yaml
# oss-fuzz/Dockerfile
# oss-fuzz/build.sh# build.sh — called by OSS-Fuzz's Clang environment
#!/bin/bash
cmake -B build \
-DCMAKE_C_COMPILER=$CC \
-DCMAKE_CXX_COMPILER=$CXX \
-DCMAKE_C_FLAGS="$CFLAGS" \
-DCMAKE_CXX_FLAGS="$CXXFLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="$LIB_FUZZING_ENGINE"
cmake --build build -j$(nproc)
cp build/fuzz_parser $OUT/
cp corpus/*.txt $OUT/fuzz_parser_seed_corpus.zip 2>/dev/null || trueGitHub Actions — time-boxed libFuzzer run
# .github/workflows/fuzz.yml
name: Fuzz
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * *' # nightly at 03:00 UTC
jobs:
libfuzzer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Clang
run: sudo apt-get install -y clang
- name: Restore corpus cache
uses: actions/cache@v4
with:
path: corpus/
key: fuzz-corpus-${{ github.ref_name }}
restore-keys: fuzz-corpus-
- name: Build fuzz target
run: |
clang++ -fsanitize=fuzzer,address,undefined \
-fno-sanitize-recover=all \
-g -O1 \
-o fuzz_parser \
fuzz/fuzz_parser.cpp src/parser.cpp
- name: Seed corpus
run: |
mkdir -p corpus
cp tests/fixtures/*.json corpus/ 2>/dev/null || true
- name: Run fuzzer (5 minutes)
run: |
./fuzz_parser corpus/ \
-max_total_time=300 \
-jobs=2 -workers=2 \
-artifact_prefix=artifacts/
- name: Upload crashes
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: artifacts/
- name: Save corpus cache
uses: actions/cache/save@v4
if: always()
with:
path: corpus/
key: fuzz-corpus-${{ github.ref_name }}-${{ github.run_id }}GitLab CI
# .gitlab-ci.yml
fuzz:
image: clang:latest
stage: test
script:
- clang++ -fsanitize=fuzzer,address -g -O1
-o fuzz_parser fuzz/fuzz_parser.cpp src/parser.cpp
- mkdir -p corpus && cp tests/fixtures/*.json corpus/ || true
- ./fuzz_parser corpus/ -max_total_time=300 -artifact_prefix=artifacts/
artifacts:
when: on_failure
paths:
- artifacts/
cache:
key: fuzz-corpus-$CI_COMMIT_REF_SLUG
paths:
- corpus/Comparison: libFuzzer vs AFL++ vs Honggfuzz
| Feature | libFuzzer | AFL++ | Honggfuzz |
|---|---|---|---|
| Model | In-process | Fork (or persistent) | In-process or fork |
| Compiler | Clang only | GCC + Clang (afl-clang-fast) | GCC + Clang |
| Speed | Very fast (in-process) | Fast with persistent mode | Fast |
| QEMU / black-box | No | Yes (QEMU, Frida) | Yes (QEMU) |
| Coverage feedback | Edge + counters | Edge + counters + CMPLOG | Branch + block |
| Structured inputs | FuzzedDataProvider, LPM | FuzzedDataProvider, LPM | Limited |
| Corpus format | Directory of files | afl_out/queue/ | Directory of files |
| Crash deduplication | SHA1 filename | Unique crashes dir | Hash-based |
| OSS-Fuzz support | Yes (primary) | Yes | Yes |
| Best for | Library APIs, unit fuzz | Full binaries, networks | Sanitizer-heavy targets |
When to use which
- libFuzzer: you control the source; target is a library or parser; you want the tightest integration with ASan/UBSan/MSan.
- AFL++: you need to fuzz a full binary (stdin/file input); you want CMPLOG for format magic bytes; you need QEMU mode for closed-source targets.
- Honggfuzz: you want hardware performance counters (
perf) for coverage; you need NetDriver for network fuzzing.
Common pitfalls
Forgetting -fno-sanitize-recover=all with UBSan
Without this flag, UBSan reports the error but continues execution. The fuzzer never sees a crash and won't save the input. Always add:
-fno-sanitize-recover=allGlobal state corruption between iterations
libFuzzer calls LLVMFuzzerTestOneInput in a loop. If your parser stores state in a global or static variable and doesn't reset it, later iterations see corrupted state. Reinitialize inside the target or use a class with RAII.
Ignoring all exceptions too broadly
Catching ... silences genuine programming errors. Catch only expected exceptions:
// Bad — hides bugs
try { Parse(data, size); } catch (...) {}
// Good
try {
Parse(data, size);
} catch (const ParseError&) {
// expected malformed-input path
}
// let std::bad_alloc, std::logic_error, etc. propagate and crashCorpus bloat
Without periodic merge, the corpus grows unboundedly and slows the fuzzer (more entries = more time re-running known inputs). Merge before committing corpus to the repo.
./fuzz_parser -merge=1 corpus_small/ corpus_big/