← Writeups

Finding a heap over-read in dr_flac with two hours and a fuzzer

9 Jul 2026 fuzzingdr_libsheap-overflowcwe-125

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:

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

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.