【テクニカル・上級編】【上級者向け】JavaScriptの実行コンテキストを自作する:インタプリタの仕組みから学ぶスコープチェーンの正体 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

JavaScriptの実行コンテキストを自作する:インタプリタの仕組みから学ぶスコープチェーンの正体

JavaScriptのコードが、V8などのエンジン上でどのように実行されるかを真に理解しているエンジニアはどれほどいるだろうか。多くの開発者は、`var`、`let`、`const`の違いや、クロージャの便利さを「経験則」として知っている。しかし、エンジン内部のバイナリ実行、V8のHidden Class(隠しクラス)によるプロパティアクセスの高速化、そしてスコープチェーンの探索コストがランタイムに与える物理的影響についてまで踏み込んでいる者は少ない。

本稿では、一般的なリファレンスマニュアルの引き写しは一切行わない。JavaScriptの実行コンテキスト(Execution Context)とEnvironment Record(環境レコード)の本質を暴くため、あえてJavaScript自身で「簡易JSインタプリタ」を実装する。その過程を通じて、スコープチェーンの正体を完全に剥き出しにし、さらにはプロトタイプ汚染がV8のインラインキャッシュ(IC)をどのように破壊し、サプライチェーン攻撃におけるリモートコード実行(RCE)へと繋がるのか、その低レイヤの深層を解き明かす。

—

1. V8エンジンにおける実行コンテキストとスコープチェーンの物理構造

私たちが日常的に書くJavaScriptのコードは、そのままCPUで実行されるわけではない。抽象構文木(AST)へ変換され、Ignition(インタプリタ)によってバイトコードにコンパイルされ、ホットなコードはTurboFan(JITコンパイラ)によって最適化された機械語へと昇華される。

この実行の土台となるのが Execution Context(実行コンテキスト) である。実行コンテキストは論理的な概念ではなく、V8のヒープ上、あるいは呼び出しスタック上に構築される厳密なデータ構造だ。

Environment Recordの3層構造

ES2015以降の仕様において、スコープは単なる「ハッシュマップ」ではない。各実行コンテキストは LexicalEnvironment と VariableEnvironment を持ち、それぞれが Environment Record を指し示している。

1. Declarative Environment Record(宣言的環境レコード): `let`、`const`、`function`、`class` などの識別子を管理する。変数の物理的なメモリ配置(V8ヒープ、あるいはスタック上のスロット)に直結する。
2. Object Environment Record(オブジェクト環境レコード): グローバルスコープや `with` 文などで使用され、オブジェクトのプロパティと識別子を1対1で結びつける。
3. Function Environment Record: 関数固有の `this`、`super`、および引数(`Arguments`)を管理する。

スコープチェーン(Scope Chain)とは、このEnvironment Recordがポインタ(`outer` 参照)によって単方向連結リストとして数珠つネギ状に繋がったものである。変数にアクセスする際、V8は現在のEnvironment Recordから始まり、`outer`を辿って上位のレコードを線形探索する。この探索コストが、深いネストや動的な `with` 文によってどれほどランタイムのパフォーマンスを劣化させるか、想像に難くないだろう。

—

2. 簡易JSインタプリタの実装:Environment Recordの自作

百聞は一見に如かず。スコープチェーンの正体を完全に掌握するため、JavaScript上で変数宣言、ブロック、クロージャ、そしてスコープ解決を完全に再現するインタプリタのコアロジックを実装する。

以下のコードは、Environment Recordとスコープチェーンを自作したミニマムなランタイムシミュレータである。

/

  • 環境レコード(Environment Record)のクラス
  • 変数のストレージと、外側(親)の環境へのポインタ(outer)を保持する。

/
class EnvironmentRecord {
constructor(outer = null) {
// 変数と定数を格納するストレージ(V8の隠しクラスの簡易表現)
this.store = new Map();
// 定数(const)のバインディングを追跡するセット
this.constants = new Set();
// 巻き上げ(Hoisting)時の未初期化状態を表現するためのフラグ
this.initialized = new Set();
// スコープチェーンを形成する親環境への参照(outer pointer)
this.outer = outer;
}

/

