vm2サンドボックスからの脱出:WebAssembly.compileStreaming Promiseの種別バイパス

## 概要

vm2のバージョン`3.11.5`または最新版において、Node.js 26上で使用すると、サンドボックスからの脱出の脆弱性が存在します。この問題は、デフォルトの`new VM()`サンドボックスから到達可能です。`NodeVM`、`require`の許可、ホストオブジェクトの注入、または意図的に安全でない構成は必要ありません。

この脱出は、GHSA-6j2x-vhqr-qr7qで対処されたセキュリティ上の制約を回避するものです。以前の修正では、JSPIのエントリーポイントである`WebAssembly.promising`と`WebAssembly.Suspending`が削除されました。これらのAPIは、vm2のPromiseの保護またはブリッジ層によってインターセプトされないホスト領域の`Promise.prototype`へのパスを公開していたためです。

同様に、`WebAssembly.compileStreaming`と`WebAssembly.instantiateStreaming`を通じて、この脆弱なクラスにアクセスできるようになっています。Node 26では、これらのストリーミングAPIは、ホスト領域のPromiseパスを生成し、ホスト領域のエラーで拒否される可能性があります。サンドボックス内のコードが`Promise.prototype.finally`を通じて`Symbol.species`を制御することで、この生のホストエラーオブジェクトを受け取り、ホストエラーのコンストラクタからホストの`Function`コンストラクタへ、そして実際のホスト`process`オブジェクトへと辿り、それを取得することができます。

この概念実証では、まず、デフォルトのVMにおいて、`process`、`require`への直接アクセス、およびコンストラクタベースの脱出がブロックされていることを示します。次に、ホストの`process`に到達し、取得されたPIDが親Nodeプロセスと一致することを確認し、ホストの`fs`を使用して無害なマーカーファイルを書き込みます。

## 影響

これはサンドボックスからの脱出です。vm2の実行を攻撃者が制御するJavaScriptに公開しているアプリケーションでは、この問題は、サンドボックスを実行しているNode.jsプロセスのコンテキストで、ホストコードの実行につながる可能性があります。

これは、デフォルトではリモートコード実行ではありません。リモート実行の可能性は、組み込みアプリケーションに依存します。正確な表現は以下の通りです。

> Node 26上のvm2の`VM`サンドボックスにJavaScriptを供給できる攻撃者は、vm2の境界を突破し、ホストNode.jsの機能を取得することができます。

これは、vm2をセキュリティ境界として使用するサービスにとって重要です。対象となるのは、プラグイン実行エンジン、ワークフローエンジン、自動化プラットフォーム、オンラインコード実行環境、ブラウザ自動化サンドボックス、およびAIエージェントやユーザースクリプトの実行環境などです。

このPoC(概念実証)は、OSの一時ディレクトリにマーカーファイルのみを書き込みます。このマーカーは、意図的に無害です。セキュリティへの影響は、このマーカー自体ではなく、コントロールケースにおいて、サンドボックス制御のコードが通常はブロックされるはずの、ホストの`process`オブジェクトやホストモジュールにアクセスできるという点にあります。

## テストされたバージョンと構成

vm2のバージョン`3.11.5`を、コミット`7a1f5100b96f48d34e0fe104ab37c0acc5944f92`でテストしました。

このエスケープは、Node.js `26.2.0`で再現されました。また、同じPoCをNode.js `20.20.2`、`22.22.3`、および`24.15.0`でもテストしましたが、これらのバージョンでは、この方法でホストプロセスにアクセスできませんでした。

このテストケースでは、デフォルトの`VM`境界のみを使用します。

```js
const { VM } = require('./lib/main');
const vm = new VM({ timeout: 8000 });

vm.run(attackerControlledJavaScript);
```

`NodeVM`は使用していません。サンドボックスには、`require`、`process`、`fs`、`child_process`、ホストコールバック、またはホストオブジェクトは提供されていません。このPoCは、`vm.run()`に渡されるJavaScript文字列のみを制御します。

バージョン依存性は、最終的な配信段階にあるようです。Node 26では、`WebAssembly.compileStreaming`からのホストレルムの拒否が、攻撃者が制御する`finally`/`Symbol.species`の機能に影響を与えますが、同じパスは、私のNode 20/22/24のテストでは悪用できませんでした。しかし、この修正はバージョンに依存しないものとして扱うべきです。サンドボックスコードは、WebAssemblyストリーミングAPIからこの生のホストPromiseパスを一切受け取ってはなりません。

