varの巻き上げを「バグ」ではなく「仕様」として理解する:AST解析とEnvironment Recordの物理的実態
JavaScriptの歴史的遺物として語られがちな `var`。シニアエンジニアやセキュリティリセーチャであれば、「`var` は巻き上げ(Hoisting)があるから使うな」「`let` や `const` を使え」という紋切り型の解説には食傷気味だろう。
だが、V8をはじめとするモダンJSエンジンの内部構造、そしてECMAScript仕様の根幹を成す Environment Record(環境レコード) のライフサイクルを突き詰めれば、`var` の巻き上げは単なる「設計ミス」ではなく、初期のJavaScriptが採用した静的スコープ解決の極めて合理的なメカニズムであることが見えてくる。
本稿では、抽象構文木(AST)の構築からV8エンジンが実行するJITコンパイル、さらにはこの仕様の隙をついた高度なプロトタイプ汚染(Prototype Pollution)のハックに至るまで、ランタイムの深層を解剖していく。
—
1. パーサーによるAST生成とHoistingの物理的順序
JavaScriptコードがV8エンジンに投入された瞬間、スキャナーが文字列をトークン化し、パーサー(Parser)が AST(抽象構文木) を構築する。ここで重要なのは、「コードの実行順序」と「ASTの解析・宣言登録順序(Hoisting)は完全に別物である」という点だ。
以下のコードを例に取ろう。
function evaluateSystem() {
console.log(secretCode); // 1. ここで何が出力されるか?
var secretCode = “V8_INTERNAL_GATE_OPEN”;
function secretCode() {
return “FUNCTION_BLOCK”;
}
console.log(secretCode); // 2. ここでは?
}
evaluateSystem();
多くのジュニアエンジニアは「`ReferenceError` になる」あるいは「文字列が出力される」と誤認する。しかし、実際の出力は以下のようになる。
[Function: secretCode]
V8_INTERNAL_GATE_OPEN
なぜ、変数宣言よりも前で関数が実行され、さらに変数宣言が関数を上書きするという奇妙な挙動が発生するのか。これを解明するには、V8のパーシングフェーズにおけるEnvironment Recordの構築順序を覗く必要がある。
Environment Recordへの登録優先順位
ECMAScript仕様書において、関数実行コンテキスト(Execution Context)が生成される際、VariableEnvironment と LexicalEnvironment が初期化される。
パーサーが関数宣言を検知した際、以下のフェーズでシンボルがEnvironment Recordにバインドされる。
1. 関数宣言(Function Declaration)の走査と巻き上げ:
ASTのルート、あるいはブロックスコープのトップレベルにおいて、`function identifier() {}` はその識別子自体と関数オブジェクトの参照を即座にメモリ上に確保し、Environment Recordに書き込む。
2. 変数宣言(`var`)の走査と巻き上げ:
`var identifier` は識別子のみが登録され、初期値は `undefined` でスロットが確保される。すでに同名の識別子(関数宣言など)が存在する場合、変数の巻き上げは既存の関数バインディングを上書きしない(関数が優先される)。
3. 実行フェーズ(Evaluation):
コードが上から順に実行され、代入式(`secretCode = “V8_INTERNAL_GATE_OPEN”`)に到達した時点で、初めてEnvironment Record内のスロットの値が `undefined`(あるいは関数参照)から新しい文字列ポインタへと書き換わる。
ASTの段階で、すでにメモリ空間のレイアウト(隠しクラスやスコープチェーンのオフセット)は決定されているのである。
—
2. V8エンジン内部におけるスコープチェーンとメモリ最適化
`var` は関数スコープ(Function Scope)を持ち、`let` / `const` はブロックスコープ(Block Scope)を持つ。この違いは、V8のヒープメモリ管理、特に Context Allocation に決定的な影響を与える。
1. `var` が生み出す「フラットな関数スコープ」
`var` で宣言された変数は、どれだけ深い制御構文(`if` や `for`)の内部にあろうとも、最も近い親の関数スコープの VariableEnvironment に配置される。
これにより、V8はコンパイル時に変数のメモリストレージ(Context Slot)の位置を静的に確定させやすく、インラインキャッシュ(Inline Caching: IC)の最適化恩恵を受けやすい。
2. `let` / `const` が強制する「Temporal Dead Zone (TDZ)」と動的チェック
一方、`let` や `const` は LexicalEnvironment に配置され、ブロックスコープ単位で細切れに管理される。
TDZ(一時的死区間)は、単なる「エラーを出すための仕様」ではなく、「変数が初期化される前に誤ってアクセスされ、V8の最適化パイプライン(TurboFan)の型推論を汚染するのを防ぐためのハードバリデーション」である。
もし `let` が `var` のように未初期化状態でアクセスを許容した場合、JITコンパイラは変数の型を確定できず、メガモーフィック(Morphic)なコードとして扱わざるを得なくなり、実行速度が著しく低下する。
—
3. イベントループと巻き上げ変数の非同期キャプチャの罠
`var` の関数スコープ特性と非同期処理(マイクロタスク / マクロタスク)が組み合わさったとき、JavaScriptランタイム特有の致命的なバグ(あるいは脆弱性の温床)が生まれる。
以下のコードを見てほしい。
function processPayloads() {
var payloads = [
{ id: 1, data: “A” },
{ id: 2, data: “B” },
{ id: 3, data: “C” }
];
for (var i = 0; i < payloads.length; i++) { setTimeout(function() { console.log("Processing Payload ID:", payloads[i].id); }, 100 i); } } processPayloads(); これを実行すると、何が起きるか? ループが瞬時に回りきり、クロージャとしてキャプチャされた変数 `i` は最終的な値 `3` に達する。その結果、非同期コールバックが実行される頃には `payloads[3]` を参照しようとし、`TypeError: Cannot read properties of undefined (reading ‘id’)` が発生してアプリケーションがクラッシュする。
なぜ起きるのか?
`var i` は関数スコープであるため、`for` ループごとに新しい変数が作られるわけではない。スコープ全体でたった1つの変数 `i` を共有し続ける。非同期タスクがイベントループのキューからポップされ、コールスタックに積まれる頃には、すでに `i = 3` に書き換わっているのだ。
現代的アプローチによる解決
これが `let` であれば、ブロックごとに新しいバインディングが生成されるため、ループのイテレーションごとに環境が保全される(いわゆる Lexical Environment のクローンが毎回作られる)。
// letを用いた健全なイテレーション
for (let i = 0; i < payloads.length; i++) {
setTimeout(() => {
console.log(“Processing Payload ID:”, payloads[i].id);
}, 100 i);
}
—
4. セキュリティ・深層ハック:`var` とプロトタイプ汚染(Prototype Pollution)のRCEシナリオ
最後に、変数の巻き上げやスコープの挙動、そしてJavaScriptの動的なオブジェクトモデルを悪用した、サプライチェーン攻撃の深層に迫る。
プロトタイプ汚染は、一般的に `Object.prototype` に任意のプロパティを注入し、後続のオブジェクト操作をハックする攻撃手法として知られている。しかし、これをランタイムのコンテキストと組み合わせることで、リモートコード実行(RCE)へと昇華させることが可能になる。
以下は、脆弱なディープマージ関数を悪用した攻撃の概念コードである。
// 脆弱なマージ関数(よくあるサードパーティ製ライブラリの断片)
function unsafeMerge(target, source) {
for (var key in source) {
if (typeof source[key] === ‘object’ && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// 攻撃者が外部APIやJSONパース経由で注入する悪意あるペイロード
var maliciousPayload = JSON.parse(‘{“__proto__”: {“outputFunctionName”: “console.log(process.mainModule.require(\’child_process\’).execSync(\’id\’))//”}}’);
var config = {};
unsafeMerge(config, maliciousPayload);
なぜこれがRCEに至るのか?
Node.jsの内部や特定のテンプレートエンジン(例: `ejs` や古いコンパイル系ライブラリ)では、オブジェクトのプロパティを動的に評価し、内部で `new Function()` や `eval()` を用いてコード生成を行う場合がある。
もしアプリケーションがテンプレートのオプションとして `outputFunctionName` などの内部設定を受け取る構造になっており、かつ `Object.prototype` が汚染されていた場合:
1. テンプレートエンジンが設定オブジェクトからプロパティを引く際、自身のプロパティになくても、プロトタイプチェーンを遡って汚染された `Object.prototype.outputFunctionName` を掴んでしまう。
2. その値がそのままコード生成の文字列として `new Function()` に渡される。
3. 結果として、任意のOSコマンド(`child_process.execSync`)がランタイム権限で実行され、サーバーが完全に乗っ取られる。
防御の極意:ランタイムの要塞化
このようなサプライチェーン攻撃からアプリケーションを守るためには、V8のオブジェクト構造を静的にロックダウンする必要がある。
// 1. Object.prototypeの凍結(Freeze)によるプロトタイプ汚染の無効化
Object.freeze(Object.prototype);
// 2. そもそも汚染可能なキーを明示的に弾くマージ関数の実装
function secureMerge(target, source) {
for (var key in source) {
// __proto__, constructor, prototype への書き込みを物理的にブロック
if (key === ‘__proto__’ || key === ‘constructor’ || key === ‘prototype’) {
continue;
}
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 安全な再帰処理…
}
}
}
—
結言
`var` の巻き上げは、JavaScriptが「動的で緩いスクリプト言語」から「JITコンパイラによって爆速で動作するモダンなランタイム」へと進化する過程の、ひとつの歴史的遺産である。
しかし、その仕様の裏にある「AST解析の優先順位」「Environment Recordのバインディング」「スコープチェーンのメモリレイアウト」を正確に理解していれば、単なるバグの温床ではなく、V8エンジンが裏側でどうメモリを割り当て、どうコードを実行しているのかを測るための最高の指標となる。
コードの表面的な挙動に一喜一憂するのではなく、ランタイムの物理的挙動を掌中に収めること。それこそが、真のシニアエンジニア、そしてセキュリティの防衛者に求められるアトリビューションなのだ。