
CVE-2026-102282: adm-zip LPE via SUID/SGID preservation during archive extraction (fixed in 0.6.1)
Incorrect permission assignment in the npm package adm-zip. Extraction can keep setuid, setgid, and sticky bits from an untrusted zip when keepOriginalPermission is enabled, allowing a root-owned setuid executable to be planted on disk from an attacker-supplied archive: local privilege escalation.
| Package | adm-zip (npm) |
| Ecosystem | Node.js |
| CVE | CVE-2026-102282 |
| Advisory | GHSA-j5f4-cc29-5x44 |
| CWE | CWE-732 Incorrect Permission Assignment for Critical Resource |
| CVSS v3.1 | 7.1 High — CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Affected | < 0.6.1 (last known affected: <= 0.6.0) |
| Fixed | 0.6.1 (11 September 2026) |
| Published | Maintainer advisory 11 September 2026; GitHub Advisory Database 29 September 2026 |
This repository contains the write-up, a dependency version checker, a safe behavioral test, and a self-contained proof of concept built around a benign verifier binary that only prints its credential context.
Every release before 0.6.1 is affected, including 0.6.0.
| Range | Status |
|---|---|
>= 0 and < 0.6.1 | Affected |
<= 0.6.0 | Last known affected |
>= 0.6.1 | Fixed |
The bug is reachable only when all of these are true:
keepOriginalPermission=true to extractAllTo(), extractAllToAsync(), or extractEntryTo().Default extraction leaves the flag off and is not affected. Extraction as an unprivileged user can still apply the mode bits, but the resulting file is owned by that same user, so it does not cross a privilege boundary.
Unix mode bits live in the high 16 bits of a zip entry's external file attributes. adm-zip copies those bits onto the extracted path.
| Location | Role |
|---|---|
headers/entryHeader.js — fileAttr | Reads the Unix mode from the external attributes |
adm-zip.js — extractAllTo, extractAllToAsync, extractEntryTo | Uses fileAttr when keepOriginalPermission is true |
util/utils.js — chmodSync inside the file write helper | Applies that mode to the path just created |
adm-zip.js directory extraction | Applies the same mode to extracted directories |
In 0.6.0 and earlier, fileAttr masks the shifted attribute with 0xfff
(0o7777). That mask keeps the three special bits along with the usual rwx
bits, and nothing in the write path clears 0o7000 before chmod:
// headers/entryHeader.js (<= 0.6.0)
get fileAttr() {
return (_attr || 0) >> 16 & 0xfff;
}
0o4000)0o2000)0o1000)Extraction passes the archive-controlled mode straight to the filesystem:
// adm-zip.js
const fileAttr = keepOriginalPermission ? entry.header.fileAttr : undefined;
filetools.writeFileTo(entryName, content, overwrite, fileAttr);
// util/utils.js
self.fs.chmodSync(path, attr || 0o666);
Note the 0.6.x call signature: extractAllTo(targetPath, overwrite, keepOriginalPermission) takes a literal boolean as its third argument.
Passing an options object such as { keepOriginalPermission: true } is
silently coerced to false, which masks the vulnerability in a test instead of
demonstrating it. Both PoC scripts in this repo pass true directly.
The fix is commit 6a63c339 (v0.6.1). fileAttr now keeps only the nine rwx bits:
get fileAttr() {
return (_attr || 0) >> 16 & 0o777;
}
keepOriginalPermission=true, extraction reads that mode through fileAttr and passes it to chmod.0o7777, setuid, setgid, and sticky survive.chmod runs as root, the new file is root-owned and can carry setuid. A later run by a less privileged user executes the file owner's code as root.With the flag left at its default, extraction does not apply the archive mode, and the special bits are not written.
Everything lives under poc/. The bundled payload is
verify.c, a benign program that prints its real and
effective uid/gid and, in its default mode, opens a shell with the effective
identity. It does not persist, hide, or do anything beyond demonstrating the
credential context.
| File | Purpose |
|---|---|
poc/verify.c | Benign setuid-verifier payload |
poc/build-archive.js | Packs a file into a zip whose entry carries mode 0o4755 |
poc/extract.js | Extracts an archive with keepOriginalPermission=true, as an affected app would |
poc/run-demo.sh | End-to-end run: build, extract as root, run as the invoking user |
git clone <this repo> && cd CVE-2026-102282
npm install [email protected] # the version under test
sudo apt install gcc # if needed
./poc/run-demo.sh # uses /var/tmp/cve-2026-102282-demo
./poc/run-demo.sh /path/to/out # or a custom output directory
Expected output against 0.6.0:
== 3. extract as root with keepOriginalPermission ==
adm-zip version: 0.6.0
extracted /var/tmp/cve-2026-102282-demo/exploit.zip into /var/tmp/cve-2026-102282-demo/extracted
== 4. result on disk ==
4755 -rwsr-xr-x root:root
== 5. run as the unprivileged user (user) ==
uid=1000(user) ...
[*] real uid=1000 gid=1000
[*] eff. uid=0 gid=0
[+] SUID active: effective uid 0 differs from real uid 1000
Expected output against 0.6.1:
== 4. result on disk ==
755 -rwxr-xr-x root:root
== 5. run as the unprivileged user (user) ==
uid=1000(user) ...
[*] real uid=1000 gid=1000
[*] eff. uid=1000 gid=1000
[-] effective and real ids match: no SUID context
Caveats for the demo:
nosuid; the script warns when it detects that mount option.sudo from your own account so step 5 has a non-root account to drop to).