TL;DR: A short, targeted fuzzing session against dr_libs
turned up a heap out-of-bounds read in dr_flac’s FLAC SEEKTABLE metadata handling
(CWE-125): the parser advertises a metadata buffer size that doesn’t match the
buffer it actually allocated, so a consumer reading the metadata the way the docs
describe reads past the end of the heap allocation. Reported as
mackron/dr_libs#318 with a 77-byte
PoC and a one-line fix.
It’s a moderate bug — an over-read, not a write — but the process is worth writing down, because the interesting part wasn’t the bug. It was the target selection, the harness choice, and the triage discipline that separated one real bug from a pile of noise.
Picking a target that isn’t already picked over
The single biggest lever in bug hunting is choosing where to look. Google’s OSS-Fuzz continuously fuzzes ~1000 open-source projects; anything in that set has been ground on 24/7 for years, so the shallow bugs are long gone. The gap is code that (a) parses untrusted input, (b) is written in C/C++, (c) is actually deployed, and (d) is not in OSS-Fuzz.
dr_libs fits perfectly. It’s a set of single-header audio decoders (FLAC, WAV, MP3)
vendored into countless projects — raylib, SDL_sound, and from there into Linux distros.
It parses attacker-supplied media files. And it isn’t in OSS-Fuzz. (A recent CVE in its
sibling dr_flac/dr_wav code confirmed the surface still yields bugs.)
A hard-won lesson from the same session: checking OSS-Fuzz membership isn’t enough — you also have to check for a fuzz harness in the repo itself. Several otherwise-tempting targets turned out to be actively fuzzed by their own maintainers. A maintainer who fuzzes their own code is a much harder target; prefer repos with an active maintainer and no existing fuzzing.
The rig
Nothing exotic: AFL++ with AddressSanitizer, all inside a throwaway Docker image so the
host stays clean and the fuzzers can be CPU-capped. Single-header libraries make harnessing
trivial — #include the header, call the entry point, feed it the fuzzer’s bytes.
One harness pitfall worth internalizing: at -O1, the compiler will delete a write into a
buffer you never read, and your bug vanishes with it. Sink the buffer — fold it into a
volatile global — or you’ll fuzz a no-op.
The harness choice that actually mattered
The obvious harness for an audio decoder is the decode path: open the file, read PCM frames. I wrote those for all three formats. They found nothing — that surface is exercised constantly by real use, so it’s relatively hardened.
The bug came from a second harness aimed at a quieter entry point: the metadata
parser (drflac_open_memory_with_metadata), which walks FLAC’s SEEKTABLE,
VORBIS_COMMENT, CUESHEET, and PICTURE blocks. Metadata parsing is variable-length,
full of size fields, and far less traveled than the audio path. That’s where the bug was —
and the general principle is the takeaway: aim at the deep, less-obvious entry point, not
the happy path everyone already hammers.
Triage: telling signal from noise
The first minutes of fuzzing produced a dozen “crashes.” None of them were the bug.
They were all UndefinedBehavior findings from UBSan — converting a NaN float sample to an int, a signed-overflow in an ADPCM decoder whose result is immediately clamped. Real UB, technically, but not memory-safety issues: no bad allocation, no out-of-bounds access, no attacker-controlled corruption. Reporting those as vulnerabilities is how you train a maintainer to ignore you.
So I reconfigured: keep UBSan for information but make only ASan fatal, so the fuzzer stops on genuine memory corruption — heap overflow, use-after-free — and not on cosmetic UB. That one change turned the crash feed from noise into signal.
The bug
With ASan-only crashing, the metadata harness produced heap-buffer-overflow and
heap-use-after-free reports at the same code address (the same bug manifesting differently
depending on heap layout). The symbolized stack pointed straight at the SEEKTABLE handler:
seekpointCount = blockSize / DRFLAC_SEEKPOINT_SIZE_IN_BYTES; /* /18 (on-disk unit) */
pRawData = drflac__malloc_from_callbacks(
seekpointCount * sizeof(drflac_seekpoint), ...); /* struct is 24 bytes */
...
metadata.pRawData = pRawData;
metadata.rawDataSize = blockSize; /* advertises blockSize... */
onMeta(pUserDataMD, &metadata); /* ...but the buffer is seekpointCount*24 */
Two constants that look interchangeable but aren’t:
DRFLAC_SEEKPOINT_SIZE_IN_BYTESis 18 — a FLAC seekpoint on disk (u64 + u64 + u16, packed).sizeof(drflac_seekpoint)is 24 — the same fields in memory, padded to 8-byte alignment.
The buffer is allocated as (blockSize / 18) * 24, but rawDataSize — which the
documentation defines as “the size in bytes of the buffer pointed to by pRawData” — is set
to blockSize. Those two sizes are unrelated. Whenever blockSize exceeds the real
allocation, a consumer that reads pRawData for the advertised rawDataSize bytes reads off
the end of the heap. Every other metadata handler allocates blockSize and stays
consistent; SEEKTABLE is the lone outlier.
The proof-of-concept is a 77-byte FLAC with a 35-byte SEEKTABLE block: 35 / 18 = 1
seekpoint → a 24-byte buffer, but rawDataSize = 35 → an 11-byte over-read, reproducible
every run.
Fix and disclosure
The fix is one line — make the advertised size match the buffer:
metadata.rawDataSize = seekpointCount * sizeof(drflac_seekpoint);
Patched, the PoC reads cleanly under ASan (3/3 runs); unpatched, it crashes 3/3. A report should prove its fix, not assert it.
Before reporting, I searched the issue tracker to confirm it wasn’t a known or already-fixed bug — a step that, elsewhere in the same session, correctly flagged a different crash as a duplicate of an existing (if unresolved) issue and saved me from filing noise. The find was disclosed as a public GitHub issue with the PoC as a base64 one-liner (verified to round-trip to the exact bytes) and the harness, framed collaboratively: lead with the fix, don’t flex severity.
Takeaways
- Target selection dominates. Not-in-OSS-Fuzz and no in-repo fuzzer, actively maintained, parses untrusted input.
- Fuzz the quiet entry points. The decode path was clean; the metadata parser wasn’t.
- Triage is the job. Most first “crashes” are UB noise. Make only memory-safety fatal and the signal appears.
- Prove the fix, check prior art, disclose kindly. That’s the difference between a credible report and a maintainer’s spam folder.
Two hours from an empty directory to a reproduced, root-caused, responsibly-disclosed bug — most of it spent not on the bug, but on the discipline around it.