TL;DR: Fuzzing zchunk — the chunked-file
format behind efficient delta downloads in the Fedora/DNF ecosystem — turned up
four memory-safety bugs in the parsing of untrusted .zck files. I reported them
privately. That same afternoon, an independent contributor opened a public PR
fixing one of the four. It was a good fix — for exactly one of two sibling
over-reads sitting a few lines apart. The maintainer’s combined patch caught
both and shipped in zchunk 1.5.4. The story here isn’t the bugs; it’s how a
narrow fix leaves a matching hole one field over.
The findings
zchunk splits a file into content-defined chunks so a client can download only
the parts that changed. The header carries an index of chunk offsets, sizes, and
digests; a .zck file is entirely attacker-controlled input to anything that
reads one. Fuzzing the header and streaming-read paths (AFL++ with
AddressSanitizer/UBSan) surfaced four issues:
- Two heap out-of-bounds reads while parsing a checksum-valid header.
read_preface()advanced past an optional element by its declared data length without checking it against the remaining header. Andindex_read()verified it had room for the first chunk digest but not for the uncompressed-source digest that follows. (CWE-125) - A NULL-pointer denial of service.
index_read()accepted an index whose declared entry count didn’t match the number actually parsed, after which the normal read APIs dereferenced a NULL first index entry. (CWE-476) - A one-byte heap out-of-bounds write. After a fully-read
dictionary-compressed file,
zck_get_range_char()could serialize an empty range and writeoutput[loc - 1]immediately before its allocation. (CWE-787)
A fifth, low-severity item — two zero-length memcpy() calls with a NULL-derived
pointer — rode along as a UBSan note.
None of these are exotic. They’re the ordinary shape of hand-written binary parsers: a length field trusted one step too far, a bounds check that guards the first read but not the second, a count that’s believed without being verified.
The same-day collision
I sent the report to maintainer Jonathan Dieter on 22 July. Five days later, as I
was checking the live tracker before a follow-up, a public pull request — #117,
from an independent contributor — appeared, fixing the read_preface()
over-read. Same bug, found independently, out in the open.
That’s an awkward moment in coordinated disclosure: part of your private report is now public, filed by someone you’ve never spoken to. It’s also a useful forcing function — it tells you the bug is real and reachable enough that two people found it, and it puts a clock on the rest of the cluster.
The sibling that nearly shipped
Here’s the part worth keeping. PR #117 fixed read_preface() cleanly. But the
index_read() over-read is its structural sibling — the same “check room for
the first digest, forget the second” mistake, a few lines away, in a function the
public PR never touched. A fix scoped to the one bug someone tripped over would
have merged, closed the issue, and left a matching out-of-bounds read live in the
next function down.
This is the entire argument for variant analysis, and for reporting the cluster rather than the single crash your fuzzer happened to land on. The fuzzer found one over-read; reading the code around it found the twin. When you disclose, you’re not just handing over a PoC — you’re handing over the pattern, so the fix can be drawn around the whole shape of the mistake instead of the one instance that surfaced.
The combined fix, and the release
Jonathan confirmed the reports the same day and took the broader route. His header
patch covers both over-reads — the read_preface() one and the index_read()
sibling PR #117 missed — plus the NULL-deref (by rejecting an index whose parsed
entry count is zero or disagrees with the declared count) and the range write. It
touches four files and supersedes #117’s scope. He released it as zchunk 1.5.4
on 2 August, and #117 was closed as covered.
I did the last step the process asks for: rebuilt the 1.5.4 release from source — no local patches — and replayed every original PoC under ASan/UBSan. All clean; the header, index, range, and zero-length paths all clear. Fixed, verified, closed.
Takeaways
- Report the cluster, not the crash. The fuzzer finds an instance; you find
the class. The
index_read()over-read never crashed in a way a narrow fix would have caught — it was found by reading the code beside the bug that did. - A same-day independent report is a signal, not a race. It confirms reachability and adds urgency. The right response is to make sure the private report’s extra coverage — here, the sibling — actually lands.
- Verify against the shipped release. Rebuilding 1.5.4 and replaying the PoCs is the difference between “the maintainer says it’s fixed” and “I watched every PoC come back clean.”
Full technical detail is in the advisory. Thanks to Jonathan Dieter for a fast, collaborative turnaround.