## 根本原因

セキュリティ境界は、vm2が、サンドボックスコードからアクセス可能なPromiseオブジェクトを、以下のいずれかの安全な形状に保つことに依存しています。

1. **サンドボックス環境のPromise。** PromiseはサンドボックスのPromiseプロトタイプチェーンを使用するため、vm2のPromiseの保護機能によって、オブジェクトの種類を固定し、コールバックを検証することができます。
2. **ブリッジ経由のホスト環境Promise。** Promiseはvm2の境界を越えるホストオブジェクトであるため、ブリッジのトラップとコールバックの検証機能が適用されます。

`WebAssembly.compileStreaming`と`WebAssembly.instantiateStreaming`は、3つ目の形状である「生のホスト環境Promiseパス」を導入します。このパスはサンドボックスコードから直接アクセス可能であり、ブリッジ経由でプロキシされません。これは、JSPIの問題を引き起こしたオブジェクトと同じ種類です。

脆弱性の利用可能性は、以下の2つの条件が同時に満たされる場合に依存します。

- ストリーミングWebAssembly APIが、サンドボックス環境ではなくホスト環境のPromise機構を持つPromiseパスを返すこと。
- 無効なストリーミングソースを渡すことによって生成されるエラーが、NodeのWebAssemblyストリーミング実装から発生するホスト環境の`TypeError`であること。

このホスト環境のエラーが攻撃者が制御するコードに渡されると、通常のホスト環境のコンストラクタの探索が可能になります。

```js
hostError.constructor.constructor('return process')()
```

この式は、エラーオブジェクトがホスト環境であるため、サンドボックス環境ではなく、ホスト環境の`Function`コンストラクタを通じて解決されます。

## 脆弱性の流れ

脆弱性の流れは小さいですが、重要なのはレルムの境界です。

1. サンドボックス内のコードが `WebAssembly.compileStreaming(0)` を呼び出します。
2. 引数が有効な `Response` オブジェクトまたは `Response` オブジェクトを解決する Promise ではないため、返される Promise が拒否されます。
3. Node 26 では、拒否される値はホスト環境のエラーオブジェクトです。
4. 攻撃者は、返された Promise に対して制御可能な `constructor` アクセサをインストールし、カスタムの `Symbol.species` コンストラクタを提供します。
5. `p.finally(() => {})` を呼び出すと、`Promise.prototype.finally` が使用する species パスに到達します。
6. `NewPromiseCapability(F)` は、攻撃者が制御するコンストラクタ `F` を呼び出し、結果のキャパシティ関数を公開します。
7. Promise が拒否されると、ホスト環境のエラーが攻撃者が制御する拒否パスに渡されます。
8. 攻撃者は、ホストエラーのコンストラクタチェーンを使用して、ホストの `process` を復元します。
9. PoC は、ホストの `fs` を `process.mainModule.require('fs')` を介してロードし、無害なマーカーファイルを書き込みます。

`Promise.prototype.finally` は重要です。なぜなら、これは vm2 の強化された `then`、`catch`、および静的な Promise ヘルパーのように同じ方法で保護されていない、残りの species 依存の Promise コンビネータだからです。しかし、サンドボックスの `Promise.prototype.finally` を単純にラップするだけでは、このバグを修正するには不十分です。危険な Promise パスは、生のホスト側の Promise の仕組みであり、サンドボックス自身の `finally` ラッパーが、この呼び出しを保護するメソッドではないためです。危険なソースを削除するか、安全にラップする必要があります。

## GHSA-6j2x-vhqr-qr7q との関係

これは、オリジナルの JSPI パスではありません。現在の HEAD では、`WebAssembly.promising` と `WebAssembly.Suspending` が削除されており、古い PoC はブロックされています。

この回避策は、異なるソースを介して、同じ危険な Promise の形状に到達します。それは `WebAssembly.compileStreaming` / `WebAssembly.instantiateStreaming` です。そのため、これは、すでに修正された JSPI の問題の重複ではなく、GHSA-6j2x クラスに対する未完の修正として報告されています。

## 実証コード

PoC は、以下のように別途提供されています。

