CVE-2026-41992 was fixed in the branch that crashed, not in the function that reads. Here is the branch the first patch walked past, and what it took to prove it was still open.
gzip will decompress more than one member in a single invocation, and they do not have to be the same format: gzip -d a.Z b.lzh, zcat *, any wrapper that reuses the process across inputs. That is an awkward thing to support safely, because state from one format's decoder has to be fully dead before the next format's decoder trusts the same memory. CVE-2026-41992 was exactly that failure: a .Z member leaves the LZW tables full of stale prefix codes, and a following .lzh member reads that same memory as a Huffman tree. Michał Majchrowicz and Marcin Wyczechowski of AFINE reported it; Paul Eggert fixed it in 63dbf6b3.
I went looking to see whether the patch actually closed the door. It closed the doorway the crash came through, and left the one beside it, eleven lines further down the same function.
This bug class exists because gzip's decompressors share memory on purpose. unlzh.c's Huffman tree-walk arrays left and right are not their own allocation; they are the LZW module's tables, renamed:
/* static ush left[2 * NC - 1]; */ /* static ush right[2 * NC - 1]; */ #define left prev #define right head /* ... and in gzip.h: */ #define tab_prefix prev /* the .Z / LZW prefix table */ #define head (prev+WSIZE) /* WSIZE == 0x8000 == 32768 */ DECLARE(ush, tab_prefix, 1L<<BITS); /* BITS == 16 -> prev[65536] */
So left[i] is prev[i] and right[i] is prev[32768 + i], both inside one 65536-entry array, which means any tree-walk index at or above 32768 puts right[i] past the end of prev. After a .Z member those slots hold LZW prefix codes running up to 65535, so the values sitting there are already large enough to do it.
The tree walk is a loop that keeps hopping while the value it reads is out of symbol range. Handed a real Huffman table it terminates at a leaf. Handed stale LZW data it is a random walk through 16-bit integers, and the second hop is already off the end:
right[49178] is a real index observed in the run below. A single hop past a poisoned slot puts the next read 32 KB beyond the allocation.63dbf6b3 was right in spirit: zero left and right before the LZH decoder trusts them. But it placed the memzero inside one branch, deep in the call graph, rather than once where decoding starts:
n = getbits(CBIT);
if (n == 0) { /* the reported crash came through here */
c = getbits(CBIT);
for (i = 0; i < NC; i++) c_len[i] = 0;
for (i = 0; i < 4096; i++) c_table[i] = c;
/* Needed in case LEFT and RIGHT are reused from a previous
LZW decompression. ... */
memzero(left, (2 * NC - 1) * sizeof *left);
memzero(right, (2 * NC - 1) * sizeof *left);
} else { /* ... but nothing clears the arrays here */
i = 0;
while (i < n) {
c = pt_table[bitbuf >> (BITBUFSIZ - 8)];
if (c >= NT) {
mask = (unsigned) 1 << (BITBUFSIZ - 1 - 8);
do {
if (bitbuf & mask) c = right[c]; <-- unlzh.c:248
else c = left [c];
mask >>= 1;
} while (c >= NT);
}
n comes straight from the attacker's bitstream. Choose n == 0 and you land in the branch that scrubs the arrays. Choose anything else and you land eleven lines below it, in a tree walk over exactly the same memory, with nothing having cleared it.
Three functions in unlzh.c walk these arrays, and I instrumented every site to be sure the count is exactly three, not "three that I noticed". After the official fix, they stand like this:
unlzh.c:285. Only reachable once read_c_len has returned, by which point the n == 0 memzero has run, and it never produced an out-of-bounds read on any build I tested.unlzh.c:248. The memzero lives in the sibling branch of the same if and never executes on this path. This is the finding.unlzh.c:303. Nothing in decode_p clears anything, but read_c_len always runs first, and in practice its memzero gets there in time. Exposed before the fix, covered after it, by luck of ordering rather than by design.The obvious oracle is AddressSanitizer: build gzip, feed it a poisoning .Z followed by a crafted .lzh, wait for the SEGV. On Linux that is what happens. On macOS/arm64 the same inputs through the same source exit 0, silently.
That isn't a reproduction failure, it's the shape of the bug. The read overshoots by roughly 32 KB, and whether that address faults comes down to what the linker happened to put after prev. On an unmapped page you get a SEGV; on another global, which is what happens on my machine, the read just succeeds and hands whatever that variable holds back into the Huffman decode. The crashing case is the one where the platform happens to notice.
So instead of waiting for a fault, I instrumented the read itself. Every tree-walk site gets a predicate on the index before the access, which makes the out-of-bounds read observable whether or not the platform bothers to object:
instrumentation, applied identically to all three revisions{ int _r = (bitbuf & mask) ? 1 : 0; unsigned _i = (unsigned)c;
if (_r && _i >= 32768)
fprintf(stderr, "[OOB:%s] right[%u] == prev[%u] / 65536\n", __func__, _i, 32768u+_i);
if (_r) c = right[c]; else c = left[c]; }
Three builds out of the same tree with the same flags and the same instrumentation, differing only in which revision of unlzh.c got dropped in: 63dbf6b3^ before the fix, 63dbf6b3 with it, and e7378c2 with the follow-up. Then the eight-byte .lzh, behind a .Z that drives LZW to high codes:
# poison.Z: compress -b16 over ~400 KB of structured bytes # evil.lzh: 8 bytes, inert on its own 1f a0 00 05 04 c3 00 f3 $ gzip -dc poison.Z evil.lzh > /dev/null [dbg] huf_decode_start: max left[0..1018]=897 max right[0..1018]=32962 [dbg] read_c_len else: entry c=19 NT=19 walk=1 [dbg] hop right[19] -> 28081 [dbg] hop left[28081] -> 22493 [dbg] hop left[22493] -> 19575 [dbg] hop right[19575] -> 49178 [OOB:read_c_len] right[49178] == prev[81946] / 65536 [dbg] hop right[49178] -> 62836 [dbg] hop left[62836] -> 0
The first line is the precondition, measured rather than assumed: after the .Z member the highest value in the first 1019 slots of right is 32962, which is already past 32768, so one hop through a slot like that is enough to put the next read off the end. Four hops later the walk reads prev[81946] out of a 65536-entry array, and it does it twenty-four times over that single run.
evil.lzh on its own does nothing at all: left and right are zero-initialised in BSS, the walk reads a zero, and the loop exits on the first hop. It only becomes a crasher once a prior .Z member in the same process has filled that memory with LZW state. Fuzzing .lzh in isolation will never find this, however long you run it. That cross-format precondition is what made the parent bug easy to miss, and it is what let this branch sit behind a patch that looked finished.
One file doesn't tell you how much of the patch is missing, so I re-ran the generator from the original work, unchanged, over 1200 executions, kept every input that reached an out-of-bounds read on the unpatched build, and replayed that set against all three:
| build | unlzh.c from | inputs reaching an OOB read |
|---|---|---|
| unpatched | 63dbf6b3^ | 48 / 48 |
| official CVE fix | 63dbf6b3 | 31 / 48 |
| follow-up fix | e7378c2 | 0 / 48 |
control, no .Z poison | 63dbf6b3^ | 0 / 48 |
The control row is the one that makes the rest mean anything: strip the .Z prefix and the identical inputs against the identical unpatched binary produce nothing. The cross-format poison is the cause, not an artefact of malformed .lzh data.
Attributing every out-of-bounds read to its sink, over a separate 1500-execution run, shows which door each fix actually shuts:
| build | read_c_len | decode_c | decode_p |
|---|---|---|---|
| unpatched | 1082 | 0 | 91 |
| official CVE fix | 523 | 0 | 0 |
| follow-up fix | 0 | 0 | 0 |
My earlier draft of this analysis claimed two of the three sinks were still exposed after 63dbf6b3. The measurements do not support that, and I have corrected it here. decode_p goes out of bounds 91 times on the unpatched build and zero times on the patched one, across more than 28,000 recorded decode_p tree-walk entries, because read_c_len always runs first and its memzero reliably gets there in time.
The correct claim is narrower and still sufficient: one sink, the read_c_len else-branch, remains demonstrably open after the official fix. decode_p is unguarded by construction but incidentally covered in every path I could construct. That distinction does not change the patch; it changes how much the patch was ever entitled to assume.
All three sinks read the same two arrays, and none of them needs those arrays re-checked at every branch that happens to touch them. They need the arrays clean once, before any of the LZH decoding starts. huf_decode_start runs exactly once per file, from decode_start, which makes it the place to state the guarantee instead of hoping for it:
static void
huf_decode_start ()
{
/* Needed in case LEFT and RIGHT are reused from a previous
LZW decompression. It may be overkill to clear all of both
arrays, but nobody has had time to analyze this carefully. */
memzero (left, (2 * NC - 1) * sizeof *left);
memzero (right, (2 * NC - 1) * sizeof *right);
init_getbits(); blocksize = 0;
}
The per-branch memzero in read_c_len comes back out; the guarantee now holds for every consumer regardless of which branch the bitstream steers into. It is also two lines shorter than the thing it replaces. One incidental repair came along with the move: the original wrote sizeof *left for both calls, including the one clearing right. Harmless, since both arrays are ush, but it is the kind of copy-paste that stops being harmless the moment someone changes a type.
Clearing 1019 entries is enough for the same reason the bug is reachable at all. A walk can only enter at an index bounded by make_table's node range, below 2 * NC - 1; it is the values it finds there that are unbounded. Zero the entry region and the first hop lands on 0, which is in symbol range, and the loop exits before it can reach anything stale.
CERT-PL scored the parent CVE 6.9 Medium, CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N: confidentiality high, availability none. That is the right shape, and my macOS result is the argument for it: the read did not crash, it quietly returned the contents of neighbouring globals into the decoder. A SEGV is the visible tail of this bug; the silent read is the body of it. Out-of-bounds bytes that survive the Huffman walk can reach the decompressed output.
It applies to any process that decompresses more than one attacker-influenced member in a single invocation: gzip -d a.Z b.lzh, zcat over a glob, mail and archive scanners that batch inputs through one gzip process.
Both fixes exist only in git. gzip 1.14 was released 2025-04-09; 63dbf6b3 landed 2026-04-15 and e7378c2 on 2026-07-24. The CVE record lists every version through 1.14, meaning every release to date, as affected, and no release yet contains either patch. If you are running a distribution gzip, you are running the unpatched path, and the parent CVE matters to you more than this follow-up does.
63dbf6b3, "gzip: don't mishandle .lzh after .Z", from Michał Majchrowicz's report. Clears left/right when n == 0.huf_decode_start. Committed the same day as e7378c2, with the message "Problem and fix reported by Elias Hasas."decode_p claim (§4). Published.