22 - Fuzzing: how go test -fuzz found real bugs¶
Objectives: Understand what Go's built-in fuzzer does and how to write a
FuzzXxxfunction. See exactly how fuzzing thedr-wav-goparser discovered a divide-by-zero and an out-of-memory crash that human-written tests missed. Learn why regression seeds undertestdata/fuzz/matter and how to replay them. Estimated time: 25 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- Fuzzing = "a monkey with infinite patience, randomly smashing keys on your keyboard to see if your program breaks." The fuzzer mutates bytes — flipping bits, swapping numbers, inserting NULs — and calls
FuzzParsemillions of times, watching for any panic or crash. - Seeds = "the first picture in a game of telephone." You hand the fuzzer one real, valid WAV file; it generates every plausible distortion of that file, finding corner cases that live just one field-flip away from legitimate input.
- Crash files under
testdata/fuzz/= "a mugshot kept on file forever." Once the fuzzer captures a bad input intestdata/fuzz/FuzzParse/be78194ecdd4d533,go testreplays it on every CI run — the bug that once crashed the process is now a permanent regression guard. - Fuzzing parsers AND accessors = "checking that both the front door lock and the safe inside are secure."
Parseaccepting a malformed WAV without error is not enough;GetSampleCountandExtractChannelscan still divide by zero on the successfully-parsed but structurally weird struct, soFuzzParsecalls all of them. - OOM from an untrusted length field = "trusting a stranger's claim that they packed a whale into a carry-on bag." The pre-fix code did
make([]byte, subchunkSize)directly from the WAV header — a header claiming 4 GB triggered a 4 GB allocation. Capping tor.Len()means you only allocate as much as is actually present. - Fuzzing vs. unit tests = "a crash-test dummy vs. a checklist." The fuzzer discovers inputs that destroy the car; unit tests verify the seatbelt works as designed. Both are needed — fuzzing covers "must not crash," unit tests cover "must behave correctly."
Why it matters: the two real bugs fuzzing found in this repo — an OOM and a divide-by-zero — would never have been written into a hand-crafted test suite, because no developer thinks to produce a WAV header that claims 4 GB of audio data backed by 4 bytes of actual bytes.
See it — fuzzing lifecycle: seed → mutate → crash → regression seed.
How Go fuzzing works¶
Go 1.18 added native fuzzing. The rules are simple:
- The function must be named
FuzzXxxand live in a_test.gofile (in Go, any file whose name ends in_test.gois a special file that holds tests instead of program logic — the compiler, the tool that turns human-written source text into a runnable program, treats it differently from regular code). - Its first argument is
*testing.F, not*testing.T. (A function's "arguments" are the values you hand it to work with when you run it.*testing.Fand*testing.Tare two different kinds of helper objects Go's testing tools pass in —Fis for fuzz tests,Tis for ordinary ones.) - You call
f.Add(...)to register seed values (one call per seed). ("Calling" a function just means running it, right here, with specific values plugged in.) - You call
f.Fuzz(func(t *testing.T, data []byte) { ... })with your test body. (func(...) { ... }is Go's way of writing a small nameless function on the spot, right where it's needed, instead of defining it elsewhere and giving it a name.[]bytemeans "a stretchy list of bytes" — a byte is one small chunk of computer memory, usually enough to hold one letter or a small number.)
During go test (no flags) the engine replays every file in
testdata/fuzz/FuzzXxx/ as a unit test (a unit test is a small, automated check that runs a specific piece of code and confirms it behaves as expected — it's the programmer's version of double-checking your work). During go test -fuzz=FuzzXxx it
runs indefinitely, mutating inputs and looking for panics (a "panic" is Go's term for the program hitting a fatal error it can't recover from at that moment — the running program stops what it's doing and unwinds, often crashing unless something is watching for it).
# Run the fuzzer for 30 seconds against FuzzParse:
go test -fuzz=FuzzParse -fuzztime=30s ./dr-wav-go/
# Replay only the known crash seeds (runs in CI like a normal test):
go test ./dr-wav-go/
In plain terms: the first command tells Go to hammer the FuzzParse function with randomly-mutated input for 30 seconds, hunting for crashes. The second command just replays the inputs that have already been saved as known trouble-makers, as a quick sanity check.
The FuzzParse function — reading the real code¶
Here is the complete fuzz test from dr-wav-go/dr_wav_fuzz_test.go:
// FuzzParse ensures Parse and every accessor survive arbitrary input without
// panicking (e.g. divide-by-zero on a zero NumChannels / bit depth).
func FuzzParse(f *testing.F) {
valid, _ := Serialize(&WAV{
Header: WAVHeader{
AudioFormat: 1, NumChannels: 2, SampleRate: 44100,
ByteRate: 176400, BlockAlign: 4, BitsPerSample: 16,
},
Data: []byte{1, 2, 3, 4, 5, 6, 7, 8},
})
f.Add(valid)
f.Add([]byte{})
f.Add([]byte("RIFF"))
f.Add(make([]byte, 44))
// A header that parses but has NumChannels = 0 (the divide-by-zero case).
zeroCh, _ := Serialize(&WAV{
Header: WAVHeader{AudioFormat: 1, BitsPerSample: 16},
Data: []byte{1, 2, 3, 4},
})
f.Add(zeroCh)
f.Fuzz(func(_ *testing.T, data []byte) {
wav, err := Parse(data)
if err != nil || wav == nil {
return
}
// None of these may panic on a successfully-parsed (but possibly
// malformed) WAV.
_ = ValidateWAV(wav)
_ = wav.GetDuration()
_ = wav.GetSampleCount()
_, _ = wav.ExtractChannels()
_, _ = Serialize(wav)
})
}
In plain terms: this code hands the fuzzer five starting examples (one valid WAV file, an empty one, a truncated one, a blank 44-byte one, and one with zero audio channels), then tells it: for every random variation you try, parse the bytes, and if that succeeds, run every other function that reads the result — none of them should ever crash.
Three things worth noticing:
1. Early-exit on parse failure. The body returns immediately if Parse
returns an error (in plain terms: as soon as the function reaches this return, it stops running right there and hands control back to whoever called it — nothing after it in the function runs for this attempt). The fuzzer is allowed to generate garbage — the important
invariant is not "all bytes must parse" but "if parsing succeeds, no accessor
may panic." (An "accessor" is just a small function whose only job is to read and hand back one piece of information from a larger bundle of data — here, things like "how many samples does this WAV have?")
2. Seeds cover interesting edge cases. The last f.Add builds a WAV whose
NumChannels is 0 (the default zero value — in Go, a variable that's never explicitly set starts out at a predictable "empty" value, 0 for numbers). A hand-written test suite probably
never constructs that, but it is a one-mutation step away from valid input.
3. Every accessor is exercised. Not just Parse — also ValidateWAV,
GetDuration, GetSampleCount, ExtractChannels, and Serialize. Any of
those could have a hidden divide-by-zero or nil-deref on edge-case headers. ("Nil" is Go's word for "no value here" — a nil-deref is trying to read details out of something that turned out to be empty, which crashes the program.)
Bug 1 — the OOM crash¶
The WAV format stores the size of each data chunk as a uint32 field inside
the file (a uint32 is a number stored in a fixed-size 4-byte slot that can only hold non-negative whole numbers — it cannot represent anything larger than about 4.3 billion, and it cannot go negative). Before the fix, the code did:
// BEFORE (unsafe — trusts untrusted input)
pcmData := make([]byte, subchunkSize) // subchunkSize comes from the file
In plain terms: make([]byte, subchunkSize) tells the program "reserve a chunk of memory big enough to hold subchunkSize bytes" (this reserving is called allocating memory) — and it does this using a number read straight from the file, with no sanity check on how large that number is.
The fuzzer produced a header where subchunkSize was 4,294,967,295
(0xFFFFFFFF, the maximum uint32) while only 4 bytes of actual data followed
it. The make call tried to allocate 4 GB on the heap (the "heap" is the large pool of memory a running program can request chunks from) and the process was
killed by the OOM killer (OOM = "out of memory" — when a program tries to grab more memory than the computer can give it, the operating system forcibly kills it rather than let it keep trying).
The fix is in dr-wav-go/dr_wav.go, inside readDataChunk:
// AFTER — cap to bytes actually present (from dr-wav-go/dr_wav.go)
allocSize := int(subchunkSize)
if allocSize > r.Len() {
allocSize = r.Len() // never trust the declared size past EOF
}
pcmData := make([]byte, allocSize)
In plain terms: before reserving memory, the fixed code first checks how many bytes are actually left to read, and if the file's claimed size is bigger than that, it uses the smaller, real number instead — so a lying header can no longer trigger a multi-gigabyte reservation.
r.Len() returns how many bytes are left in the bytes.Reader (a "Reader" here is a small helper object that hands out bytes from the file one piece at a time and keeps track of how much is left — the function "returns" a value, meaning it finishes its work and hands that value back to whoever asked). By capping the
allocation to what is actually present, the function can never allocate more
than the input itself, no matter what the header claims.
The key lesson
A binary parser must never use an untrusted length field directly as an allocation size. Always cap it against the bytes available in the reader. See Lesson 17 for the full treatment.
Bug 2 — the divide-by-zero crash¶
After parsing succeeds, the caller can ask for the sample count (the "caller" is whatever code runs — calls — this function; here it means the fuzz test itself, invoking GetSampleCount on the parsed result):
// from dr-wav-go/dr_wav.go
func (w *WAV) GetSampleCount() int {
bytesPerSample := int(w.Header.BitsPerSample) / 8
if bytesPerSample == 0 || w.Header.NumChannels == 0 {
return 0
}
return len(w.Data) / bytesPerSample / int(w.Header.NumChannels)
}
In plain terms: this function calculates how many audio samples are in the file by dividing the total data length by the size of one sample. Before it does that division, it checks whether either number it's about to divide by is zero — dividing by zero is not just "wrong," it makes the program crash outright, so the check simply hands back 0 instead of ever attempting it.
The current code guards NumChannels == 0 and BitsPerSample == 0 with an
early return. Before this guard existed, GetSampleCount divided by
w.Header.NumChannels unconditionally, so a fuzzer-generated WAV with
NumChannels = 0 caused an integer divide-by-zero panic (not an OOM — the
runtime kills the process with panic: runtime error: integer divide by zero).
The seed the fuzzer used to find this is preserved verbatim in
testdata/fuzz/FuzzParse/be78194ecdd4d533:
go test fuzz v1
[]byte("RIFF000\x00WAVEfmt \x10\xde\x16\xc4\xd6\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00data")
The byte sequence is a mostly-valid RIFF/WAV header but with NumChannels = 0
and BitsPerSample = 0 in the fmt chunk. Parse accepts it (it does not
validate those fields); GetSampleCount then divided by zero.
Accessors can panic even on "successful" parses
Parse returning nil error does not mean the parsed struct is safe to
use. (A "struct" is Go's way of bundling several related pieces of data — here, all the WAV header details — into one named package; each named piece inside it, like NumChannels, is called a "field.") Fields derived from untrusted binary data may be zero, negative, or
otherwise surprising. Every accessor that does arithmetic on header fields
must guard those fields explicitly.
The corpus and regression seeds¶
Once the fuzzer finds a crash it writes the offending bytes to a file under
testdata/fuzz/<FuzzFunctionName>/. In this repo:
From that point on, running go test ./dr-wav-go/ (no -fuzz flag, as in
normal CI — "CI" stands for Continuous Integration, the automated system that runs a project's tests every time someone proposes a change, before that change is allowed in) automatically replays every file in that directory. If the bug
comes back — for example, someone removes the NumChannels == 0 guard (a "guard" is just a check, like the if condition above, placed before risky code to stop it from running when conditions are unsafe) — the
test fails immediately, on every run.
This is why fuzz-found seeds belong in the repository alongside the fix, not in a personal scratch directory.
Try it
From the repository root, replay the known crash seed as a unit test:
Expected output: the test passes, printing something like
--- PASS: FuzzParse (0.00s). The seed that once crashed the process now
runs cleanly and confirms the fix is in place.
To run the fuzzer live (stop it with Ctrl-C when you have seen enough):
Expected output: a line like fuzz: elapsed: 20s, execs: 1234567, ...
followed by ok — no new crashes found after the fixes landed.
Fuzz parsers AND accessors — the pattern¶
The structure of FuzzParse is the right model for any binary parser:
1. Build one or more valid seeds with f.Add.
2. Include seeds that target known edge cases (zero fields, minimum sizes).
3. In f.Fuzz: call Parse; if it returns an error, return early.
4. If Parse succeeds, call EVERY downstream accessor on the result.
Step 4 is the part that catches the divide-by-zero class of bugs. The fuzzer
found GetSampleCount crashing not by breaking the parser, but by feeding a
header that the parser accepted and that exposed a missing guard further down
the call chain.
The same pattern applies across every module in this workspace (a "module" here is one self-contained chunk of the codebase — like dr-wav-go, the WAV-file part — that can be built and tested on its own). A good fuzz
corpus (the "corpus" is simply the whole collection of seed and crash files a fuzzer has accumulated for a given function) seeds from a real (valid) file, then exercises every public function
that consumes the parsed result.
What the audit confirmed about fuzzing¶
The 10-agent security audit (docs/audits/2026-06-23-code-review-security-audit.md)
noted that the recently-landed anti-OOM and divide-by-zero fixes in dr-wav-go
were "present and correct." It also confirmed the broader finding: no
memory-corruption, slice-out-of-range, nil-deref, or integer-overflow bug was
found in any module after the fuzz-driven fixes landed.
The audit did flag that stb-image-go's LoadStream had no pixel-count guard
(M6) — a gap that fuzzing would likely have found too, had a FuzzLoadStream
function existed. Writing fuzz targets for every public parser entry point, not
just the primary one, is the lesson.
CI fuzzing on a schedule
The repository runs a weekly scheduled fuzzing job in
.github/workflows/go-ci.yaml. Even after all known seeds pass, the
fuzzer keeps exploring. A regression that slips through code review may
still be caught by the next scheduled run before it reaches a release.
Key takeaways¶
func FuzzXxx(f *testing.F)is all you need to start. Seed withf.Add, exercise withf.Fuzz. The fuzzer handles the rest.- Seeds matter. A zero-channel WAV is one field-flip away from a valid file. Including that seed directly pointed the fuzzer at the divide-by-zero in seconds.
- Cap allocations to bytes present.
make([]byte, untrustedSize)is an OOM waiting to happen.make([]byte, min(untrustedSize, reader.Len()))is safe. ThereadDataChunkfix indr-wav-go/dr_wav.gois the canonical example. - Fuzz accessors, not just parsers. A successfully-parsed struct can still
kill your process if an accessor divides by a zero field.
FuzzParseexercisesGetSampleCount,GetDuration,ExtractChannels, andSerializein the same fuzz body. - Crash files under
testdata/fuzz/are permanent CI tests. They replay automatically on everygo testrun. A bug found by the fuzzer is a bug that can never silently return.