Lab D · Fuzz a Parser, Commit the Crash Seed¶
Objectives: Write a
FuzzXxxfunction for the dr-wav-go parser, rungo test -fuzzto let the engine mutate inputs automatically, and commit any crash-producing seed so the regression is permanent. You will also learn how the existingFuzzParsein this repo was written and why its seed corpus is designed the way it is. Estimated time: 30 minutes.
This is a hands-on lab
The lesson teaches by doing. Read each section, run the command, observe the output, then move to the next step. The expected output for each command is shown directly below it.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- Fuzzing = "a robot that hammers a lock with millions of random keys until one breaks it." The Go fuzzer mutates byte inputs at machine speed — thousands per second — calling your
FuzzXxxfunction each time, looking for panics or hangs that no human-written test case would think to try. - A seed corpus = "the handful of real keys you give the robot so it mutates plausible shapes, not random noise." Each
f.Add(...)call registers a starting example; the engine flips bits and inserts bytes from there, reaching dangerous corners far faster than pure randomness. - A crash seed = "the one broken key the robot found, bagged and labelled." When the fuzzer triggers a panic, it writes the exact byte sequence to
testdata/fuzz/<FuzzName>/so you can replay and study it. - Committing the seed = "bolting the broken key to the wall so no one can ever forget it exists." Once the seed file is in the repo, every plain
go test ./...run (no-fuzzflag) replays it — the bug's ghost stands guard against regressions forever. - Corpus-driven coverage = "the robot keeps any key that opened even one new door, building a map of the lock." The fuzzer tracks which code branches each input exercises and retains inputs that discover new branches, gradually charting the parser's full state space.
Why it matters: the two real OOM bugs found in dr-wav-go were not caught by any hand-written test — the fuzzer found them in minutes.
See it — fuzz loop: mutate, execute, branch-track, crash-save.
The real FuzzParse — what it does and why¶
Open dr-wav-go/dr_wav_fuzz_test.go. Here is the complete function (in plain
terms: a named, reusable chunk of code — you can "call" or "run" it by name,
and it may hand back a result to whoever ran it, which is what "return"
means below):
// from dr-wav-go/dr_wav_fuzz_test.go
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
}
_ = ValidateWAV(wav)
_ = wav.GetDuration()
_ = wav.GetSampleCount()
_, _ = wav.ExtractChannels()
_, _ = Serialize(wav)
})
}
In plain terms: this function builds one well-formed audio file in memory
(the valid bytes), then registers it and four other tricky byte sequences
as starting examples for the fuzzer with f.Add(...). The f.Fuzz(...) part
defines what happens on every mutated attempt: try to read (Parse) the
bytes, and if that succeeds, run every other function on the result too,
checking that none of them crash the program.
A few words worth pinning down before you go further: a function here is
a named, reusable piece of code — like FuzzParse itself, or Parse,
ValidateWAV, GetDuration — that you can "call" (run) by writing its name
followed by parentheses, optionally handing it some input values. When a
function finishes its work and hands a result back to whoever called it,
that's called "returning" a value. A struct (short for "structure"), like
WAV or WAVHeader below, is a bundle of related named values grouped under
one name — think of it as a labeled form with several blank fields to fill
in; each labeled slot inside it (AudioFormat, NumChannels, and so on) is
called a field. The square-bracket notation []byte{1, 2, 3, 4} builds a
slice — an ordered, resizable list — of bytes (a byte is one small
unit of computer memory, able to hold a number from 0 to 255; raw files and
network data are usually described as a stream of bytes). package drwavgo
at the top of a file says which package (a named grouping of related Go
code, roughly like a folder of code that can be reused elsewhere) the file's
contents belong to, and import "testing" pulls in code that someone else
already wrote and packaged up so this file can use it, without copying it in
by hand.
Why five seeds?¶
| Seed | Purpose |
|---|---|
valid |
A well-formed WAV — the fuzzer mutates it to find edge cases close to real input. |
[]byte{} |
Empty input — tests the length guard (< 44 check) immediately. |
[]byte("RIFF") |
Four-byte truncation — exercises the early-read failure paths. |
make([]byte, 44) |
Minimum-length all-zeros — forces the RIFF/WAVE/fmt magic-byte checks. |
zeroCh |
Valid structure, zero NumChannels — targets the divide-by-zero in GetSampleCount. |
Seeding is cheap and high-value: a seed that exercises a new branch saves the fuzzer from having to discover that branch through random mutation.
What the fuzz body checks¶
The f.Fuzz callback (a function you hand to something else, to be run later
— here, run once per mutated input, rather than run directly by you) calls
every public method on a successfully-parsed WAV. A method is just a
function that is attached to a particular struct — you call it "on" a value,
like wav.GetDuration(), rather than passing the value in as a separate
argument. The invariant is simple: if Parse returns nil error, nothing else may
panic. "Nil" is Go's word for "nothing here" — an empty, absent value; a
function that "returns nil error" is reporting that nothing went wrong. A
panic is Go's term for the program hitting a fatal, unrecoverable problem
mid-run and aborting instead of continuing normally — the opposite of a
graceful error message. The fuzzer reports a crash if any call panics, not just Parse itself.
This is the same pattern used across the codebase — call every accessor so a latent divide-by-zero or nil-deref in a downstream function gets caught too.
The bug the fuzzer found: OOM in readDataChunk¶
The fuzzer discovered that a WAV whose data subchunk header claims a huge
size (e.g. 4 GB) caused Parse to call make([]byte, 4294967295) and crash
the process with out-of-memory. make([]byte, N) is Go's way of asking the
computer to reserve (in plain terms: "allocate," meaning set aside and hand
you) a chunk of its memory big enough to hold N bytes in a row; if N is
absurdly large — here, over four billion — the computer can run out of spare
memory trying to satisfy the request, which is what "out-of-memory" (OOM)
means: the program asked for more space than the machine had free to give.
The fix in dr-wav-go/dr_wav.go caps the allocation to the bytes actually
remaining in the reader:
// from dr-wav-go/dr_wav.go — readDataChunk
if string(subchunkID[:]) == "data" {
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 for the audio data, this code
checks the size the file claims to need (allocSize) against r.Len() —
how many bytes are actually still available to read. If the file's claimed
size is bigger than what's really there, it shrinks the request down to what
actually exists, so the program never tries to grab more memory than the
machine can safely hand over.
The principle: never allocate based on an untrusted integer from the input. Always bound it against something you already know is safe — here, the bytes physically present in the reader. See Lesson 17 for the threat model behind this pattern.
The committed crash seed¶
After the fuzzer found the OOM, it wrote the crash-triggering input to:
The file content is:
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")
This is a minimal valid RIFF/WAVE header with a data subchunk whose declared
size field is crafted to be much larger than the remaining bytes. After the fix,
replaying this seed passes: Parse returns a result with the data capped to the
bytes actually present, rather than panicking.
The seed is committed alongside the fix in the same git commit — it is now a
permanent regression test that runs on every go test ./... with no special
flags.
Step-by-step lab¶
Step 1 — run the existing seed corpus (no fuzzing)¶
Try it
Expected output (the seeds replay as unit tests):
When there is no -fuzz flag (a flag is an optional switch you add after
a command to change how it behaves — like a checkbox typed on the command
line), go test runs FuzzParse once for each
seed in testdata/fuzz/FuzzParse/ plus the seeds registered with f.Add.
This is how committed seeds become regression tests (a regression test is
simply a check that re-runs a case which broke once before, to make sure
the same bug never comes back unnoticed).
Step 2 — run the fuzzer for a short burst¶
Try it
Expected output (abridged):
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 87432 (29143/sec), new interesting: 12 (total: 18)
fuzz: elapsed: 6s, execs: 182901 (31820/sec), new interesting: 14 (total: 20)
...
fuzz: elapsed: 20s, execs: 610233 (30511/sec), new interesting: 19 (total: 25)
PASS
new interesting counts inputs that opened new coverage branches. The
fuzzer keeps those in a temporary corpus (under $GOCACHE/fuzz/) but
does not commit them unless they cause a crash.
Step 3 — replay the committed crash seed explicitly¶
Try it
Expected output (the patched code handles it cleanly):
If you were to revert the readDataChunk fix and re-run, this test would
crash with an OOM or panic — that is exactly the regression guarantee.
Step 4 — write your own fuzz target (optional extension)¶
Extension exercise
Write a second fuzz target that focuses only on Serialize → Parse
round-trips. The invariant: for any WAV that Serialize accepts, the
output must re-parse to the same header and data.
// dr-wav-go/dr_wav_roundtrip_fuzz_test.go
package drwavgo
import "testing"
func FuzzRoundTrip(f *testing.F) {
f.Add(uint16(1), uint16(2), uint32(44100), uint16(16), []byte{1, 2, 3, 4})
f.Fuzz(func(t *testing.T, fmt uint16, ch uint16, sr uint32, bps uint16, data []byte) {
wav := &WAV{
Header: WAVHeader{
AudioFormat: fmt,
NumChannels: ch,
SampleRate: sr,
BitsPerSample: bps,
},
Data: data,
}
raw, err := Serialize(wav)
if err != nil {
return // Serialize may reject invalid fields; that's fine
}
got, err := Parse(raw)
if err != nil {
t.Fatalf("Parse failed after Serialize succeeded: %v", err)
}
if got.Header != wav.Header {
t.Fatalf("header round-trip mismatch: got %+v want %+v", got.Header, wav.Header)
}
})
}
Run it:
The fuzzer will mutate the individual fields rather than raw bytes, letting
it explore the parameter space of Serialize's input validation quickly.
Step 5 — committing a new crash seed¶
If go test -fuzz finds a crash, it prints:
--- FAIL: FuzzParse (3.21s)
Fuzzing found a crashing input; minimizing...
...
Failing input written to testdata/fuzz/FuzzParse/a1b2c3d4e5f6
To re-run: go test -run=FuzzParse/a1b2c3d4e5f6 ./...
FAIL
The workflow after that is:
- Fix the bug in the production code.
- Confirm the seed now passes:
go test -run=FuzzParse/a1b2c3d4e5f6 -v ./... - Commit the seed file in the same commit as the fix — one SHA, one story.
- Update
docs/BUG_JOURNAL.mdin that same commit (per global rule §1).
Never delete crash seeds
A crash seed is proof a bug existed and a regression guard against it returning. The file is tiny (a few hundred bytes). There is no good reason to remove it.
How CI picks this up¶
In .github/workflows/go-ci.yaml, the weekly fuzz job runs every module's
target through a build matrix (shown here with the dr-wav-go row expanded):
# from .github/workflows/go-ci.yaml — fuzz job (matrix)
strategy:
matrix:
include:
- module: dr-wav-go
target: FuzzParse
# … jsmn-go/FuzzParse, miniz-go/FuzzExtract, stb-truetype-go/FuzzLoadFont, …
steps:
- name: Fuzz ${{ matrix.module }} (${{ matrix.target }})
working-directory: ${{ matrix.module }}
run: go test -run='^$' -fuzz="^${{ matrix.target }}$" -fuzztime=120s .
- name: Upload crash corpus on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crash-${{ matrix.module }}
path: ${{ matrix.module }}/testdata/fuzz/
On failure the corpus is uploaded as a CI artifact so a developer can download it and reproduce the crash locally without needing to re-run the fuzzer.
The regular test job (which runs on every push and PR) uses:
No -fuzz flag — but this still replays every file in testdata/fuzz/. The
committed seed is therefore tested on every CI run, not just on Mondays.
Key takeaways¶
- A
FuzzXxxfunction has two parts:f.Add(seed...)to register starting examples, andf.Fuzz(func(t, data))to define the invariant. The invariant should be "nothing panics" for a parser — call every downstream accessor. - Seeds should cover the full range of the input space cheaply: valid input, empty input, truncated input, and any known dangerous patterns (e.g. zero channels for a divide-by-zero).
- When the fuzzer finds a crash, it writes a minimal reproducer to
testdata/fuzz/<Name>/. Commit it with the fix in the same SHA. - Committed seeds replay as ordinary unit tests on every
go test ./...run — no special flags needed. This is free, permanent regression coverage. - The
readDataChunkOOM fix — cappingallocSizetor.Len()— is the canonical example of why fuzzing finds what hand-written tests miss: no human thinks to writemake([]byte, 0xFFFFFFFF)in a test case.