[escape-poc.js](https://github.com/user-attachments/files/28443205/escape-poc.js)

このPoC(概念実証)では、特権的なオブジェクトを一切公開しないデフォルトの`new VM()`を使用しています。マーカーファイルは、サンドボックス化されたコードがホスト側の機能を回復したことを示す、無害な証拠にすぎません。

PoCは、最初に4つの制御チェックを実行します。

```text
プロセスアクセス: ブロック
requireアクセス: ブロック
コンストラクタプロセスアクセス: ブロック
コンストラクタrequire(fs): ブロック
```

その後、エクスプロイトを実行し、回復されたプロセスが実際のホストプロセスであることを、回復されたPIDと親プロセスのPIDを比較することで検証します。

Node 26.2.0で期待される脆弱な出力:

```text
[control] プロセスアクセス: ブロック
[control] requireアクセス: ブロック
[control] コンストラクタプロセスアクセス: ブロック
[control] コンストラクタrequire(fs): ブロック
[exploit] ホストプロセスに到達: はい
[exploit] ホストPID: <PID>
[exploit] ホストPIDが親PIDと一致: はい
[exploit] ホスト実行パス: <Node実行ファイル>
[exploit] ホストバージョン: v26.2.0
[exploit] マーカーファイル: 作成済み
[result] 脆弱
```

Node 24.15.0で期待される出力:

```text
[control] プロセスアクセス: ブロック
[control] requireアクセス: ブロック
[control] コンストラクタプロセスアクセス: ブロック
[control] コンストラクタrequire(fs): ブロック
[exploit] ホストプロセスに到達: いいえ
[exploit] マーカーファイル: 作成されていない
[result] このNodeバージョンでは再現できませんでした
```

マーカーファイルには、証明文字列、Nodeのバージョン、PID、およびタイムスタンプのみが含まれています。

検証のために、コミット`7a1f5100b96f48d34e0fe104ab37c0acc5944f92`の最新の公開クローンから、Node 26の結果を再現しました。`lib/`は変更されておらず、PoCファイルのみがコピーされました。

## 推奨される修正

最小限の修正は、すでにJSPI APIを削除しているWebAssemblyのセキュリティ強化ブロックと同じ場所から、残りのストリーミングWebAssembly APIをサンドボックスから削除することです。

```js
if (typeof WebAssembly.compileStreaming !== 'undefined') {
localReflectDeleteProperty(WebAssembly, 'compileStreaming');
}
if (typeof WebAssembly.instantiateStreaming !== 'undefined') {
localReflectDeleteProperty(WebAssembly, 'instantiateStreaming');
}
```

この修正は、Node 26.2.0 および Node 24.15.0 での PoC (概念実証) に対して有効です。この修正を適用すると、ストリーミング API はサンドボックス内で利用できなくなり、PoC はホストプロセスを復元できなくなり、マーカーファイルは書き込まれません。

追加のセキュリティ強化に関する推奨事項は以下の2点です。

1. 既存の Promise のセキュリティ強化と一貫性を持たせるため、`Promise.prototype.finally` の強化パスを追加します。これだけではこの問題は解決できませんが、サンドボックス環境の Promise に関する既知の脆弱性を解消します。
2. サンドボックスからアクセス可能な Promise を生成する内部関数が、ブリッジを介して生のホスト側の Promise オブジェクトを外部に公開しないことを確認する、回帰テスト/不変性テストを追加します。

ストリーミングではない WebAssembly の Promise API も確認しました。エンドツーエンドのエスケープは、`compileStreaming` および `instantiateStreaming` を介して再現されましたが、`compile` または `instantiate` を介しては再現されませんでした。したがって、最小限の修正は、確認された脆弱性のあるストリーミング API の2つを削除するものです。より広範な不変性は、サンドボックス内のコードが生のホスト側の Promise オブジェクトを受け取ってはならないということです。

## 回帰テスト

回帰テストは、直接的な修正とエンドツーエンドの不変性の両方をカバーする必要があります。

1. `WebAssembly.compileStreaming` が利用できないか、または `new VM()` 内で安全にラップされていること。
2. `WebAssembly.instantiateStreaming` が利用できないか、または `new VM()` 内で安全にラップされていること。
3. PoC が Node 26 上でホストの `process` を復元できないこと。
4. `process`、`require` への直接アクセス、およびコンストラクタベースのプロセスへのアクセスは引き続きブロックされていること。
5. `finally` + スピーシーズプリミティブが、WebAssembly から返される Promise を介してホストの `process` に到達しないこと。

影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響あり >= 3.10.1, <= 3.11.6、3.11.7 で修正済み。