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