vm2サンドボックスからの脱出に関する脆弱性(Node.js 26)
## 報告者
- 名前またはハンドルネーム: `[YMsora]`
- 報告日: 2026年8月14日
## 概要
最新の公開されたvm2のバージョン(**3.11.5**)および現在の`main`ブランチは、Node.js 26で使用される場合に、サンドボックスからの脱出の脆弱性があります。非同期関数によって作成された通常のfulfilled Promiseは、`Promise.prototype.finally()`を通じて、攻撃者が制御できる`constructor[Symbol.species]`を保持し続ける可能性があります。
vm2は、ネイティブの`Promise.prototype.then`および`catch`メソッドに対してラッパーをインストールします。Node.js 26 / V8 14.6では、V8の`SetPrototypeProperties`の処理が、既存のデータプロパティを更新しますが、`PromiseThenLookupChain`プロテクターを無効にしません。その後、`Promise.prototype.finally()`は、この古いプロテクターを信頼し、ネイティブの`then`を呼び出す内部の高速パスを使用します。これにより、vm2のラッパーおよびその`resetPromiseSpecies(this)`による保護がバイパスされます。
攻撃者が制御する種(species)のコンストラクタは、ネイティブPromiseの反応で使用されるresolveおよびreject関数を提供します。そのネイティブ反応の境界で調整されたスタックオーバーフローが発生すると、攻撃者が制御するreject関数に、ホスト環境の`RangeError`が引き起こされます。そのコンストラクタチェーンは、ホストの`Function`コンストラクタ、そして最終的にホストの`process`オブジェクトに到達します。
添付された検証は、破壊的な操作を行いません。`process.version`のみを読み込みます。コマンドの実行、ファイルのアクセス、ネットワークリクエストは行いません。
## 最新バージョンの状況
2026年8月14日に検証済み:
- npm `latest`: `vm2@3.11.5`
- `vm2@3.11.5`の公開日時: 2026年5月18日15:37:26.138Z
- npm `gitHead`: `7a1f5100b96f48d34e0fe104ab37c0acc5944f92`
- npm tarball: `https://registry.npmjs.org/vm2/-/vm2-3.11.5.tgz`
- npm integrity: `sha512-RSrkBiwrj6FRU+QdqNs6KG0XdlvJCjpQ4GXiqmMbrhmwfu5k/XIMpAer0L8f6iuf0uJ3a4T1xJN126Q8yf0VIA==`
- GitHubのデフォルトブランチ`main`のHEAD: 上記と同じコミットで、`v3.11.5`というタグも付与されています。
- 最新の正式なNode.jsリリース: `v26.7.0`、V8 `14.6.202.34`
npmのtarballと、現在のGitHubの`main`ブランチには、同じ`lib/setup-sandbox.js`というGitのオブジェクトが含まれています。このレポート作成時点では、`main`ブランチに未公開の修正はありません。
恒久的なソース参照先:
<https://github.com/patriksimek/vm2/blob/7a1f5100b96f48d34e0fe104ab37c0acc5944f92/lib/setup-sandbox.js#L345-L390>
## 影響を受ける設定
### Node.js 26.7.0でテストされたvm2のバージョン
| vm2のバージョン | 結果 |
| --- | --- |
| 3.10.2から3.10.5 | ホスト領域に到達、リリースごとに3/3 |
| 3.11.0 | ホスト領域に到達、3/3 |
| 3.11.1 | ホスト領域に到達、3/3 |
| 3.11.2 | ホスト領域に到達、5/5 |
| 3.11.3 | ホスト領域に到達、3/3 |
| 3.11.4 | ホスト領域に到達、5/5 |
| 3.11.5 | ホスト領域に到達、5/5 |
| 現在の`main`ブランチ (`7a1f5100`) | ホスト領域に到達、5/5 |
したがって、このレポートは、**vm2の3.10.2から3.11.5**のバージョンが、脆弱なランタイムで使用された場合に影響を受けることを確認しています。以前のvm2のバージョンについては、個別に過去の既知の問題によって脆弱である可能性はありますが、それがこの根本原因を確立するものではありません。
### 標準のvm2 3.11.5を使用したNode.jsのバージョン
| Node.js | V8 | 結果 |
| --- | --- | --- |
| 22.23.2 | 12.4.254.21 | 安全、5/5 |
| 24.19.0 | 13.6.233.17 | 安全、5/5 |
| 25.9.0 | 14.1.146.11 | 安全、5/5 |
| 26.0.0 | 14.6.202.33 | ホスト領域に到達、5/5 |
| 26.1.0から26.7.0までのすべての正式リリース | 14.6.202.34 | ホスト領域に到達、リリースごとに5/5 |
| v27.0.0-nightly202608131b2de5e052 | 14.6.202.34 | ホスト領域に到達 |
| v27.0.0-v8-canary20260812f3fe529a1e | 15.3.55 | 安全 |
この脆弱な動作は、Linux/musl、Linux/glibc、およびWindows x64で再現されました。これはAlpine Linuxに特有の問題ではありません。
## 設定要件
デフォルトの設定が影響を受けます。
```js
const vm = new VM();
```
デフォルトの`new VM()`は、Node.js 26.7.0とvm2 3.11.5で、オペレーティングシステムのデフォルトのNodeスタックサイズを使用して、3/3で検証されました。
このエスケープは、より強力な設定でも再現可能です。
```js
const vm = new VM({
allowAsync: true,
eval: false,
wasm: false,
timeout: 5000
});
```
したがって、この脆弱性を利用するには、以下のものが**必要ありません**。
- WebAssemblyまたはJSPI
- 動的な評価機能の有効化
- Bufferオブジェクト
- 公開されたホストオブジェクト
- 埋め込み側が提供するカスタムPromise
- 特定のオペレーティングシステム
- 脆弱性固有のコード
このデモンストレーションでは、非同期関数を使用しているため、非同期機能が利用可能である必要があります。これは、vm2のデフォルト設定です。
vm2がラッパーをインストールする際、V8の`PromiseThenLookupChain`プロテクターは依然として有効である必要があります。新規またはデフォルトのNode.jsプロセスはこの条件を満たします。関連性のない、以前の変更によってプロテクターが無効化される場合、この脆弱性を安全に回避できますが、これはvm2の公式な対策ではなく、デフォルト設定には含まれていません。
## 再現方法
添付ファイル`poc.js`は、公開されているnpmパッケージのみを使用し、無害なホストバージョンのマーカーを出力します。
```sh
mkdir vm2-node26-repro
cd vm2-node26-repro
npm init -y
npm install --ignore-scripts --no-audit --no-fund vm2@3.11.5
cp /path/to/poc.js .
node poc.js
```
または、公式のNode.js 26.7.0コンテナを使用する場合:
```sh
docker run --rm -v "$PWD:/poc:ro" -w /tmp node:26.7.0-bookworm-slim sh -lc '
npm init -y >/dev/null 2>&1 &&
npm install --ignore-scripts --no-audit --no-fund vm2@3.11.5 >/dev/null 2>&1 &&
cp /poc/poc.js . &&
node poc.js
'
```
出力例:
```json
{
"node": "v26.7.0",
"v8": "14.6.202.34-node.28",
"vm2": "3.11.5",
"config": "default",
"result": "HOST",
"hostVersion": "v26.7.0",
"speciesCalls": 1,
"localError": false,
"localRangeError": false
}
```
キャリブレーションされた深さは、プラットフォームやプロセス構成によって異なります。これは予想される動作です。このPoCは、固定された深さに依存するのではなく、境界を検索します。
`eval`とWebAssemblyを無効にしても不十分であることを確認するには、次のコマンドを実行します。
```sh
STRICT=1 node poc.js
```
## 技術的な根本原因
1. vm2は、組み込みの`Promise.prototype.then`および`catch`メソッドを、コールバックをサニタイズし、`resetPromiseSpecies(this)`を実行するラッパーで置き換えます。
2. これらのプロトタイプへの書き込みは、`lib/setup-sandbox.js`において、連続した直接的な代入です。
3. V8 14.6では、`proto_assign_seq_opt`という最適化が有効になり、この代入シーケンスが`SetPrototypeProperties`に統合されます。
4. Node.js 26の実装において、既存のデータプロパティの処理において、`Object::SetDataProperty(&it, value)`が、まず`it.UpdateProtector()`を呼び出すことなく実行されます。
5. JavaScriptのプロパティは、vm2のラッパーを含むようになりますが、V8の`PromiseThenLookupChain`プロテクターが誤って有効なままになります。
6. `Promise.prototype.finally()`は、内部的に`InvokeThen`を実行します。プロテクターが有効であると判断されるため、V8は、vm2のラッパーに到達する可能性のある、観測可能なプロパティの参照ではなく、ネイティブなPromiseの`then`メソッドを直接選択します。
7. ラッパーは実行されないため、攻撃者が提供した`constructor[Symbol.species]`が、ネイティブな`then`が結果の能力を作成する前にリセットされません。
8. ネイティブなPromiseの処理において、攻撃者が提供した能力の解決関数が呼び出されます。調整されたスタック境界において、V8はホストのrealmの`RangeError`を作成し、vm2による変換なしに、攻撃者が提供した能力の拒否関数に渡します。
9. その結果、`error.constructor.constructor`は、VMが`eval:false`で構成されていた場合でも、ホストの`Function`になります。
根本原因の制御は強力です。
```sh
node --no-proto-assign-seq-opt poc.js
```
Node.js 26.7.0では、これにより結果が`HOST`から`SAFE`(3/3)に変わります。別の意味的なテストでも、`wrapperCalls=0`から`wrapperCalls=1`に変わります。
## 以前のアドバイザリーとの関連性
このレポートは、意図的に以前の作業との重複を明らかにしています。
### CVE-2026-22709 / GHSA-99p7-6v5w-7xg8
この問題は、非同期関数から返される組み込みPromiseのコールバックサニタイズを回避することに関連していました。vm2の修正では、`globalPromise.prototype.then`と`catch`のラッパーを追加または強化しました。現在の問題はこれとは異なります。V8 14.6では、`finally()`が古い保護メカニズムを信頼し、エンジン側の高速パスでインストールされたラッパーを回避します。
### CVE-2026-47208 / GHSA-76w7-j9cq-rx2j
この問題は、`localPromise`の例外を処理する部分で、`resetPromiseSpecies`の呼び出しが不足していたことが原因でした。3.11.4で修正済みと報告されています。現在の問題は、組み込みの非同期Promiseと`finally()`/`InvokeThen`の組み合わせによるものです。この問題は、3.11.4と3.11.5の両バージョンで再現されます。
### CVE-2026-47210 / GHSA-6j2x-vhqr-qr7q
この問題は、JSPIをバックエンドとするPromiseとWebAssembly APIが必要でした。3.11.4の修正では、関連するJSPIのインターフェースが削除されました。現在のPoCでは、通常のfulfilledな非同期関数のPromiseを使用し、`wasm:false`でも動作し、3.11.5で再現されます。
### 公開されているV8の修正
この問題の根本原因であるV8の保護メカニズムに関するバグは、すでに公開されており、V8のアップストリームで修正されています。
- data-property branch: <https://github.com/v8/v8/commit/7b26e81087ec88c287d9a4812f969d412f32774e>
- follow-up lazy-accessor branch: <https://github.com/v8/v8/commit/4635ddd85839461c8ad791990023a477efefe7bd>
このレポートは、新しいV8のバグの発見を主張するものではありません。これは、以前に文書化されておらず、まだ修正されていない、vm2のサンドボックスからの脱出に関するものです。この脱出は、V8のバグとvm2のPromiseの保護機能の相互作用によって引き起こされます。この問題は、vm2の最新リリースと現在のメインブランチに影響を与えます。
## 影響
vm2の`VM`内で信頼できないJavaScriptを実行できる攻撃者は、サンドボックスの境界を越え、ホストの`Function`コンストラクタと`process`オブジェクトを取得できます。これにより、ホストのNode.jsプロセスの権限で任意のコードを実行できるようになり、ファイルシステム、認証情報、環境、ネットワーク、および子プロセス機能へのアクセスが可能になります。
これは、vm2が意図しているセキュリティ境界であり、サンドボックス内で攻撃者によって制御されたコードを実行すること以外に、追加のアプリケーション側のミスは必要ありません。
## 推奨される対策
1. 組み込みの`then`と`catch`のラッパーを、V8のPromiseの参照チェーン保護機能を確実に無効にする操作(適切な`Reflect.defineProperty`または`Object.defineProperty`を使用するなど)によってインストールし、最適化可能な連続した直接代入シーケンスを使用しないでください。
2. 組み込みの`Promise.prototype.finally`のエントリポイントをラップし、キャッシュされたネイティブ`finally`の実装に委譲する前に、`resetPromiseSpecies(this)`を実行してください。
3. 組み込みのPromiseを使用して、ビルド/ランタイムの回帰テストを追加します。具体的には、`(async () => 1)()`を使用します。攻撃者によって制御される独自のSpeciesが、`p.finally()`によって構築されるべきではありません。
4. vm2のリリースが利用可能になるまで、Node.js 26が影響を受けるバージョンとして記録するか、脆弱なランタイム範囲に対して安全策を講じてください。
5. Node.jsと連携して、既存のV8保護機能の修正をNode.js 26のリリースラインに適用してください。
検証された対策として、キャッシュされたネイティブ実装を呼び出す前に`finally`をラップすることで、テストされた直接的なバリエーションがブロックされることが確認されています。また、V8側の修正も、正しいラッパーの呼び出しを復元します。
貢献者は`YMsora`です。`https://github.com/YMs0ra`
## 添付ファイル
[vm2-node26-finally-private-report.zip](https://github.com/user-attachments/files/31059187/vm2-node26-finally-private-report.zip)
影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは3.10.2以上、3.11.6以下、3.11.7で修正済み。