LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Fuzzing for Developers: Finding Bugs Machines Can't Ignore

securitytestingfuzzinggocrustcivulnerability-research

Fuzzing for Developers: Finding Bugs Machines Can’t Ignore

Fuzzing is one of the most effective techniques for finding security vulnerabilities and correctness bugs — and one of the most underused by application developers. The security community has known this for decades: Google’s OSS-Fuzz program has found over 10,000 vulnerabilities in open source projects since 2016. Heartbleed, Shellshock, and countless critical CVEs in media parsers, network protocols, and compression libraries were all found or could have been found by fuzzing.

The reason developers don’t fuzz more isn’t technical difficulty — modern fuzzing tools are remarkably approachable. It’s unfamiliarity. This guide fixes that.

You’ll learn how coverage-guided fuzzers work, how to write effective fuzz targets in Go, C/C++, and Rust, how to use AFL++ and libFuzzer, how to run fuzzing in CI, and how to triage and fix the crashes your fuzzer finds.


How Coverage-Guided Fuzzing Works

A naïve fuzzer generates random inputs and throws them at a program. This finds obvious bugs but misses most of the interesting code paths — a random byte stream will never produce a valid JPEG header, so the JPEG parser’s complex decoding logic never executes.

Coverage-guided fuzzing solves this with a feedback loop:

┌──────────────────────────────────────────────────────┐
│                Coverage-Guided Fuzzer                │
│                                                      │
│  Corpus (interesting inputs)                         │
│       │                                              │
│       ▼                                              │
│  Mutate input (bit flips, byte substitutions,        │
│                splicing, structure-aware mutations)  │
│       │                                              │
│       ▼                                              │
│  Run target with mutated input                       │
│       │                                              │
│       ├── CRASH? → Save to crash corpus, report      │
│       │                                              │
│       ├── New coverage? → Add to corpus, keep        │
│       │   (This input reached new code — it's        │
│       │    interesting! Mutate it more.)             │
│       │                                              │
│       └── No new coverage → Discard                  │
└──────────────────────────────────────────────────────┘

The fuzzer instruments the target binary with coverage hooks — either at compile time (with LLVM’s SanitizerCoverage) or at runtime (with QEMU for black-box targets). Every new branch, basic block, or edge reached by a test case gets recorded. Inputs that reach previously unseen code are kept in the corpus and mutated further.

This feedback loop means the fuzzer doesn’t waste time on random garbage. It learns the shape of valid inputs by stumbling across them, then explores variations systematically.

What fuzzers find

  • Memory safety bugs (C/C++): buffer overflows, use-after-free, heap corruption, stack overflows
  • Panics and crashes (Go, Rust): index out of bounds, nil dereference, integer overflow
  • Logic bugs: infinite loops, incorrect output for edge-case inputs, integer truncation
  • Denial of service: inputs that cause O(n²) behavior, excessive memory allocation, algorithmic complexity attacks
  • Parser differentials: two parsers that should agree on an input format but don’t

Fuzzing in Go with go test -fuzz

Go 1.18 introduced native fuzzing support directly in the go test framework. No external tools needed.

Writing a fuzz target

A fuzz target is a function named FuzzXxx that takes *testing.F:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// parser/parser_test.go
package parser_test

import (
    "testing"
    "unicode/utf8"

    "github.com/myorg/myapp/parser"
)

