From de64de6c254fa1cf5411372e56d1279c98db6e9e Mon Sep 17 00:00:00 2001
From: Jim Meyering <meyering@meta.com>
Date: Sun, 16 Aug 2026 23:28:35 -0700
Subject: [PATCH 1/3] tests: test for the lzw-lzh fix

* tests/Makefile.am (TESTS): Add it.
* tests/lzw-lzh-abuse: New file.  Show how a poisoning .Z can
cause a following .lzh file to silently decompress improperly.
---
 tests/Makefile.am   |  1 +
 tests/lzw-lzh-abuse | 63 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 64 insertions(+)
 create mode 100755 tests/lzw-lzh-abuse

diff --git a/tests/Makefile.am b/tests/Makefile.am
index b5a19b6..e95dba1 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -23,6 +23,7 @@ TESTS =					\
   hufts					\
   keep					\
   list					\
+  lzw-lzh-abuse				\
   memcpy-abuse				\
   mixed					\
   null-suffix-clobber			\
diff --git a/tests/lzw-lzh-abuse b/tests/lzw-lzh-abuse
new file mode 100755
index 0000000..444d495
--- /dev/null
+++ b/tests/lzw-lzh-abuse
@@ -0,0 +1,63 @@
+#!/bin/sh
+# Before gzip-1.15, decompressing an LZH (.lzh, SCO 'compress -H') file after
+# an LZW (.Z) file in the same gzip process would let stale LZW state leak
+# into the LZH decoder, silently producing erroneous output or a buffer
+# overflow.  Demonstrate the former malfunction.
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/init.sh"; path_prepend_ ..
+
+# An LZW (.Z) file to populate the shared arrays.  It need only leave
+# something nonzero in tab_prefix[257..511] -- the memory unlzh reuses as
+# left[257..511] -- covering node 511, where the LZH file's walk starts.
+#
+# This started as a checked-in 615-byte .Z, then shrank to the output of
+# "perl -e 'print map chr, 0..255' | compress", which was still bulky to
+# inline, and opaque. Then, perhaps going overboard, I realized I could use
+# 256 open-coded 9-bit 'a's, eight to a nine-byte unit ("compress" would
+# never emit that, of course), making this nicely compressible in a
+# different way:
+z='\x61\xc2\x84\x09\x13\x26\x4c\x98\x30'
+z=$z$z$z$z
+z=$z$z$z$z
+hex_printf_ "\x1f\x9d\x90$z$z" > poison.Z || framework_failure_
+
+# A minimal triggering LZH file: fill its code table with a symbol >= NC,
+# so decode_c walks left[]/right[] to decode the very first code.
+# With cleared arrays this decodes to a single NUL byte; with stale
+# arrays from this preceding LZW file, we'd get 0x61.
+hex_printf_ '\x1f\xa0\x00\x01\x00\x00\x1f\xf0\x00\x00\x00' > in.lzh \
+  || framework_failure_
+
+# After the 2-byte magic the bit fields are (see read_c_len, decode_c):
+#   bits  0-15  blocksize            = 1
+#   bits 16-25  read_pt_len(TBIT=5)  n = 0, fill symbol = 0
+#   bits 26-43  read_c_len(CBIT=9)   n = 0, c_table fill = 511  <-- >= NC
+#   bits 44-51  read_pt_len(PBIT=4)  n = 0, fill symbol = 0
+# The key is that value of 511 (normally 0).
+
+fail=0
+
+# The LZH file must decode the same with or without a preceding poison.Z.
+gzip -dc poison.Z > z_out || fail=1
+gzip -dc in.lzh > lzh_alone || fail=1
+gzip -dc poison.Z in.lzh > combined || fail=1
+
+cat z_out lzh_alone > exp || framework_failure_
+compare exp combined || fail=1
+
+Exit $fail
-- 
2.55.0


From 1e36a619c99232d9022b66949c2b56c9fe712c78 Mon Sep 17 00:00:00 2001
From: Jim Meyering <meyering@meta.com>
Date: Fri, 28 Aug 2026 12:37:39 -0700
Subject: [PATCH 2/3] =?UTF-8?q?gzip:=20don=E2=80=99t=20mishandle=20.lzh=20?=
 =?UTF-8?q?after=20.lzh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

