Zsh Mailing List Archive
Messages sorted by:
Reverse Date,
Date,
Thread,
Author
[PATCH] Src/parse.c: bound h->npats in .zwc loader
- X-seq: zsh-workers 54571
- From: Michael Ridgway <michael.ridgway@xxxxxxxxx>
- To: zsh-workers@xxxxxxx
- Subject: [PATCH] Src/parse.c: bound h->npats in .zwc loader
- Date: Fri, 15 May 2026 16:07:34 +1000
- Arc-authentication-results: i=1; mx.google.com; arc=none
- Arc-message-signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; h=content-transfer-encoding:to:subject:message-id:date:from :mime-version:dkim-signature; bh=b7CgBLGSNFNhw/8WexL29w4uUyGHW1OxGH1M1w2l7dw=; fh=SbTlPuNNxBzTkRlwWtqw/TXBY0HvGvtE97RpPp3sJPM=; b=jC95HnH6QnYp1mGnlNbS0EvoRHBTUSkfrAFd2bBaFjZ8YhVMK7HALhZHOv1lXQ6BlK zJM9hkwc1aE3zOxF3BGMGtiH6XujTmMpmRcVexPy3wSM0B/+knYnUdpoQdUhJAI/uiTa pFurI1PS4b0r00DzxVTbBmcbRj79Fb2vV0kLEGIRwEunr9DkW1i0kVkzQro7MdWn5Il6 6V0UteQTvfPK4A3MoNlz5/wW95FV9E8YF3Pa3RPbe6EqjNBfhN/q6zoj+TECG6Qvm0Vs NUSraiHgwZ1OOouzy/WdHKkwuW5hCLVpBrlFWvZWb5+vO5+OYlZeSbgY1iMzmCyHGTi1 d50A==; darn=zsh.org
- Arc-seal: i=1; a=rsa-sha256; t=1778825266; cv=none; d=google.com; s=arc-20240605; b=JbDqZy5rA6MKTMCKFRDW5ilMViPle15EaT37fxo7W3jpmmMO2fN0KLkiz2j5Km7nsc VhqAWk3/w0MhIVInzmzfIAddcsTCM3bJv6pYo903OzJx0sACowLrp1jpLKwBlu6h5Xqg xnXtk/meIF2PgPYjnKlyNPRHNUtWhiSWUKGZMjWshfG1ZYFiYULULKz4q8ivAjagdlVr 7KW+iIPhbKfRmJ/8Y6y8aWxVwfHkJiiwiu/Xr5M5xZSoUynztG2lJmKDjaQ4m3nOAi9P uCBj5ds+tFzD1RO0dnKvY3MMXCKWM4hMXKCT98ZaRhQJnu2vMKYG6CsERXilso3ofoTO 1+yw==
- Archived-at: <https://zsh.org/workers/54571>
- List-id: <zsh-workers.zsh.org>
Hi all,
As discussed in the zsh-security thread of 2026-05-03 onwards (and per
Oliver's nudge yesterday — happy to get this into the next minor
release).
Background. The .zwc loader at Src/parse.c:3920 computes
int po = h->npats * sizeof(Patprog);
with h->npats an attacker-controlled uint32 from the .zwc header. The
multiplication is done in size_t and narrowed to int, so h->npats =
0x40000000 wraps po to zero. zalloc(h->len + 0) returns a small
buffer; the subsequent
while (np--) *pp++ = dummy_patprog1;
then writes ~1 billion 8-byte pointers off the end of it. The USE_MMAP
branch at :3884 has the same shape (int np, zalloc(np * sizeof(Patprog)),
same write loop).
I tried Oliver's suggested check first:
if (h->npats > SIZE_MAX / sizeof(Patprog))
return NULL;
It catches multiplication overflow at the size_t level, which is the
right shape for that failure mode -- but h->npats is a uint32, so on
64-bit platforms SIZE_MAX/sizeof(Patprog) ~= 2^61 and the comparison
is dead code (-Wall flagged it as "result of comparison ... is always
false"). The original PoC value of 0x40000000 doesn't overflow size_t
on a 64-bit system; it just produces an 8 GB allocation that SIGBUSes
when the fill loop touches uncommitted pages. So the SIZE_MAX check
on its own doesn't actually close the crash.
This patch uses a file-derived structural bound instead. Every
pattern emitted into the wordcode stream costs exactly one wordcode
(parse.c:1307, 1316, 1352, 2667, 2673, 2679, 2684 -- all of the form
ecadd(ecnpats++)), so a file claiming more patterns than it has
wordcodes to reference them with is malformed by construction:
h->npats > h->len / sizeof(wordcode)
No magic number, scales with the input, costs O(1) on the load path.
np and po are widened to size_t at the same time. This is required in
conjunction with the bound: after the bound, h->npats can still reach
UINT32_MAX / sizeof(wordcode) ~= 2^30, and h->npats * sizeof(Patprog)
can reach ~2^33, which doesn't fit in int. Without widening, a
crafted h->len near UINT32_MAX would let the int-truncation reappear
through a different door.
Built and tested against current master (HEAD = 11a451df9). Full test
suite passes (72 scripts, 3 skipped for missing optional modules).
Regression test added in Test/B14zwc.ztst covering both the legitimate
autoload path and the malformed-npats rejection.
The patch addresses only the h->npats case. The disclosure also noted
related uint32 fields used unchecked in size arithmetic at
parse.c:3286 and :3299 (fdheaderlen(buf) * sizeof(wordcode)); those
need their own look but the failure mode is weaker -- it requires
shipping the backing bytes -- so I'd send those separately.
---
Src/parse.c | 15 ++++++++++++---
Test/B14zwc.ztst | 38 ++++++++++++++++++++++++++++++++++++++
2 files changed, 50 insertions(+), 3 deletions(-)
create mode 100644 Test/B14zwc.ztst
diff --git a/Src/parse.c b/Src/parse.c
index 2b7f8bb59..545cbe61e 100644
--- a/Src/parse.c
+++ b/Src/parse.c
@@ -3882,10 +3882,14 @@ check_dump_file(char *file, struct stat
*sbuf, char *name, int *ksh,
#ifdef USE_MMAP
if (f) {
- Eprog prog = (Eprog) zalloc(sizeof(*prog));
+ Eprog prog;
Patprog *pp;
- int np;
+ size_t np;
+
+ if (h->npats > h->len / sizeof(wordcode))
+ return NULL;
+ prog = (Eprog) zalloc(sizeof(*prog));
prog->flags = EF_MAP;
prog->len = h->len;
prog->npats = np = h->npats;
@@ -3917,7 +3921,12 @@ check_dump_file(char *file, struct stat
*sbuf, char *name, int *ksh,
{
Eprog prog;
Patprog *pp;
- int np, fd, po = h->npats * sizeof(Patprog);
+ int fd;
+ size_t np, po;
+
+ if (h->npats > h->len / sizeof(wordcode))
+ return NULL;
+ po = h->npats * sizeof(Patprog);
if ((fd = open(file, O_RDONLY)) < 0 ||
lseek(fd, ((h->start * sizeof(wordcode)) +
diff --git a/Test/B14zwc.ztst b/Test/B14zwc.ztst
new file mode 100644
index 000000000..d8a8b2d38
--- /dev/null
+++ b/Test/B14zwc.ztst
@@ -0,0 +1,38 @@
+# Regression tests for the .zwc loader (zcompile / autoload of compiled
+# function dumps). The header field h->npats is attacker-controlled when
+# a .zwc lands on $fpath, so the loader must reject implausible values
+# rather than allocating an arbitrary buffer and crashing.
+
+%prep
+
+ mkdir zwc.tmp zwc.tmp/fpath
+ print 'print victim ran' >zwc.tmp/victim
+ ( cd zwc.tmp && zcompile victim )
+ cp zwc.tmp/victim.zwc zwc.tmp/fpath/orig.zwc
+ chmod u+w zwc.tmp/fpath/orig.zwc
+
+%test
+
+ ( fpath=($ZTST_testdir/zwc.tmp/fpath)
+ cp $ZTST_testdir/zwc.tmp/fpath/orig.zwc \
+ $ZTST_testdir/zwc.tmp/fpath/victim.zwc
+ autoload -Uz victim
+ victim )
+0:Unmodified .zwc autoloads and runs
+>victim ran
+
+# FDHead.npats sits at byte offset FD_PRELEN*4 + sizeof(wordcode)*2 = 56.
+# 0x40000000 (npats ~= 1 billion) is the value from the original
+# crash report; with the bound check in check_dump_file it must be
+# rejected, falling back to "function definition file not found"
+# rather than triggering an out-of-bounds write or absurd allocation.
+
+ ( fpath=($ZTST_testdir/zwc.tmp/fpath)
+ cp $ZTST_testdir/zwc.tmp/fpath/orig.zwc \
+ $ZTST_testdir/zwc.tmp/fpath/victim.zwc
+ printf '\x00\x00\x00\x40' | \
+ dd of=$ZTST_testdir/zwc.tmp/fpath/victim.zwc \
+ bs=1 seek=56 count=4 conv=notrunc 2>/dev/null
+ autoload -Uz victim
+ victim ) 2>/dev/null
+1:Malformed .zwc with implausible npats does not crash the shell
--
2.51.2
— Michael
---
Messages sorted by:
Reverse Date,
Date,
Thread,
Author