LibFuzzer Guide: In-Process Coverage-Guided Fuzzing for C and C++

LibFuzzer Guide: In-Process Coverage-Guided Fuzzing for C and C++

LibFuzzer is LLVM's built-in coverage-guided fuzzer for C and C++. Unlike AFL++, which runs as a separate process, LibFuzzer runs inside your program's process — making it 10-20x faster. It's integrated directly into Clang, requires no external tools, and is used by Google's OSS-Fuzz project to continuously fuzz thousands of open-source projects. Writing a LibFuzzer target takes about 10 lines of code.

LibFuzzer vs AFL++

Aspect LibFuzzer AFL++
Architecture In-process External process
Speed 10,000–1M executions/sec 1,000–5,000 executions/sec
Setup Compile-time only Requires wrapper binary
Source required Yes No (QEMU mode)
Corpus sharing Manual Automatic (parallel mode)
OSS-Fuzz support Native Yes
Supported languages C, C++ C, C++, and more

Use LibFuzzer when:

  • You're fuzzing a library (not a standalone binary)
  • Maximum speed matters
  • You want OSS-Fuzz integration
  • You're already using Clang

Use AFL++ when:

  • You need to fuzz a binary without source
  • You want parallel fuzzing with automatic corpus sync
  • You're fuzzing a program that reads from files or stdin

Writing a Fuzz Target

A LibFuzzer target is a C function:

// fuzz_target.c
#include <stdint.h>
#include <stddef.h>
#include "my_library.h"

// Entry point: LibFuzzer calls this repeatedly with different inputs
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    // Create a null-terminated copy if needed
    char *buf = malloc(size + 1);
    if (!buf) return 0;
    memcpy(buf, data, size);
    buf[size] = '\0';
    
    // Call the function under test
    my_parse_function(buf, size);
    
    free(buf);
    return 0;  // Return 0 to tell LibFuzzer the input was processed
}

Rules for the fuzz target:

  1. Must accept (const uint8_t *data, size_t size) parameters
  2. Must return 0 (other values reserved for future use)
  3. Must be deterministic — same input should always produce same behavior
  4. Should not call exit(), abort(), or _exit()
  5. Global state should be reset at the start of each call (or initialized once)

C++ target:

// fuzz_json.cpp
#include <cstdint>
#include <cstddef>
#include <string>
#include "json_parser.hpp"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    std::string input(reinterpret_cast<const char*>(data), size);
    
    try {
        auto result = JsonParser::parse(input);
        // Additional invariant checks
        if (result.is_object()) {
            // Verify re-serialization is valid
            auto reserialized = result.dump();
            JsonParser::parse(reserialized);  // Should not throw
        }
    } catch (const JsonParseError&) {
        // Expected for invalid JSON
    }
    // Any other exception = bug
    
    return 0;
}

Building and Running

LibFuzzer is part of Clang — no separate installation needed.

Compile:

# With AddressSanitizer (highly recommended)
clang -O1 -g \
  -fsanitize=fuzzer,address,undefined \
  fuzz_target.c my_library.c -o fuzz_target

# Without ASAN (faster, but misses memory errors)
clang -O2 -g \
  -fsanitize=fuzzer \
  fuzz_target.c my_library.c -o fuzz_target

The -fsanitize=fuzzer flag links LibFuzzer and provides the main() function — you don't write one.

Run:

# Run with a corpus directory
mkdir corpus/
echo "valid input" > corpus/seed1.txt
./fuzz_target corpus/

# Run for a fixed time
./fuzz_target corpus/ -max_total_time=60  # 60 seconds

# Run with specific seed (reproduce a crash)
./fuzz_target crash-input.bin

Output:

INFO: Seed: 2374086805
INFO: Loaded 1 modules   (1234 inline 8-bit counters): 1234 [0x...]
INFO: Loaded 1 PC tables (1234 PCs): 1234 [0x...]
INFO:        1 files found in corpus/
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: A corpus is not provided, starting from an empty corpus
#2	INITED cov: 56 ft: 57 corp: 1/1b exec/s: 2 rss: 30Mb
#4	NEW    cov: 57 ft: 58 corp: 2/2b lim: 4 exec/s: 4 rss: 31Mb L: 1/1 MS: 2 ChangeByte-EraseBytes-
#512	pulse  cov: 71 ft: 82 corp: 12/32b lim: 8 exec/s: 512 rss: 34Mb

Key metrics:

  • cov: code coverage (edges hit)
  • ft: features (coverage + value profile data)
  • corp: corpus size (files/bytes)
  • exec/s: executions per second

Corpus Management

Merge and deduplicate corpus:

# Merge corpus from multiple runs, deduplicate
./fuzz_target -merge=1 corpus_merged/ corpus1/ corpus2/

Minimize corpus:

# Keep only minimal set that covers the same paths
./fuzz_target -merge=1 corpus_min/ corpus_all/

Minimize a crash input:

./fuzz_target -minimize_crash=1 -max_total_time=60 crash-input.bin

The minimized input reproduces the same crash with the smallest possible byte sequence — easier to understand and debug.

Sanitizers

Always compile with sanitizers. They convert undefined behavior into detectable crashes.

AddressSanitizer (ASan) — memory errors:

clang -fsanitize=fuzzer,address -O1 -g fuzz.c -o fuzz

Finds: buffer overflows, use-after-free, heap/stack corruption

UndefinedBehaviorSanitizer (UBSan) — undefined behavior:

clang -fsanitize=fuzzer,undefined -O1 -g fuzz.c -o fuzz

Finds: integer overflow, null pointer dereference, misaligned access

MemorySanitizer (MSan) — use of uninitialized memory:

clang -fsanitize=fuzzer,memory -O1 -g fuzz.c -o fuzz

