vm2: NodeVMの組み込み機能の許可リストを回避する方法:node:test.run() execArgvによるサンドボックスからの脱出

## 概要

Node.js 24以降のバージョンでは、`vm2`が、組み込み側が明示的に`node:test`の組み込み機能を許可している場合、サンドボックス化された`NodeVM`コードに対して、ホストの`node:test`モジュールを公開する可能性があります。サンドボックス内のコードは、`require('node:node:test')`を通じてそのモジュールにアクセスし、攻撃者が制御する`execArgv`を使用して`run()`を呼び出すことができます。

`node:test.run()`は、プロセス分離されたテスト実行のために別のNodeプロセスを開始し、提供された`execArgv`の値をそのプロセスに渡します。したがって、`--eval=<JavaScript>`を指定すると、制限のないホストNodeプロセスで任意のJavaScriptが実行され、`NodeVM`のサンドボックスから抜け出すことができます。

PoC(概念実証)により、サンドボックスから`fs`、`child_process`、`module`、および`process`を直接インポートしようとすると、生成されたプロセスがホストの`fs`をインポートして無害なマーカーを書き込むまでは拒否されることが確認されました。

## 影響を受けるバージョンと環境

- パッケージ:`vm2`
- 影響を受けるバージョン:`>=3.9.6, <=3.11.5`
- 最新の再現バージョン:`3.11.5`
- 再現されたランタイム:Node.js `v24.18.0`
- 正確なパスはNode.js 22には存在しません。これは、`module.builtinModules`がスキームのみの`node:test`エントリを公開していないためです。
- 構成の前提条件:

```js
require: {
builtin: ['node:test'],
external: false
}
```

下限バージョンは直接テストされました。`vm2@3.9.5`では`require('node:node:test')`がブロックされますが、`vm2@3.9.6`ではこの脆弱性を悪用するパスが許可されます。また、`3.11.5`までの代表的なバージョンでも再現されました。

## 根本原因

この問題は、組み込み機能の許可、汎用的なホストへのパススルー、およびプレフィックスの正規化の組み合わせによって引き起こされます。

1. Node.js 24以降では、`module.builtinModules`にスキームのみのキー`node:test`が含まれます。
2. `lib/builtin.js`は、この配列から`BUILTIN_MODULES`を構築します。ファミリーベースの`DANGEROUS_BUILTINS`による保護には`test`が含まれていないため、`node:test`は依然として許可対象となります。
3. 組み込み側が明示的に`node:test`を許可すると、`addDefaultBuiltin()`は、汎用的なローダーを通じてそれを登録します。

```js
builtins.set(key, special ? special : vm => vm.readonly(hostRequire(key)));
```

4. `lib/setup-node-sandbox.js`において、`requireImpl()`関数は、組み込みモジュールを検索する前に、`node:`というプレフィックスを1つ取り除きます。

```js
if (localStringPrototypeStartsWith(filename, 'node:')) {
id = localStringPrototypeSlice(filename, 5);
nmod = loadBuiltinModule(id);
}
```

5. その結果、サンドボックス環境で`node:node:test`を要求すると、保存されているキー`node:test`に変換され、ホストモジュールの読み取り専用プロキシが提供されます。
6. この読み取り専用プロキシは、`node:test.run()`を安全にするものではありません。呼び出しはホストの実装に転送され、攻撃者が制御する`execArgv`が、新たに生成されたNodeプロセスに渡される可能性があります。
7. `--eval=<攻撃者のJavaScript>`を実行すると、vm2の外で実行され、通常のホストの組み込みモジュールにアクセスできます。

この二重のプレフィックスは、アクセス制御のメカニズムですが、セキュリティ境界の脆弱性はさらに広範囲に及びます。汎用的なホスト経由のローダーは、`test`という組み込みモジュール群を安全であると見なしていますが、その`run()` APIは、制限のないNodeプロセスを起動する可能性があります。

## 実証コード

`poc`ディレクトリから以下のコマンドを実行します。

```bash
npm ci --ignore-scripts --no-audit --no-fund
node repro.js
```

Node.js 24以降で期待される成功結果は以下の通りです。

```json
{
"vm2Version": "3.11.5",
"nodeVersion": "v24.18.0",
"markerExists": true,
"childIsDistinctProcess": true,
"marker": {
"hostCodeExecution": true
}
}
```

このPoCは、自身のディレクトリにのみ`host-rce-marker.json`というファイルを書き込み、シェルを起動したり、ネットワークサービスに接続したり、サードパーティのデータにアクセスしたりすることはありません。

## 影響

影響を受ける`NodeVM`の設定において、攻撃者が意図的に信頼できないJavaScriptを実行することを許可されている場合、サンドボックスから脱出し、埋め込み側のオペレーティングシステムのIDで任意のJavaScriptを実行する可能性があります。

これにより、生成されたプロセスは、ホストユーザーのファイルシステム、環境、ネットワーク、およびプロセス実行権限にアクセスできるようになります。したがって、ホストサービス全体に対する機密性、完全性、および可用性の影響が生じる可能性があります。

## 修正提案

正規化された `test` という組み込み関数群は、ワイルドカード展開や明示的な組み込み関数登録が行われる前に、危険なものとして扱うようにしてください。

例えば、`DANGEROUS_BUILTINS` に `test` を追加することで、既存のプレフィックスと関数名のチェックで、`node:test` と `node:test/reporters` の両方を拒否することができます。

```js
const DANGEROUS_BUILTINS = new Set([
// 既存のエントリ
'test'
]);
```

もし `test` のヘルパー関数を公開する必要がある場合は、`run()` 関数、プロセス分離、`execArgv`、またはその他のホストプロセスの制御機能が公開されないように、サンドボックス内で `mock` または `override` を使用したラッパーを提供してください。

推奨されるテストケース:

- 明示的な `builtin: ['node:test']` の設定
- ワイルドカードを使用した組み込み関数の設定
- `require('node:test')` の実行
- `require('node:node:test')` の実行
- `node:test/reporters` およびその派生形
- 直接的な低レベルの組み込み関数の登録
- テストランナーのプロセスオプションを通じて `--eval`, `--require`, または `--import` を渡す試み
[vm2-node-test-ghsa-submission.zip](https://github.com/user-attachments/files/29930119/vm2-node-test-ghsa-submission.zip)

影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響範囲は 3.9.6 以降、3.11.6 以下、3.11.7 で修正済み。