  • 変数を宣言する(var, let, constの振る舞いを分ける)

/
define(name, value, kind = ‘let’) {
if (this.store.has(name) && kind === ‘let’) {
throw new SyntaxError(`Identifier ‘${name}’ has already been declared`);
}
this.store.set(name, value);
this.initialized.add(name);

if (kind === ‘const’) {
this.constants.add(name);
}
}

/

  • 変数の値を代入する(TDZやconstの不変性をチェック)

/
set(name, value) {
let env = this;
while (env !== null) {
if (env.store.has(name)) {
if (env.constants.has(name)) {
throw new TypeError(`Assignment to constant variable.`);
}
env.store.set(name, value);
return value;
}
env = env.outer;
}
throw new ReferenceError(`${name} is not defined`);
}

/

  • スコープチェーンを遡りながら変数を解決(ルックアップ)する

/
get(name) {
let env = this;
// スコープチェーンの探索ループ
while (env !== null) {
if (env.store.has(name)) {
if (!env.initialized.has(name)) {
// Temporal Dead Zone (TDZ) の検出
throw new ReferenceError(`Cannot access ‘${name}’ before initialization`);
}
return env.store.get(name);
}
// 親環境(outer)へポインタを辿る
env = env.outer;
}
throw new ReferenceError(`${name} is not defined`);
}
}

// — 動作検証 —

// 1. グローバル環境の作成
const globalEnv = new EnvironmentRecord(null);
globalEnv.define(‘x’, 100, ‘let’);

// 2. 関数スコープ(ローカル環境)の作成、グローバル環境を outer に設定
const functionEnv = new EnvironmentRecord(globalEnv);
functionEnv.define(‘y’, 200, ‘const’);

// スコープチェーンを通じた変数の解決
console.log(“— スコープチェーンの解決テスト —“);
console.log(“ローカルからグローバル変数 ‘x’ を参照:”, functionEnv.get(‘x’)); // 100
console.log(“ローカル変数 ‘y’ を参照:”, functionEnv.get(‘y’)); // 200

try {
// const への再代入テスト
functionEnv.set(‘y’, 300);
} catch (e) {
console.error(“キャッチしたエラー:”, e.message); // Assignment to constant variable.
}

このコードを実行すると、V8が内部で行っている「変数名のハッシュ化」「`outer` ポインタによるスコープチェーンの遡り」「`const` の不変性チェック」が、完全に脳内で再現できるようになるはずだ。

—

3. V8の最適化メカニズム:隠しクラス(Hidden Class / Map)とインラインキャッシュ

スコープチェーンの探索は、ネストが深くなるほど計算量が線形に増加する。V8エンジンはこのコストを最小化するために、Hidden Class(V8内部用語では `Map`) と Inline Caching (IC) という強力な最適化機構を備えている。

隠しクラスによる動的プロパティの静的化

JavaScriptは動的言語であり、オブジェクトに後からプロパティを追加できる。しかし、毎回プロパティ名を文字列としてハッシュ検索していたのでは、C++やRustのような速度は出せない。
V8は、オブジェクトの「プロパティの構造(どのプロパティがどのオフセットに存在するか)」を定義する隠しクラスを動的に生成する。

// V8が異なる隠しクラスを生成する例
function Point(x, y) {
this.x = x; // 隠しクラス A
this.y = y; // 隠しクラス B
}

const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
// p1 と p2 は同じ隠しクラスを共有し、メモリ上のオフセットが固定されるため高速にアクセスできる

しかし、ここで プロトタイプ汚染(Prototype Pollution) が絡むと、この最適化の防壁が一瞬で崩壊する。

—

4. プロトタイプ汚染(Prototype Pollution)が引き起こすランタイムハックとRCE

セキュリティ研究者やシニアエンジニアが最も警戒すべき脆弱性の一つがプロトタイプ汚染だ。悪意あるペイロードによって `Object.prototype` が改ざんされたとき、V8のランタイムとプロトタイプチェーンはどう挙動するのか。

