Skip to content
C++
Analysis & Formatting
Updated 2025-01-01T00:00:00.000Z

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.

bash
# 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 crash

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

cpp
// 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

bash
# 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.cpp

The -fsanitize=fuzzer flag links the libFuzzer runtime and replaces main() with libFuzzer's driver. You do not write a main() function.

Running libFuzzer

bash
# 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-abc123def456

Reading libFuzzer output

cpp
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-abc123def456

Key fields:

FieldMeaning
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
NEWInput added to corpus — new coverage found
REDUCEExisting corpus entry shrunk

Crash files are named crash-<sha1>. Feed them back to reproduce:

bash
./fuzz_parser crash-abc123def456
# prints the ASan stack trace, then exits non-zero

Fuzzing a parser — concrete example

Here is a complete fuzz target for a simple CSV parser:

cpp
// 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:

bash
# 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=4

AFL++

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

bash
# Ubuntu / Debian
sudo apt install afl++

# From source (for latest features)
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus && make && sudo make install

Instrumenting and running

bash
# 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)
bash
# 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 path

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

cpp
// 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;
}
bash
afl-clang-fast++ -o fuzz_persistent main_fuzz.cpp parser.cpp
afl-fuzz -i afl_in -o afl_out -- ./fuzz_persistent

Shared memory mode

For maximum speed, AFL++ can pass inputs via shared memory instead of files. Combine with persistent mode:

bash
# 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_persistent

Corpus management

A good corpus is the difference between finding bugs in minutes and finding nothing in days.

Seeding the corpus

bash
# 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.txt

Minimizing corpus entries (libFuzzer)

libFuzzer can merge a corpus and remove redundant entries:

bash
./fuzz_parser -merge=1 corpus_merged/ corpus/ extra_findings/
# corpus_merged/ now contains the minimal set covering the same edges

Minimizing a crash input

A crash file produced by fuzzing is often hundreds of bytes. Minimize it to the smallest input that still crashes:

bash
# 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

bash
# 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.

cpp
#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:

MethodReturns
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

bash
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

protobuf
// request.proto
syntax = "proto3";

message HttpRequest {
    string method = 1;
    string path   = 2;
    int32  content_length = 3;
    bytes  body   = 4;
}

Write the fuzz target

cpp
// 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());
}
bash
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.

bash
# 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.

bash
# Minimal OSS-Fuzz integration: add these files to your repo
# oss-fuzz/project.yaml
# oss-fuzz/Dockerfile
# oss-fuzz/build.sh
bash
# 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 || true

GitHub Actions — time-boxed libFuzzer run

yaml
# .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

yaml
# .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

FeaturelibFuzzerAFL++Honggfuzz
ModelIn-processFork (or persistent)In-process or fork
CompilerClang onlyGCC + Clang (afl-clang-fast)GCC + Clang
SpeedVery fast (in-process)Fast with persistent modeFast
QEMU / black-boxNoYes (QEMU, Frida)Yes (QEMU)
Coverage feedbackEdge + countersEdge + counters + CMPLOGBranch + block
Structured inputsFuzzedDataProvider, LPMFuzzedDataProvider, LPMLimited
Corpus formatDirectory of filesafl_out/queue/Directory of files
Crash deduplicationSHA1 filenameUnique crashes dirHash-based
OSS-Fuzz supportYes (primary)YesYes
Best forLibrary APIs, unit fuzzFull binaries, networksSanitizer-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:

bash
-fno-sanitize-recover=all

Global 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:

cpp
// 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 crash

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

bash
./fuzz_parser -merge=1 corpus_small/ corpus_big/
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z