// Seed corpus: representative inputs the fuzzer starts from.
// These should cover valid cases, edge cases, and inputs that exercise
// different code paths.
func FuzzParseConfig(f *testing.F) {
    // Seed with known-good inputs
    f.Add([]byte(`{"version": 1, "name": "test"}`))
    f.Add([]byte(`{}`))
    f.Add([]byte(`{"version": 0}`))
    f.Add([]byte(``))

    f.Fuzz(func(t *testing.T, data []byte) {
        // The fuzzer will mutate 'data' and call this function millions of times.
        // This function must NEVER panic for valid reasons — only for bugs.

        // Invariant 1: Parse must not panic.
        cfg, err := parser.ParseConfig(data)
        if err != nil {
            // Errors are fine — invalid input should return an error, not panic.
            return
        }

        // Invariant 2: Re-serializing a parsed config must produce valid UTF-8.
        serialized := cfg.Serialize()
        if !utf8.Valid(serialized) {
            t.Errorf("Serialize produced invalid UTF-8 for input: %q", data)
        }

        // Invariant 3: Round-trip: parse → serialize → parse must be consistent.
        cfg2, err := parser.ParseConfig(serialized)
        if err != nil {
            t.Errorf("Re-parse of serialized config failed: %v\nOriginal: %q\nSerialized: %q",
                err, data, serialized)
        }

        // Invariant 4: Two parses of the same input must return the same result.
        if cfg.Version != cfg2.Version {
            t.Errorf("Version mismatch: %d vs %d", cfg.Version, cfg2.Version)
        }
    })
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// More complex fuzz target: HTTP request parser
func FuzzParseHTTPRequest(f *testing.F) {
    f.Add([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
    f.Add([]byte("POST /api/data HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello"))

    f.Fuzz(func(t *testing.T, data []byte) {
        req, err := parser.ParseHTTPRequest(data)
        if err != nil {
            return  // Invalid input — acceptable
        }

        // Invariants for any successfully parsed request:
        if req.Method == "" {
            t.Error("Parsed request has empty method")
        }
        if req.ContentLength > 0 && len(req.Body) > int(req.ContentLength) {
            t.Errorf("Body (%d bytes) exceeds Content-Length (%d)",
                len(req.Body), req.ContentLength)
        }
    })
}

Running the fuzzer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Run for 1 minute
go test -fuzz=FuzzParseConfig -fuzztime=1m ./parser/

# Run indefinitely (until crash or Ctrl-C)
go test -fuzz=FuzzParseConfig ./parser/

# Run with a specific corpus directory
go test -fuzz=FuzzParseConfig -test.fuzzcachedir=./fuzz-corpus ./parser/

# Run normal tests only (using seed corpus, no fuzzing)
go test ./parser/   # Runs FuzzParseConfig as a normal test with seed inputs

When the fuzzer finds a crash, it saves the failing input to testdata/fuzz/FuzzParseConfig/:

--- FAIL: FuzzParseConfig (3.14s)
    parser_test.go:28: Serialize produced invalid UTF-8 for input: "\xff\xfe{\"version\":1}"

Failing input written to testdata/fuzz/FuzzParseConfig/b3c4d5e6f7a8b9c0

The failing input is now part of your regression test suite — it runs every time you run go test:

1
2
# Reproduce the crash
go test -run=FuzzParseConfig/b3c4d5e6f7a8b9c0 ./parser/

Go fuzz target patterns for common components

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Fuzzing a codec: encode → decode must round-trip
func FuzzCodecRoundTrip(f *testing.F) {
    f.Add([]byte("hello world"))
    f.Add([]byte{0x00, 0xFF, 0x80})
    f.Add([]byte{})

    f.Fuzz(func(t *testing.T, original []byte) {
        encoded := mycodec.Encode(original)
        decoded, err := mycodec.Decode(encoded)
        if err != nil {
            t.Fatalf("Decode failed on encoded output: %v\nInput: %q\nEncoded: %q",
                err, original, encoded)
        }
        if !bytes.Equal(original, decoded) {
            t.Fatalf("Round-trip mismatch\nInput:   %q\nDecoded: %q", original, decoded)
        }
    })
}

// Fuzzing a cryptographic primitive: constant-time comparison
func FuzzHMACVerify(f *testing.F) {
    key := []byte("test-key-32-bytes-for-hmac-sha256")
    f.Add([]byte("valid message"), []byte("invalid-sig"))

    f.Fuzz(func(t *testing.T, message, sig []byte) {
        // Should never panic, regardless of input
        _ = myauth.VerifyHMAC(key, message, sig)
    })
}

// Fuzzing SQL query builder: must never produce injection
func FuzzQueryBuilder(f *testing.F) {
    f.Add("user@example.com", "password123", 1)

    f.Fuzz(func(t *testing.T, email, password string, page int) {
        query, args := mydb.BuildUserQuery(email, password, page)

        // Invariant: query must use parameterized form, not inline values
        if strings.Contains(query, email) {
            t.Errorf("Email value inlined in query: %s", query)
        }
        _ = args  // args should contain the values
    })
}

libFuzzer: C, C++, and LLVM-based Languages

libFuzzer is built into LLVM and powers fuzzing for C, C++, Rust, and any language with LLVM-based compilation.

Writing a libFuzzer target (C/C++)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// fuzz_json_parser.c
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include "my_json_parser.h"

// The fuzz entry point — libFuzzer calls this with mutated data
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    // Never modify data or size.
    // Return 0 always — returning non-zero tells libFuzzer to skip this input.

    // Create a null-terminated copy (many parsers need this)
    char *input = (char *)malloc(size + 1);
    if (!input) return 0;
    memcpy(input, data, size);
    input[size] = '\0';

    // Parse — should never crash
    json_value_t *result = json_parse(input);
    if (result != NULL) {
        // If parse succeeded, test invariants
        char *reserialized = json_serialize(result);

        // Invariant: re-serialization must be valid JSON
        json_value_t *reparsed = json_parse(reserialized);
        if (reparsed == NULL) {
            // Bug! Successfully parsed and serialized, but re-parse fails.
            // libFuzzer will save this input as a crash.
            abort();
        }
        json_free(reparsed);
        free(reserialized);
        json_free(result);
    }

    free(input);
    return 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// fuzz_image_decoder.cpp — fuzzing a C++ image library
#include <cstdint>
#include <cstddef>
#include <vector>
#include "my_image_lib.hpp"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    std::vector<uint8_t> input(data, data + size);

    try {
        // AddressSanitizer (enabled via -fsanitize=address) will catch:
        // - Buffer overflows
        // - Use-after-free
        // - Heap corruption
        auto image = ImageDecoder::decode(input);

        // Test invariants on successfully decoded images
        if (image.width > 0 && image.height > 0) {
            // Pixel buffer must be exactly width * height * channels bytes
            size_t expected = (size_t)image.width * image.height * image.channels;
            if (image.pixels.size() != expected) {
                // Logic bug: pixel buffer wrong size
                __builtin_trap();  // Causes a crash that libFuzzer will save
            }
        }
    } catch (const std::exception &e) {
        // Expected exceptions (invalid format) are fine
    } catch (...) {
        // Unknown exception type is a bug
        __builtin_trap();
    }

    return 0;
}

Building with sanitizers

The real power of libFuzzer comes from combining it with sanitizers that turn silent memory corruption into immediate crashes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Makefile
CLANG = clang
CLANGPP = clang++
FUZZ_FLAGS = -g -O1 -fsanitize=fuzzer,address,undefined

# Build the fuzz target
fuzz_json_parser: fuzz_json_parser.c my_json_parser.c
	$(CLANG) $(FUZZ_FLAGS) -o $@ $^

fuzz_image_decoder: fuzz_image_decoder.cpp my_image_lib.cpp
	$(CLANGPP) $(FUZZ_FLAGS) -o $@ $^

# Run the fuzzer
run-json-fuzz:
	./fuzz_json_parser \
		-max_total_time=3600 \    # Run for 1 hour
		-timeout=10 \             # Kill inputs taking > 10 seconds
		-max_len=65536 \          # Max input size: 64KB
		-jobs=8 \                 # Parallel fuzzing jobs
		corpus/json/              # Corpus directory

# Minimize a crash to the smallest reproducing input
minimize-crash:
	./fuzz_json_parser \
		-minimize_crash=1 \
		-exact_artifact_path=crash_minimized.bin \
		crash_to_minimize.bin

Sanitizer combination matrix

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# AddressSanitizer (ASan): heap/stack/global buffer overflows, use-after-free
-fsanitize=address

# UndefinedBehaviorSanitizer (UBSan): integer overflow, null dereference, misaligned access
-fsanitize=undefined

# MemorySanitizer (MSan): reads of uninitialized memory (requires full recompile of deps)
-fsanitize=memory

# ThreadSanitizer (TSan): data races (can't combine with ASan/MSan)
-fsanitize=thread

# Best combination for most fuzz targets:
-fsanitize=fuzzer,address,undefined

# For finding leaks too:
-fsanitize=fuzzer,address,leak,undefined
ASAN_OPTIONS=detect_leaks=1

AFL++: Fuzzing Without Source Code (and More)

AFL++ (American Fuzzy Lop plus plus) is the successor to AFL with better mutation strategies, faster execution, and support for fuzzing binaries you can’t recompile.

Source-available targets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Install AFL++
apt-get install afl++
# or build from source: github.com/AFLplusplus/AFLplusplus

# Instrument the target with AFL's compiler wrapper
export CC=afl-clang-fast
export CXX=afl-clang-fast++
./configure --disable-shared
make

# Create corpus directory with initial inputs
mkdir -p corpus/valid corpus/findings

# Put your seed inputs in corpus/valid:
echo '{"version": 1}' > corpus/valid/seed1.json
echo '{}' > corpus/valid/seed2.json

# Start fuzzing
afl-fuzz \
    -i corpus/valid \      # Input corpus
    -o corpus/findings \   # Output: crashes, hangs, queue
    -m 512 \               # Memory limit per process (MB)
    -t 1000 \              # Timeout per execution (ms)
    -- ./my_json_parser @@  # @@ is replaced with the fuzz input file path

AFL++ for binary-only targets (QEMU mode)

1
2
3
4
5
6
7
8
# Fuzz a binary you can't recompile (proprietary library, binary-only dependency)
# QEMU mode instruments at runtime — ~2-5x slower than compile-time instrumentation

afl-fuzz \
    -Q \                   # QEMU mode
    -i corpus/valid \
    -o corpus/findings \
    -- ./proprietary_parser @@

AFL++ persistent mode (dramatically faster)

Forking a new process for each test case is expensive. Persistent mode loops within a single process:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// target_persistent.c
#include "my_parser.h"
#include <unistd.h>

// AFL persistent mode: run the target function in a loop without forking
__AFL_FUZZ_INIT();

int main(void) {
    // Must call __AFL_INIT() before the fuzzing loop
    __AFL_INIT();

    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;

    while (__AFL_LOOP(10000)) {  // Loop up to 10,000 times per process lifetime
        size_t len = __AFL_FUZZ_TESTCASE_LEN;

        // Call your target function directly — no file I/O
        parse_data(buf, len);
    }

    return 0;
}
1
2
3
4
5
# Compile with persistent mode support
afl-clang-fast -o target_persistent target_persistent.c my_parser.c

# Persistent mode is typically 5-10x faster than fork mode
afl-fuzz -i corpus/ -o findings/ -- ./target_persistent

AFL++ parallel fuzzing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Master instance (with -M)
afl-fuzz -i corpus/ -o findings/ -M fuzzer01 -- ./target @@

# Secondary instances (with -S) — different mutation strategies
afl-fuzz -i corpus/ -o findings/ -S fuzzer02 -- ./target @@
afl-fuzz -i corpus/ -o findings/ -S fuzzer03 -- ./target @@
afl-fuzz -i corpus/ -o findings/ -S fuzzer04 -- ./target @@

# Monitor all instances
afl-whatsup findings/

Reading AFL++ output

            american fuzzy lop ++4.05a {fuzzer01} (target) [explore]
┌─ process timing ─────────────────────────────────┬─ overall results ─────┐
│        run time : 0 days, 2 hrs, 14 min, 32 sec  │  cycles done : 23     │
│   last new find : 0 days, 0 hrs, 0 min, 15 sec   │ corpus count : 847    │
│last saved crash : 0 days, 0 hrs, 12 min, 8 sec   │saved crashes : 3      │
│ last saved hang : none seen yet                   │  saved hangs : 0      │
├─ cycle progress ──────────────────────────────────┴───────────────────────┤
│  now processing : 412.2 (48.6%)   │ favored items : 89 (10.5%)           │
│  runs tsh. exec : 0 (0.00%)       │  new edges on : 89 (10.5%)           │
├─ map coverage ────────────────────┬───────────────────────────────────────┤
│    map density : 3.24% / 4.11%    │ count coverage : 4.14 bits/tuple      │
├─ findings in depth ───────────────┴───────────────────────────────────────┤
│ favored paths : 89 (10.50%)       │   new edges on : 89 (10.50%)         │
│  total paths  : 847               │ total crashes  : 3                   │
├─ fuzzing strategy yields ─────────────────────────────────────────────────┤
│   bit flips : 28/2.09k, 13/2.09k, 9/2.09k                               │
│  byte flips : 4/261, 0/257, 0/253                                        │
│ arithmetics : 4/14.5k, 1/3.88k, 0/448                                   │
│  known ints : 1/1.60k, 0/6.77k, 0/4.52k                                 │
│  dictionary : 0/0, 0/0, 0/0, 0/0                                        │
│havoc/splice : 401/61.9k, 0/0                                             │
└───────────────────────────────────────────────────────────────────────────┘

Key numbers to watch:

  • saved crashes: Bugs found — investigate immediately
  • corpus count: Growing means the fuzzer is exploring new code
  • cycles done: Full passes through the corpus — higher is better
  • last new find: Time since last interesting input — if this is hours, consider restarting or adding new seed inputs

Fuzzing in Rust with cargo-fuzz

Rust’s memory safety prevents most memory corruption bugs, but fuzzers still find logic bugs, panics (slice index out of bounds, integer overflow in debug mode), and algorithmic complexity issues.

1
2
3
4
5
6
7
8
# Install cargo-fuzz
cargo install cargo-fuzz

# Initialize fuzzing in your Rust project
cargo fuzz init

# Create a new fuzz target
cargo fuzz add fuzz_parse_config
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// fuzz/fuzz_targets/fuzz_parse_config.rs
#![no_main]

use libfuzzer_sys::fuzz_target;
use my_crate::config::Config;

fuzz_target!(|data: &[u8]| {
    // If the fuzzer can construct data as a &str (valid UTF-8), test string parsing
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = Config::from_str(s);  // Must not panic
    }
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// More sophisticated: use structured fuzzing with arbitrary
// cargo add arbitrary --features derive

use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;

#[derive(Arbitrary, Debug)]
struct FuzzInput {
    key: String,
    value: Vec<u8>,
    ttl_seconds: u32,
    flags: u8,
}

fuzz_target!(|input: FuzzInput| {
    // The fuzzer generates structured inputs, not just raw bytes
    // This is much more effective for complex data structures

    let mut cache = my_crate::Cache::new();

    // These operations must never panic
    cache.set(&input.key, &input.value, input.ttl_seconds);

    if let Some(val) = cache.get(&input.key) {
        // Invariant: retrieved value must equal what was stored
        // (ignoring TTL expiry edge case)
        assert_eq!(val, &input.value[..],
            "Cache returned wrong value for key {:?}", input.key);
    }
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Run the fuzzer
cargo fuzz run fuzz_parse_config

# Run with a time limit
cargo fuzz run fuzz_parse_config -- -max_total_time=3600

# Run with more parallel jobs
cargo fuzz run fuzz_parse_config -- -jobs=8

# Minimize a crash
cargo fuzz tmin fuzz_parse_config <crash-file>

# Generate a coverage report
cargo fuzz coverage fuzz_parse_config

Structured Fuzzing: Beyond Raw Bytes

Raw byte fuzzing is inefficient for inputs with complex structure (protocols, binary formats with checksums, deeply nested data). Structured fuzzing generates valid structure and fuzzes the semantics.

FuzzedDataProvider in C++

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// fuzz_http_client.cpp
#include <fuzzer/FuzzedDataProvider.h>
#include "my_http_client.hpp"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    FuzzedDataProvider fdp(data, size);

    // Generate structured input from fuzz data
    std::string method = fdp.PickValueInArray({"GET", "POST", "PUT", "DELETE", "PATCH"});
    std::string path = fdp.ConsumeRandomLengthString(256);
    uint16_t port = fdp.ConsumeIntegralInRange<uint16_t>(1, 65535);
    bool use_tls = fdp.ConsumeBool();
    std::string body = fdp.ConsumeRemainingBytesAsString();

    HttpClient client;
    try {
        auto resp = client.request({
            .method = method,
            .url = (use_tls ? "https" : "http") + ("://localhost:" + std::to_string(port)) + path,
            .body = body,
            .timeout_ms = 100,
        });
        (void)resp;
    } catch (const NetworkException &) {
        // Expected — connections will fail
    } catch (...) {
        // Unexpected exception type
        __builtin_trap();
    }

    return 0;
}

Grammar-based fuzzing with Gramatron/Nautilus

For protocols and languages with formal grammars:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Using Hypothesis for Python (property-based testing with fuzzing characteristics)
from hypothesis import given, settings, HealthCheck
from hypothesis import strategies as st
import my_module

# Composite strategy that builds valid-looking config objects
config_strategy = st.fixed_dictionaries({
    "version": st.integers(min_value=0, max_value=100),
    "name": st.text(max_size=255),
    "tags": st.lists(st.text(max_size=50), max_size=20),
    "settings": st.dictionaries(
        st.text(max_size=50),
        st.one_of(st.integers(), st.text(), st.booleans()),
        max_size=10,
    ),
})

@given(config_strategy)
@settings(
    max_examples=10000,
    suppress_health_check=[HealthCheck.too_slow],
    deriving=True,  # Hypothesis also derives inputs from failures
)
def test_config_round_trip(config_dict):
    """Parse → serialize → re-parse must be idempotent."""
    cfg = my_module.Config.from_dict(config_dict)
    serialized = cfg.to_json()
    cfg2 = my_module.Config.from_json(serialized)
    assert cfg == cfg2, f"Round-trip mismatch\nOriginal: {config_dict}\nRe-parsed: {cfg2.to_dict()}"

Triaging and Fixing Crashes

Finding crashes is the easy part. Triaging them — understanding what they are, how severe they are, and how to fix them — is where the real work happens.

Step 1: Reproduce the crash

1
2
3
4
5
6
7
8
# Go: the crash is saved in testdata/fuzz/
go test -run=FuzzParseConfig/b3c4d5e6f7a8b9c0 -v ./parser/

# libFuzzer: run with the crash file
./fuzz_target crash-abc123def456

# AFL++: crashes are in findings/crashes/
./target findings/crashes/id:000003,sig:11,src:000847,op:havoc,rep:16

Step 2: Minimize the crash

A fuzzer-generated crash is often much larger than it needs to be. Minimizing finds the smallest input that reproduces the bug — much easier to debug:

1
2
3
4
5
6
7
8
9
# libFuzzer minimization
./fuzz_target -minimize_crash=1 -exact_artifact_path=crash_min.bin crash-abc123

# AFL++ minimization
afl-tmin -i findings/crashes/id:000003 -o crash_min.bin -- ./target @@

# Go: the saved corpus entry is already minimal
# But you can print it:
xxd testdata/fuzz/FuzzParseConfig/b3c4d5e6f7a8b9c0

Step 3: Understand the crash with sanitizer output

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000001234
READ of size 4 at 0x602000001234 thread T0
    #0 0x5562abcd1234 in parse_length src/parser.c:142:5
    #1 0x5562abcd5678 in parse_record src/parser.c:89:3
    #2 0x5562abcdef00 in LLVMFuzzerTestOneInput fuzz_parser.c:15:3
    #3 0x5562ab123456 in fuzzer::Fuzzer::ExecuteCallback

0x602000001234 is located 0 bytes to the right of 4-byte region
[0x602000001230, 0x602000001234)
allocated by thread T0 here:
    #0 0x7f1234567890 in malloc
    #1 0x5562abcd0123 in parse_record src/parser.c:87:20

This tells you:

  • heap-buffer-overflow: Reading 4 bytes past the end of a 4-byte allocation
  • parse_length at parser.c:142 is where the read happened
  • The buffer was allocated at parser.c:87
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// parser.c:87–145 — the bug
Record *parse_record(const uint8_t *data, size_t size) {
    Record *r = malloc(4);              // Line 87: allocated 4 bytes
    r->type = data[0];
    r->flags = data[1];
    r->version = data[2];
    r->reserved = data[3];

    // Bug: parse_length reads length from r->length, which isn't in the 4-byte alloc
    // The struct is larger than 4 bytes but only 4 were allocated!
    uint32_t len = parse_length(r);  // Line 142: reads r->length — OOB!
    ...
}

// Fix: allocate the full struct size
Record *r = malloc(sizeof(Record));

Step 4: Classify severity

Crash type Severity Rationale
Heap buffer overflow (write) Critical Likely exploitable for code execution
Heap buffer overflow (read) High Information disclosure; may be exploitable
Use-after-free Critical Classic memory corruption exploit primitive
Stack buffer overflow Critical Stack smashing → code execution
Null pointer dereference Medium Denial of service; rarely exploitable
Integer overflow Medium–High Context dependent; can lead to heap overflow
Uninitialized memory read Medium Information disclosure
Assertion failure / panic Low–Medium Denial of service
Infinite loop / hang Low–Medium Denial of service

Step 5: Write a regression test

Every fuzzer-found crash should become a unit test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// parser_test.go — regression test for fuzzer-found crash
func TestParseConfig_FuzzerRegression_BOM(t *testing.T) {
    // This input was found by the fuzzer on 2026-03-27.
    // It triggered invalid UTF-8 in Serialize() due to unhandled BOM prefix.
    // Tracked in: github.com/myorg/myapp/issues/847
    input := []byte("\xff\xfe{\"version\":1}")

    cfg, err := ParseConfig(input)
    if err != nil {
        return  // Error is acceptable
    }

    serialized := cfg.Serialize()
    if !utf8.Valid(serialized) {
        t.Errorf("Serialize produced invalid UTF-8: %q", serialized)
    }
}

CI Integration: Keeping Fuzz Testing Continuous

Fuzzing benefits enormously from continuous running — not just a one-time exercise.

GitHub Actions: short fuzz runs in CI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# .github/workflows/fuzz.yaml
name: Fuzz Tests

on:
  push:
    branches: [main, release/*]
  pull_request:
  schedule:
    - cron: '0 2 * * *'  # Nightly 2-hour fuzz run

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

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      # Restore corpus from cache (seeds fuzzer with previous interesting inputs)
      - uses: actions/cache@v4
        with:
          path: |
            ./parser/testdata/fuzz/
            ./codec/testdata/fuzz/
          key: fuzz-corpus-${{ github.ref }}-${{ github.sha }}
          restore-keys: |
            fuzz-corpus-${{ github.ref }}-
            fuzz-corpus-

      - name: Fuzz parser (30 seconds on PRs, 2 hours nightly)
        run: |
          FUZZ_TIME="30s"
          if [ "${{ github.event_name }}" = "schedule" ]; then
            FUZZ_TIME="7200s"
          fi
          go test -fuzz=FuzzParseConfig -fuzztime=$FUZZ_TIME ./parser/
          go test -fuzz=FuzzCodecRoundTrip -fuzztime=$FUZZ_TIME ./codec/

      # Upload any crash artifacts
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: fuzz-crashes-${{ github.sha }}
          path: |
            ./parser/testdata/fuzz/FuzzParseConfig/
            ./codec/testdata/fuzz/FuzzCodecRoundTrip/
          retention-days: 30

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

      - name: Install dependencies
        run: |
          apt-get update
          apt-get install -y clang llvm libfuzzer-dev

      - name: Build fuzz targets
        run: make fuzz-targets

      - name: Run libFuzzer (short run)
        run: |
          ./fuzz_json_parser \
            -max_total_time=60 \
            -timeout=5 \
            corpus/json/

OSS-Fuzz: free continuous fuzzing for open source

If your project is open source, OSS-Fuzz provides free, continuous, large-scale fuzzing on Google’s infrastructure:

1
2
3
4
5
6
# oss-fuzz/projects/my-project/project.yaml
homepage: "https://github.com/myorg/my-project"
language: go  # or c, c++, rust, python
primary_contact: "security@myorg.com"
auto_ccs:
  - "dev@myorg.com"
1
2
3
4
5
# oss-fuzz/projects/my-project/Dockerfile
FROM gcr.io/oss-fuzz-base/base-builder-go
RUN go install golang.org/x/tools/cmd/...@latest
COPY . $SRC/my-project
WORKDIR $SRC/my-project
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# oss-fuzz/projects/my-project/build.sh
#!/bin/bash -eu

cd $SRC/my-project

# Build all fuzz targets
for target in $(grep -r "func Fuzz" --include="*_test.go" -l); do
    pkg=$(dirname $target)
    fuzz_name=$(grep -o "func Fuzz[A-Za-z]*" $target | head -1 | cut -d' ' -f2)
    go-fuzz-build -func=$fuzz_name -o $OUT/${fuzz_name}.zip ./$pkg
done

OSS-Fuzz runs your fuzz targets 24/7, files GitHub issues when it finds crashes, and emails you when new bugs are discovered. It’s one of the highest-ROI security investments an open source project can make.

ClusterFuzz for private projects

For private/enterprise projects, run your own ClusterFuzz instance on GCP:

1
2
3
4
5
6
7
8
# Deploy ClusterFuzz
gcloud config set project my-clusterfuzz-project
python butler.py deploy --config-dir=my-config-dir

# Upload fuzz targets
python butler.py upload_corpus \
    --fuzzer-name my-json-parser \
    --corpus-dir corpus/json/

Writing High-Value Fuzz Targets: A Checklist

Before writing a fuzz target, ask:

Where are the trust boundaries? — The best fuzz targets process data from untrusted sources: file parsers, network protocol decoders, API request deserializers, command-line argument parsers.

What are the invariants? — Round-trips, idempotency, value ranges, relationship constraints. The stronger your invariants, the more bugs the fuzzer can find.

Are you covering the interesting code? — Check coverage. If your fuzz target never reaches 80% of the code in the module, your seeds are wrong or your entry point is too shallow.

Is the fuzz target fast? — Aim for > 1,000 executions/second. Slow targets find fewer bugs per hour. Remove I/O, avoid network calls, pre-initialize expensive objects outside the fuzz loop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Slow: initializes a database connection on every fuzz iteration
func FuzzQueryHandler(f *testing.F) {
    f.Fuzz(func(t *testing.T, query string) {
        db, _ := sql.Open("postgres", dsn)  // DON'T DO THIS
        defer db.Close()
        handler := NewHandler(db)
        handler.Handle(query)
    })
}

// Fast: initialize once, reuse across iterations
func FuzzQueryHandler(f *testing.F) {
    db, _ := sql.Open("postgres", dsn)    // Initialize once
    handler := NewHandler(db)              // Initialize once

    f.Fuzz(func(t *testing.T, query string) {
        handler.Handle(query)              // Fast per-iteration call
    })
}

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Go native fuzzing
go test -fuzz=FuzzXxx -fuzztime=1m ./pkg/
go test -run=FuzzXxx/crashfile ./pkg/            # Reproduce crash

# cargo-fuzz (Rust)
cargo fuzz run target_name
cargo fuzz tmin target_name crash-file           # Minimize crash
cargo fuzz coverage target_name                  # Coverage report

# libFuzzer (C/C++)
clang -fsanitize=fuzzer,address,undefined -o target fuzz_target.c
./target -max_total_time=3600 -jobs=8 corpus/
./target -minimize_crash=1 -exact_artifact_path=min.bin crash-file

# AFL++
afl-fuzz -i corpus/ -o findings/ -- ./target @@   # Basic run
afl-fuzz -Q -i corpus/ -o findings/ -- ./target @@ # QEMU (binary-only)
afl-tmin -i crash -o crash_min -- ./target @@      # Minimize crash
afl-whatsup findings/                              # Status of parallel fuzz run

# Corpus operations
# Merge and deduplicate corpora
afl-cmin -i old_corpus/ -o merged_corpus/ -- ./target @@

# libFuzzer corpus merge
./target -merge=1 merged_corpus/ corpus1/ corpus2/

Summary

Fuzzing is automated bug hunting that runs while you sleep. The machines are patient, methodical, and never get bored — they will explore edge cases that no human would think to try, and they will find the input that causes a crash in code you were sure was correct.

The investment is modest: writing a fuzz target for a parser or codec takes an hour. Running it in CI takes an afternoon of setup. The return is catching security vulnerabilities, panics, and correctness bugs before they reach production — or before a security researcher finds them for you.

Start with the highest-risk code in your system: anything that parses external input. Write fuzz targets that encode your invariants. Run them in CI for 30 seconds on every pull request and overnight for hours on your main branch. Add the crashes as regression tests. Repeat.

The first real bug your fuzzer finds will justify every hour spent.

Comments