Research
The gzip patch that fixed the crash and left the bug
On 24 July I sent Paul Eggert, who maintains GNU gzip, a note about a patch he had written in April. It was not a new bug. It was the same bug the patch was for, still reachable, about eleven lines below where the fix had been applied. He committed a proper one that afternoon.
The interesting part is not that a patch was incomplete. Patches are code, code has bugs, and a maintainer fixing a fuzzer crash at speed is doing exactly what we all want maintainers to do. The interesting part is what happened in the three months in between: a CVE was published, scanners started reporting the issue, and anyone reading a version number would have concluded the problem was handled.
What the bug is
gzip still ships a decompressor for LZH, a format from the late eighties. It also ships one for LZW, the old .Z files. Both live in the same binary and, for reasons that made sense when memory was scarce, they share the same block of it.
The LZH decompressor walks a Huffman tree using two arrays called left and right. Those are not really arrays. They are #defines pointing into prev, which belongs to the LZW code:
#define left prev
#define right head /* head = prev + WSIZE, WSIZE = 32768 */
So decompress a .Z file and prev fills with LZW prefix codes, some larger than 32768. Then hand gzip an LZH file in the same invocation, and the tree walk starts reading indices that were never Huffman data. An index of 49178 in right resolves to prev[81946], and prev only holds 65536 entries.
That is the whole thing. One command:
gzip -d poison.Z small.lzh
Two files, two formats, one process, and the second one reads memory belonging to whatever the linker placed after prev.
Why the first fix missed
The April patch (commit 63dbf6b3) added memzero calls to clear the arrays. It put them inside read_c_len(), in the branch handling a degenerate table where the code count n is zero.
n comes out of the attacker’s bitstream. Send an LZH file where n is anything other than zero and control never enters the branch that does the clearing, but it does reach the tree walk a few lines later, which reads the arrays that were supposed to have been cleared.
It is an easy mistake to make and a hard one to notice, because the patch is correct about the crash it was written for. The reported case went through the n == 0 path. Clearing there made the reproducer stop crashing. Everything looked done.
The eventual fix, commit e7378c2, moved the clearing into huf_decode_start(), which runs once per file before any decoding happens. That way it does not matter which branch the bitstream steers into. Whatever the attacker chooses, the arrays are already zeroed.
The part I want you to take away
I did not find this by reading the patch and being clever. I found it by asking a dull question: does this fix cover every path into the behaviour, or only the one in the bug report?
To answer it I built three copies of gzip 1.13, identical except for unlzh.c, taken from the unpatched revision, the April patch, and the follow-up. Then I ran the same inputs through all three and counted.
Across 1200 generated cases the unpatched build produced 48 out-of-bounds reads. The officially patched build produced 31. Not zero. The follow-up produced zero, and a control run with no .Z file in front produced zero on every build, which is what tells you the counting means anything.
Thirty-one is the number that matters. A patch that takes you from 48 to 31 reads, in a scanner’s eyes, exactly like a patch that takes you to 0. Both change the version string. Only one changes your exposure.
AddressSanitizer said nothing, and was not wrong
This is worth its own paragraph because it burns people.
I first ran this on macOS on an arm64 machine, with an AddressSanitizer build, and got a clean exit. Exit code 0, no report, nothing. On Linux the same input tripped ASan immediately.
Neither result was a malfunction. ASan flags reads that land in a redzone or unmapped memory. On the Mac, the memory immediately after prev happened to hold other globals, so the read was out of bounds by every definition that matters and still landed on something perfectly legitimate to touch. The tool answered the question it was asked. The question just was not the one I cared about.
What worked was a check with no opinion about memory layout, wired into all three places that walk the tree:
if (_r && _i >= 32768)
fprintf(stderr, "[OOB:%s] right[%u] == prev[%u]\n", __func__, _i, 32768u + _i);
Any index at or past 32768 in right is out of bounds as a matter of arithmetic, on every platform, whether or not the address happens to be mapped. That oracle found the surviving path on both machines and gave the same answer on both.
If you take one habit from this post, take that one. When a tool reports nothing, work out whether it was capable of reporting the thing you were looking for. Silence from a scanner is not evidence of absence; it is evidence that the scanner ran.
What this means if you are not a gzip maintainer
Most organisations handle vulnerabilities through a pipeline: a scanner finds a CVE, someone patches the package, the scanner stops reporting it, the ticket closes. That pipeline has no step in it that asks whether the patch worked. It asks whether the version changed.
That is fine almost all of the time, because almost all patches are complete. It stops being fine for the ones that are not, and those are precisely the ones nobody looks at twice, because the paperwork says they are resolved.
Worth knowing about this one specifically: as of today neither patch has appeared in a gzip release. 1.14 came out in April 2025 and remains the latest. If your distribution has not backported the commits, the version number on your system tells you nothing about whether either fix is present.
Where it plausibly bites: anything decompressing archives it did not create, in batches, without checking formats first. Mail gateways. Malware sandboxes. Backup tooling. zcat across a glob. The impact is a read, not code execution, so this is a Medium and I would not tell you to drop what you are doing. But a read can put the contents of adjacent memory into decompressed output, and that is worth more than nothing.
The full technical account
Everything above is the short version. The complete analysis, including the fuzzing harness, the 8-byte minimal LZH file, the instrumented builds and the exact walk that reaches right[45850], is on my research site:
- CVE-2026-41992: incomplete patch in gzip’s LZH decompressor
- Walkthrough: verifying the GNU gzip patch
Primary sources, if you would rather read upstream than take my word for any of it:
- Paul Eggert’s oss-security announcement, 23 August 2026
- Commit
63dbf6b3, the April patch - Commit
e7378c2d, the fix that moved the clearing intohuf_decode_start() - CVE-2026-41992 at NVD
Credit where it belongs: the original bug was reported by Michał Majchrowicz, CERT-PL published the CVE on 29 June, and Paul Eggert wrote both patches and turned the second one around the same day I sent it. That last part is a maintainer doing the job properly, and it deserves saying.
We do this for clients too
Re-testing a fix is a small piece of what we do, and it is the piece people most often skip. If you have patched something important and nobody has confirmed the patch closes the path rather than the reproducer, that is a penetration test question or a vulnerability assessment question depending on how wide you want to look.
Either way, the question worth asking about any fix is the one I asked here: does this cover every path in, or just the one somebody reported?