プロトタイプ汚染のメカニズム

// 攻撃者が外部入力(JSONの再帰的マージ処理など)を介して引き起こす汚染
const payload = JSON.parse(‘{“__proto__”: {“polluted”: “RCE_PAYLOAD”}}’);

// 脆弱なマージ関数のシミュレーション
function vulnerableMerge(target, source) {
for (let key in source) {
if (typeof source[key] === ‘object’ && source[key] !== null) {
if (!target[key]) target[key] = {};
vulnerableMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}

let userConfig = {};
vulnerableMerge(userConfig, payload);

// すべてのオブジェクトのプロトタイプが汚染される
const ordinaryObject = {};
console.log(ordinaryObject.polluted); // “RCE_PAYLOAD”

なぜこれがV8の最適化を破壊し、RCEに至るのか?

1. インラインキャッシュ(IC)のメガモフィズム(Megamorphism)化:
V8はプロパティアクセスを高速化するため、特定のプロパティアクセスが「どの隠しクラスを指しているか」をキャッシュする(Monomorphic)。しかし、`Object.prototype` が汚染され、あらゆるオブジェクトのプロトタイプチェーンの形状が変化すると、V8はキャッシュを維持できなくなり、メガモフィック(多態性)状態に陥る。これにより、アプリケーション全体のCPU使用率が跳ね上がり、スループットが劇的に低下する(DoS攻撃の成立)。

2. 安全装置のバイパスとRCEへの飛躍:
プロトタイプ汚染単体では直ちにRCE(リモートコード実行)にはならない。しかし、これが テンプレートエンジン(Handlebars, EJSなど) や ORM(Mongooseなど)、あるいは Child Processのオプション生成ロジック と組み合わさった瞬間、形勢は逆転する。
例えば、ライブラリ内部が `options` オブジェクトのプロパティ(例: `execArgv` や `shell`)を安全だと思って参照しているとき、`Object.prototype` に不正な設定が仕込まれていると、その設定が意図せず採用されてしまう。結果として、Node.jsの `child_process.spawn()` などを経由した任意のOSコマンド実行(RCE)へと直結するのだ。

—

5. チーフアーキテクトが教える、ランタイム防壁の構築

この脆弱性とランタイムの脆弱性を完全に断つためには、コードレベルだけでなく、V8ランタイムの挙動を意識した防御的設計が不可欠である。

1. プロトタイプの完全な凍結(Object.freeze):
アプリケーションの起動時に、コアとなるプロトタイプオブジェクトを凍結する。

Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);

これにより、万が一悪意あるペイロードが実行されても、`__proto__` 経由の改ざん試行は厳格モード(strict mode)下ではエラーとなり、非厳格モードでも無視される。

2. JSON入力の厳格なサニタイズとMapの活用:
任意のオブジェクトをディープマージする際は、キー名として `__proto__`, `constructor`, `prototype` が含まれていないかを明示的にブラックリスト/ホワイトリストで検証する。また、動的なキーバリューの保持には、プロトタイプチェーンを持たない `Map` オブジェクトを使用することで、プロトタイプ汚染の影響を物理的に遮断できる。

—

結びにかえて

JavaScriptは「手軽に書けるスクリプト言語」という顔の裏に、V8エンジンという極限まで最適化された巨大なランタイムシステムを隠し持っている。実行コンテキストの生成、Environment Recordのポインタ操作、そして隠しクラスとインラインキャッシュの最適化パス――これらを脳内で正確にトレースできる者だけが、モダンWebアプリケーションのパフォーマンス限界を突破し、堅牢なセキュリティアーキテクチャを構築できる。

コードの1行がV8のヒープとCPUキャッシュにどう影響するか。その視点を忘れない限り、あなたの書くJavaScriptは、他の追随を許さない圧倒的な品質と堅牢性を備えることだろう。

タイトルとURLをコピーしました