If an .lzh member ends up with all code lengths zero, make_table
neither stores a symbol in c_table nor runs its own clearing loop, so
decode_c decodes with whatever table the previously decompressed .lzh
member left behind.  The member then decodes differently depending on
what preceded it in the same process.

* unlzh.c (huf_decode_start): Clear c_table too.
* NEWS: Mention this.
---
 NEWS    | 4 ++++
 unlzh.c | 7 +++++++
 2 files changed, 11 insertions(+)

diff --git a/NEWS b/NEWS
index 5b17e9d..4710307 100644
--- a/NEWS
+++ b/NEWS
@@ -22,6 +22,10 @@ GNU gzip NEWS                                    -*- outline -*-
   when an internal bit buffer is not properly cleared.
   [bug present since the beginning]

+  When decompressing an .lzh file after another .lzh file, the output is
+  no longer corrupted by the previous file's decoding table.
+  [bug present since the beginning]
+
   On old-fashioned or limited platforms lacking mktemp, gzexe, zdiff
   and znew no longer have a race when creating a temporary file.
   [bug present since the beginning]
diff --git a/unlzh.c b/unlzh.c
index c3e92ff..7fde9c6 100644
--- a/unlzh.c
+++ b/unlzh.c
@@ -313,6 +313,13 @@ huf_decode_start ()
     memzero (left, (2 * NC - 1) * sizeof *left);
     memzero (right, (2 * NC - 1) * sizeof *right);

+    /* Also needed in case C_TABLE is reused from a previous LZH
+       decompression.  If a member's code lengths are all zero,
+       make_table stores no symbol in C_TABLE and skips its own
+       clearing loop, so decode_c would use the previous member's
+       table.  */
+    memzero (c_table, 4096 * sizeof *c_table);
+
     init_getbits();  blocksize = 0;
 }

-- 
2.55.0


From cd02e11b0c20483df8484b20f6108dbc856366ff Mon Sep 17 00:00:00 2001
From: Jim Meyering <meyering@meta.com>
Date: Fri, 28 Aug 2026 12:37:46 -0700
Subject: [PATCH 3/3] tests: test for the lzh-lzh fix

* tests/lzh-lzh-ctable-abuse: New file.  Two crafted .lzh files that would
let c_table state from the first leak into the decoding of the second.
Result: the second file silently decodes to \x01 rather than \0.
* tests/Makefile.am (TESTS): Add it.
---
 tests/Makefile.am          |  1 +
 tests/lzh-lzh-ctable-abuse | 42 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 43 insertions(+)
 create mode 100755 tests/lzh-lzh-ctable-abuse

diff --git a/tests/Makefile.am b/tests/Makefile.am
index e95dba1..f6fdb7d 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -23,6 +23,7 @@ TESTS =					\
   hufts					\
   keep					\
   list					\
+  lzh-lzh-ctable-abuse			\
   lzw-lzh-abuse				\
   memcpy-abuse				\
   mixed					\
diff --git a/tests/lzh-lzh-ctable-abuse b/tests/lzh-lzh-ctable-abuse
new file mode 100755
index 0000000..c8916a2
--- /dev/null
+++ b/tests/lzh-lzh-ctable-abuse
@@ -0,0 +1,42 @@
+#!/bin/sh
+# Before gzip-1.15, decompressing an LZH (.lzh, SCO 'compress -H') file after
+# another LZH file in the same gzip process would let the first file's decoding
+# tables leak into the second.
+
+# Copyright (C) 2026 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/init.sh"; path_prepend_ ..
+
+# This first file fills the gzip-internal c_table.
+hex_printf_ '\x1f\xa0\x00\x01\x51\x4c\x97\x77\xbe\xf8\x06\xee\x00\x00\x00\x00' \
+  > a.lzh || framework_failure_
+
+# This file should decode to a single NUL byte.
+# Before the fix, it'd use a stale c_table value and decode to \x01.
+hex_printf_ '\x1f\xa0\x00\x01\x04\xc8\x00\x00\x00\x00' \
+  > b.lzh || framework_failure_
+
+fail=0
+
+# Each file must decode the same, regardless of context.
+gzip -dc a.lzh > a || fail=1
+gzip -dc b.lzh > b || fail=1
+gzip -dc a.lzh b.lzh > combined || fail=1
+
+cat a b > exp || framework_failure_
+compare exp combined || fail=1
+
+Exit $fail
-- 
2.55.0