Note: MSan requires all code (including stdlib) to be instrumented, which is complex. ASan + UBSan covers most cases.

Combine sanitizers:

clang -fsanitize=fuzzer,address,undefined -O1 -g fuzz.c -o fuzz

Custom Mutator

LibFuzzer's default mutations (bit flips, byte insertions, interesting values) work well for generic inputs. For structured inputs (protobuf, ASN.1, complex formats), a custom mutator generates valid-but-mutated structures:

// custom_mutator.cpp

// Called by LibFuzzer when it wants to mutate an input
extern "C" size_t LLVMFuzzerCustomMutator(
    uint8_t *data, size_t size, size_t max_size, unsigned int seed) {
    
    // Deserialize the input as a protobuf message
    MyProto msg;
    if (!msg.ParseFromArray(data, size)) {
        // If not valid proto, return a valid one
        msg.set_field("default");
    }
    
    // Apply structured mutations
    std::mt19937 rng(seed);
    switch (rng() % 3) {
        case 0: msg.set_field(std::string(rng() % 100, 'A')); break;
        case 1: msg.clear_field(); break;
        case 2: msg.set_count(rng()); break;
    }
    
    // Serialize back
    std::string serialized;
    msg.SerializeToString(&serialized);
    
    if (serialized.size() > max_size) return 0;
    memcpy(data, serialized.data(), serialized.size());
    return serialized.size();
}

This technique (structure-aware fuzzing) dramatically increases the depth of fuzzing for complex input formats.

Coverage Analysis

View which code paths LibFuzzer covered:

# Compile with coverage instrumentation
clang -fsanitize=fuzzer -fprofile-instr-generate -fcoverage-mapping \
  -O1 -g fuzz.c my_library.c -o fuzz_cov

# Run corpus through coverage-instrumented binary
LLVM_PROFILE_FILE="fuzz.profraw" ./fuzz_cov corpus/*

# Generate coverage report
llvm-profdata merge -sparse fuzz.profraw -o fuzz.profdata
llvm-cov show ./fuzz_cov -instr-profile=fuzz.profdata -format=html > coverage.html

The HTML report shows line-by-line coverage — useful for identifying which branches the fuzzer hasn't reached yet. Manually add seed inputs that exercise uncovered branches to accelerate exploration.

Dictionary-Guided Fuzzing

A dictionary of interesting tokens helps LibFuzzer generate meaningful mutations:

# sql.dict
"SELECT"
"INSERT INTO"
"DROP TABLE"
"' OR '1'='1"
"1; DROP TABLE users--"
"UNION SELECT"
./fuzz_target corpus/ -dict=sql.dict

LibFuzzer splices dictionary tokens into existing corpus inputs, generating inputs that are more likely to reach SQL parsing code.

Reproducing and Triaging Crashes

When LibFuzzer finds a crash:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000050
READ of size 4 at 0x602000000050 thread T0
    #0 0x55555555d2a3 in parse_header /src/parser.c:142:3
SUMMARY: AddressSanitizer: heap-buffer-overflow /src/parser.c:142 in parse_header
artifact_prefix='./'; Test unit written to ./crash-abc123def456
Base64: aW52YWxpZA==  # base64-encoded crash input

Reproduce:

# The crash input is saved automatically
./fuzz_target crash-abc123def456

# Decode base64 if needed
echo "aW52YWxpZA==" | base64 -d > crash.bin
./fuzz_target crash.bin

Minimize:

./fuzz_target -minimize_crash=1 -max_total_time=30 crash-abc123def456
# Outputs: minimized-from-crash-abc123def456

Debug with lldb/gdb:

lldb -- ./fuzz_target_no_sanitizer crash-abc123def456
run
bt  # Backtrace at crash point

Integration with OSS-Fuzz

If your project is open source, OSS-Fuzz runs your LibFuzzer targets continuously on Google's infrastructure for free.

Project structure:

oss-fuzz/projects/my-project/
  build.sh         # How to build the fuzz targets
  Dockerfile       # Container with dependencies
  project.yaml     # Project metadata

build.sh:

#!/bin/bash
set -ex

# Build the library
cd my-project
./configure --enable-static
make -j$(nproc)

# Build fuzz targets
for fuzzer in fuzz_parser fuzz_decompressor fuzz_crypto; do
    $CC $CFLAGS -I. $SRC/${fuzzer}.c \
        -o $OUT/${fuzzer} \
        $LIB_FUZZING_ENGINE ./libmyproject.a
done

OSS-Fuzz:

  • Runs your fuzzers continuously
  • Reports crashes to you via GitHub issues
  • Verifies fixes by running the crash input after your fix
  • Shows coverage reports
  • Has found 10,000+ bugs in projects like Chrome, OpenSSL, curl, and FFmpeg

LibFuzzer in CI

# .github/workflows/fuzz.yml
name: Fuzz Testing

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 4 * * *'  # Daily at 4am

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Clang
        run: |
          sudo apt-get install -y clang

      - name: Build fuzz targets
        run: |
          clang -fsanitize=fuzzer,address,undefined \
            -O1 -g \
            fuzz/fuzz_parser.c src/parser.c \
            -I include/ \
            -o fuzz_parser

      - name: Run fuzz tests (corpus regression)
        run: |
          # Run with saved corpus only (fast, for every PR)
          ./fuzz_parser fuzz/corpus/parser/ -max_total_time=30

      - name: Extended fuzz (scheduled only)
        if: github.event_name == 'schedule'
        run: |
          ./fuzz_parser fuzz/corpus/parser/ -max_total_time=3600

      - name: Save new corpus entries
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-corpus
          path: fuzz/corpus/

The corpus grows over time as LibFuzzer discovers new paths. Commit the corpus to your repository so findings aren't lost between runs.

Read more

Start now free