SIGN IN SIGN UP

fix(stb_vorbis): heap overflow on untrusted audio — the allocation truncates, the consumer does not

Closes the PLAN item OPEN since 2026-08-18. Reachable from crispasr_audio_load,
so the CLI, the server's upload path and every binding.

THE MECHANISM, confirmed by arithmetic against the original ASAN report:

    f->comment_list_length = get32_packet(f);                   // attacker u32
    f->comment_list = setup_malloc(f, sizeof(char*) * length);  // int param
    memset(f->comment_list, 0, sizeof(char*) * length);         // size_t

setup_malloc's parameter is an `int`. With length = 1646854400 the product is
13,174,835,200, which truncates to 289,933,312 — EXACTLY the allocation size the
report showed. The memset computes the same product, but `sizeof` makes it a
size_t, so it is the full 13,174,835,200 — EXACTLY the write size the report
showed. One quantity, computed two ways, disagreeing across a boundary.

UPSTREAM HAS NOT FIXED IT. nothings/stb master still has `setup_malloc(vorb *f,
int sz)` and the same truncating multiply, so there was nothing to pull (which
is what the OPEN item asked to check first) and upstream is vulnerable by the
same truncation — lacking our memset, its `for` loop instead walks
comment_list[i] past the short allocation. Worth reporting there.

THE MEMSET IS ITSELF AN EARLIER CRISPASR PATCH, added to fix a different fuzz
crash (vorbis_deinit freeing an unwritten array). It did not create the
truncation; it converted a gradual overflow into an immediate 13 GB one.

THE FIX, two layers:
1. At the call site, bound the count before the multiply. Two bounds: the
   product must not truncate (INT_MAX/sizeof(char*), minus setup_malloc's
   (sz+7)&~7 rounding), and — derived from the data rather than picked — every
   comment costs at least 4 bytes for its own u32 length, so a file of N bytes
   cannot declare more than N/4 comments. That cannot reject a real file: one
   with C comments necessarily carries >= 4*C bytes. stream_len is 0 for
   stdio-backed streams, where only the first bound applies.
2. At the choke point, setup_malloc rejects a negative sz. Nearly every caller
   passes `sizeof(T) * count` computed in size_t, so this is not a one-off
   shape; a truncated value landing negative would otherwise reach malloc as a
   huge size_t.

The vendor string and the per-comment strings on the same path have the same
shape (attacker u32 length, then `len+1` in an int), so length_is_plausible()
bounds them by the same data-derived rule.

REPRODUCER, and it is verified in both directions.
tests/fuzz/regressions/ogg-comment-count-int-overflow.ogg, 101 bytes, generated
by tools/gen-ogg-comment-fuzz-seed.py. Against the pre-fix vendored decoder
under ASAN it reproduces the three-week-old report EXACTLY: WRITE of size
13174835200, 0 bytes after a 289933312-byte region, start_decoder:3683,
setup_malloc:960, stb_vorbis_open_memory:5141, stb_vorbis_decode_memory:5419 —
every size and every line number. After the fix the same input is rejected
cleanly. Reachability through crispasr_audio_load is established by the original
report's own stack, which is where it was found.

AND A CORRECTION TO THE CORPUS README. The seed it documented as missing
declared comment_list_length = 0x3FFFFFFF. I generated it and it reproduces
NOTHING against current code: 8 * 0x3FFFFFFF truncates to -8, so setup_malloc
fails and the header is rejected. Restoring that exact value would have added a
seed that gates nothing — the same disease as the empty replay set it was
written to fix. That row is retired rather than restored, with the reason.
C
crispasr integration committed
2d2c13eead983f7550eea18ed95da2563480c8c7
Parent: cfbf5a6