moizxsec
Disclosure 8 min read ★ selected

unzipper: Zip Slip Arbitrary File Write via a Sibling-Prefix Path Bypass

unzipper already blocks classic ../../ Zip Slip; it was patched for CVE-2018-1002203 years ago. But the guard it shipped was a string-prefix check (extractPath.indexOf(destination) != 0), and a string prefix is not a directory boundary. An archive entry named ../dest-evil/x escapes /tmp/dest into the sibling /tmp/dest-evil, which the old check happily accepts because the path starts with the destination string. One crafted entry writes a file outside the extraction root. CVE-2026-59972: reported, root-caused, and patched upstream, on a package pulling ~29M downloads a week.

Severity
High 8.1
Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H
Weakness
CWE-22
Affected
unzipper ≤ 0.12.4 npm
Fixed in
0.12.5
Vendor
ZJONSSON/node-unzipper
Status
Patched
Published

unzipper is one of the default ways Node.js code reads ZIP archives, roughly 29 million downloads a week. When you extract an archive from an untrusted source, the one thing you are trusting the library to do is keep every file inside the directory you asked for. unzipper tries to. It had already been patched for classic Zip Slip (../../etc/...) under CVE-2018-1002203. The problem is how it was patched: with a string-prefix check. And a string prefix is not a directory boundary.

This is CVE-2026-59972 (GHSA-2756-j7rr-9464), CVSS 8.1, CWE-22 Path Traversal. It is a bypass of the existing mitigation, not a reopening of the original bug: the classic vector stays blocked. I reported it, wrote the root-cause analysis, and authored the upstream patch; the fix shipped in 0.12.5.

The guard, and the hole in it#

Here is the containment check as it stood in lib/extract.js (and, nearly identically, in lib/Open/directory.js):

// lib/extract.js - vulnerable
opts.path = path.resolve(path.normalize(opts.path));

const extractPath = path.join(
  opts.path,
  entry.path.replace(/\\/g, '/')
);

if (extractPath.indexOf(opts.path) != 0) {   // ← the entire boundary check
  return cb();
}

The intent is “reject any entry whose resolved path does not live under the destination.” The implementation asks a weaker question: “does the resolved path string start with the destination string?” Those are not the same question.

Consider:

Destination:  /srv/out
Entry path:   ../out-evil/file.txt
Resolved to:  /srv/out-evil/file.txt

"/srv/out-evil/file.txt".indexOf("/srv/out") is 0; the sibling path does begin with the destination string, because out-evil shares the out prefix. The check passes. The file is written to /srv/out-evil/file.txt, which is outside /srv/out.

The classic ../../ payload still fails this check (it resolves above the destination and does not share the prefix), which is exactly why the bug hid: the mitigation looks like it works, because the one payload everyone tests with is still blocked.

Proof of concept#

Build an archive with a single entry whose name is ../dest-evil/escaped.txt, then extract to /tmp/dest:

const unzipper = require('unzipper');
const fs = require('fs');

fs.createReadStream('evil.zip')
  .pipe(unzipper.Extract({ path: '/tmp/dest' }));

Result:

escaped OUTSIDE dest?       true => PWNED: unzipper sibling-prefix zip-slip
classic ../../ blocked?     true
intended dest contents:     []
>>> VULNERABLE: file written outside extraction root

The intended destination is empty; the payload landed in the sibling /tmp/dest-evil. The classic traversal in the same test is still blocked, confirming this is a new bypass class, not the 2018 bug resurfacing. The same escape works through the Open API (unzipper.Open.* → extract()), because lib/Open/directory.js carried a copy of the same prefix check.

Why 8.1 and not lower#

The vector is AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H: network-reachable input, low complexity, no privileges, but it needs the victim to extract an attacker-supplied archive (UI:R), and the payoff is high integrity and high availability impact: arbitrary file write outside the root. Write-primitives are rarely the end of the story:

  • overwrite application source or config that is later loaded,
  • drop a file into a directory that is served or executed,
  • corrupt build artifacts or adjacent service data.

The escape is constrained (it reaches siblings that share the destination’s prefix, not the whole filesystem), but “constrained arbitrary write” on a dependency with eight-figure weekly install counts is still a serious supply-chain exposure. The blast radius is every service that extracts untrusted ZIPs with unzipper and assumed the existing Zip Slip patch had it covered.

Discovery#

I was reviewing how a service handled uploaded ZIPs and checked the extraction guard rather than trusting the CHANGELOG line that said Zip Slip was fixed. The guard was a single indexOf(...) != 0. That pattern is a tell: a prefix test on a path string treats /srv/out and /srv/out-evil as the same boundary. The fix for CVE-2018-1002203 had swapped one incomplete check for a slightly less incomplete one: it stopped ../../ but never enforced the directory boundary it was pretending to enforce.

Confirming it was one crafted archive entry. The sibling-prefix name escaped on the first run, through both the streaming Extract API and the Open API.

The fix#

The correct check is boundary-aware, not string-aware. path.relative(dest, target) returns the path from the destination to the target; if the target is genuinely inside the destination, that relative path never starts with .. and is never absolute:

// lib/extract.js - patched (0.12.5)
const extractPath = path.join(opts.path, entry.path.replace(/\\/g, '/'));
const rel = path.relative(opts.path, extractPath);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
  return cb();
}
  • rel.startsWith('..') rejects anything that climbs out of the destination, including the sibling-prefix case, because path.relative('/srv/out', '/srv/out-evil/file.txt') is ../out-evil/file.txt, which starts with ...
  • path.isAbsolute(rel) rejects entries that resolve onto a different root (e.g. absolute paths on Windows).
  • rel === '' rejects an entry that resolves to the destination directory itself.

The same change was applied in lib/Open/directory.js, which also gained the backslash→slash normalization that extract.js already had (without it, the Open path was additionally inconsistent across platforms). The patch ships with test/zipSlipSiblingPrefix.js covering both extraction APIs: the sibling-prefix entry must be blocked, and a normal nested entry must still extract. Released as unzipper@0.12.5; anything ≤ 0.12.4 should upgrade.

I deliberately used path.relative over a destination + path.sep prefix comparison. Both are correct, but path.relative states the invariant directly (“is the target reachable from the root without climbing out?”) and handles the absolute-path and same-directory edge cases in one expression, instead of relying on the reader to remember why the trailing separator matters.

Timeline#

DateEvent
2026-06Found during a review of untrusted-ZIP extraction; one-entry PoC escapes to a sibling.
2026-06-10Root cause + patch (both extraction APIs) and regression tests prepared.
2026-06-21Fix released to npm as unzipper@0.12.5.
2026-07-16GHSA-2756-j7rr-9464 / CVE-2026-59972 published, CVSS 8.1.

Takeaways#

  • A string prefix is not a path boundary. target.startsWith(dest) and target.indexOf(dest) == 0 both treat /srv/out-evil as living under /srv/out. Use path.relative (or compare against dest + path.sep) so the sibling-prefix case cannot slip through.
  • “Already patched” is a claim to verify, not a reason to skip. This library had a Zip Slip fix on record. The fix was real but incomplete, and the CHANGELOG line discouraged exactly the scrutiny that would have found it. Read the guard, don’t trust the note.
  • Fix every copy of the check. The same flawed guard lived in two files. A patch to one and not the other would have left the Open API exploitable. Grep for the pattern, not the file.
  • Test the bypass, not just the original. The classic ../../ payload passed the old guard’s test suite because that is the payload the guard was written against. Regression tests have to include the shape that breaks the current check, not the previous one.