[PATCH] Src/parse.c: bound h->npats in .zwc loader

Michael Ridgway <[email protected]>
Newsgroups gmane.comp.shells.zsh.devel
Message-ID <CAOcC9F2hZnLPztmOZPLK2w68t+XcDuH-X4pJgqeA8QptmOgKYw@mail.gmail.com>
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

  ---
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.