【テクニカル・上級編】プロトタイプ汚染(Prototype Pollution)のメカニズムと防御的プログラミング – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

V8の深淵とプロトタイプ汚染:ランタイムの防壁を突破するセキュリティ・アーキテクチャ

JavaScriptのオブジェクトモデルは、その動的な柔軟性ゆえに美しく、同時に極めて危険な二面性を持っている。V8エンジンをじめとするモダンJavaScriptエンジンは、`Hidden Class(隠しクラス / Maps)` や `Inline Caching (IC)` を駆使し、静的言語に匹敵するオブジェクトプロパティの高速アクセスを実現している。

しかし、この最適化機構の根底にあるのは「プロトタイプチェーンによる委譲(Delegation)」という言語仕様の根本原理だ。このプロトタイプチェーンの動的な書き換え可能性を悪用したのが 「プロトタイプ汚染(Prototype Pollution)」 である。

本稿では、V8のメモリレイアウトとオブジェクト最適化の内部挙動を踏まえながら、プロトタイプ汚染がなぜRCE(リモートコード実行)へと昇華するのか、そのサプライチェーンの脅威を低レイヤから解き明かし、実戦で通用する完全な防御的プログラミングのイディオムを提示する。

—

1. V8エンジンにおけるオブジェクトの物理レイアウトと隠しクラス

プロトタイプ汚染の脅威を正確に理解するには、まずV8がメモリ上でオブジェクトをどのように表現しているかを知る必要がある。

V8のヒープ上において、すべてのJavaScriptオブジェクトは `JSObject` として表現される。これらは大きく分けて以下の2つの領域を持つ:

1. In-Object Properties(インオブジェクトプロパティ): オブジェクトの構造体内部に直接格納されるプロパティ。最もアクセスが速い。
2. Properties Store(プロパティ配列): オブジェクトが動的に拡張され、インオブジェクト領域が枯渇した際に割り当てられる別領域の配列(バックストア)。

さらに、V8は動的なオブジェクトに静的言語の構造をもたらすため、Hidden Class(V8内部用語では `Map`) をすべてのオブジェクトに付与する。Hidden Classは「どのプロパティがどのオフセット(メモリ上の位置)に存在するか」のレイアウト情報を保持している。

+———————————–+
| JSObject |
| +—————————–+ |
| | Map Pointer (Hidden Class) |–+—> [Hidden Class 0x7ffd…]
| +—————————–+ | – x: offset 0
| | Properties Pointer (BackStore)-| – y: offset 1
| +—————————–+ |
| | Elements Pointer | |
| +—————————–+ |
| | In-Object Properties (a, b) | |
| +—————————–+ |
+———————————–+

プロトタイプチェーンとインラインキャッシュ(IC)

プロパティアクセス時、V8はまず対象オブジェクトのHidden Classを参照する。もしそこに目当てのプロパティがなければ、`__proto__`(内部スロット `[[Prototype]]`)を辿って親のHidden Classへと遡る。

この走査コストを削減するのが Inline Caching (IC) だ。「直前にアクセスしたオブジェクトのHidden Classがこれと同じなら、プロパティのオフセットはここだ」というメモ化をバイトコード実行時に行う。

ここでプロトタイプ汚染が起きるとどうなるか?
グローバルな `Object.prototype` が改ざんされると、全オブジェクトのHidden ClassおよびICの前提条件が崩壊する。V8は最適化されたコード(Optimized Code)を捨ててデアオプティマイゼーション(Deoptimization)を強制され、メモリ上の至る所で予期せぬプロパティルックアップが発生してパフォーマンスが急落するだけでなく、アプリケーションのロジックが根底から乗っ取られる。

—

2. プロトタイプ汚染のメカニズム:再帰的マージの罠

プロトタイプ汚染は、主に外部からの入力(JSONリクエストや設定ファイル)を安全ではない方法で既存のオブジェクトにマージ(Deep Merge)する処理において発生する。

典型的な脆弱なマージ関数の実装を見てみよう。

// 【危険な実装例】検証なしの再帰的マージ
function unsafeDeepMerge(target, source) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === ‘object’) {
if (!target[key]) {
target[key] = {};
}
unsafeDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

const payload = JSON.parse(‘{“__proto__”: {“polluted”: “vulnerable”}}’);

const benignObject = {};
unsafeDeepMerge(benignObject, payload);

// なんということでしょう、すべてのプレーンオブジェクトが汚染されます
console.log({}.polluted); // => “vulnerable”
console.log(benignObject.polluted); // => “vulnerable”

何が起きているのか?

1. `JSON.parse` は `__proto__` という文字列キーを、通常の文字列プロパティとしてオブジェクトのキーに解釈してパースする。
2. ループ内で `key === “__proto__”` に到達した時、`target[“__proto__”]` にアクセスすると、それは実際には `Object.prototype` を指し示す(JavaScriptの仕様によるゲッターの振る舞い、またはV8内部での特殊処理)。
3. 結果として、`Object.prototype.polluted` に値が書き込まれ、メモリ上のあらゆるオブジェクトのプロトタイプチェーンを通じてそのプロパティが参照可能になってしまう。

—

3. サプライチェーンを突いたRCE(リモートコード実行)への昇華

「プロパティが生えるだけなら大したことない」と考えてはならない。Node.jsエコシステムにおいて、プロトタイプ汚染はしばしばリモートコード実行(RCE)へと直結する。

フレームワークやライブラリ(テンプレートエンジン、クエリパーサー、ORMなど)が内部でプロパティの存在チェック(`if (options.someProperty)`)や、設定値の展開を行う際、意図せぬデフォルト値や挙動のスイッチがONになってしまうためだ。

例:テンプレートエンジンや子プロセス実行の乗っ取り

例えば、ある内部ユーティリティが以下のようなコードを持っていたとする。

// 内部ユーティリティの例:オプションオブジェクトを安全と信じきっている
function executeCommand(userOptions) {
const defaultOptions = {
shell: ‘/bin/sh’,
// 開発者が想定していないプロパティがプロトタイプ経由で混入する
};

// 内部で設定を結合する処理
const finalConfig = Object.assign({}, defaultOptions, userOptions);

// もしObject.prototypeが汚染され、ここに影響を与えるプロパティがあれば…
}

さらに深刻なケースでは、Node.jsの内部モジュール(例:`child_process` や `util`、あるいはサードパーティのバリデーションライブラリ)が、オブジェクトのシリアライズや拡張を行う際に、`toString` や `valueOf`、あるいは内部制御用のプロパティ(`env`, `cwd`, `shell` など)をプロトタイプ経由で読み取り、意図しないコマンド実行やファイル読み込みを引き起こす。

—

4. ランタイムの防壁:堅牢な防御的プログラミングの実装

プロトタイプ汚染を完全に無効化し、V8のパフォーマンスを維持したまま安全なオブジェクト操作を行うための実戦的アプローチを解説する。

対策1: キーのブラックリスト化と厳密なバリデーション

マージ処理や再帰的代入を行う際は、`__proto__`, `constructor`, `prototype` といった危険なキーの侵入を徹底的に弾く必要がある。

/

  • 危険なプロパティ名であるかを検証する
  • @param {string} key
  • @returns {boolean}

/
function isSafeKey(key) {
return key !== ‘__proto__’ && key !== ‘constructor’ && key !== ‘prototype’;
}

対策2: 安全なDeep Mergeの実装

以下は、プロトタイプ汚染を完全にブロックしつつ、安全にオブジェクトを結合するプロダクションレディな実装だ。

/

  • プロトタイプ汚染対策を施した安全なDeep Merge
  • @param {Object} target
  • @param {Object} source
  • @returns {Object}

/
function safeDeepMerge(target, source) {
if (target === null || source === null || typeof target !== ‘object’ || typeof source !== ‘object’) {
return source;
}

for (const key of Object.keys(source)) {
// 危険なキーは絶対に処理しない
if (!isSafeKey(key)) {
continue;
}

const sourceVal = source[key];
const targetVal = target[key];

if (sourceVal && typeof sourceVal === ‘object’ && !Array.isArray(sourceVal)) {
// ターゲット側がオブジェクトでない、またはプリミティブの場合は空オブジェクトで初期化
if (!targetVal || typeof targetVal !== ‘object’ || Array.isArray(targetVal)) {
target[key] = {};
}
safeDeepMerge(target[key], sourceVal);
} else {
target[key] = sourceVal;
}
}

return target;
}

// ————————————————–
// 動作検証
// ————————————————–
const maliciousPayload = JSON.parse(‘{“__proto__”: {“polluted”: “hack_success”}, “safeKey”: “hello”}’);
const destination = {};

safeDeepMerge(destination, maliciousPayload);

console.log(“destination.safeKey:”, destination.safeKey); // => “hello”
console.log(“destination.polluted:”, destination.polluted); // => undefined
console.log(“Object.prototype.polluted:”, {}.polluted); // => undefined

対策3: そもそもプロトタイプを持たないオブジェクトの生成 (`Object.create(null)`)

Node.jsのバックエンドで、リクエストボディや設定データを保持する「辞書(Dictionary)」や「マップ」としてオブジェクトを使用する場合、`{}(Literal Object)` を使うべきではない。リテラルオブジェクトは自動的に `Object.prototype` を継承してしまう。

最初からプロトタイプチェーンを持たない Null-prototype objects を生成することで、プロトタイプ汚染の攻撃面(Attack Surface)を物理的に消滅させることができる。

// プロトタイプを持たない完全な孤立オブジェクト
const safeDictionary = Object.create(null);

console.log(safeDictionary.__proto__); // => undefined
console.log(Object.getPrototypeOf(safeDictionary)); // => null

// 万が一プロトタイプ汚染攻撃を受けても、このオブジェクトには影響しない
const attackerPayload = JSON.parse(‘{“__proto__”: {“polluted”: true}}’);

// safeDictionary 自体を直接汚染することはできない(__proto__はただの文字列キーとして扱われる可能性があるが、Object.keys等で安全にハンドリング可能)

※注意点として、`Object.create(null)` で作ったオブジェクトは `toString()` などの標準メソッドを持たないため、ログ出力や文字列結合時に `Object.prototype.toString.call(obj)` のように明示的なメソッド借用が必要になるケースがある。しかし、セキュリティ上のメリットはその代償を遥かに上回る。

—

5. チーフアーキテクトからの提言:ランタイムの信頼を疑え

JavaScriptの動的性は諸刃の剣だ。V8の最適化エンジンがどれほど高速にコードをコンパイルしようとも、アプリケーション層のアーキテクチャが「外部からの入力を無防備に信頼する」という設計ミスを犯していれば、いかに強固なランタイムも内側から崩壊する。

1. 外部境界の入力はすべて疑え:JSON.parse やクエリパラメータ、設定ファイルからのデータは、必ずスキーマバリデーション(ZodやAjvなど)を通すか、`Object.create(null)` をベースにした安全なデータ構造へサニタイズして取り込め。
2. ライブラリの依存関係を監査せよ:サプライチェーン攻撃の多くは、古くて脆弱なnpmパッケージ(特に古いバージョンの `lodash.merge` や各種extend系ライブラリ)に起因する。定期的な `npm audit` やSBOMの解析をCI/CDパイプラインに義務付けよ。
3. プロトタイプ凍結の検討:極端なセキュリティ要件が求められる環境では、アプリケーション起動時に `Object.freeze(Object.prototype)` を実行し、プロトタイプ自体の改ざんをランタイムレベルで不可能にするアプローチも視野に入れよ(※サードパーティライブラリがプロトタイプ拡張を行っている場合は互換性問題に注意が必要)。

コードの1行、オブジェクトの生成方法のわずかな違いが、システムの生死を分ける。V8の挙動とJavaScriptの言語仕様の裏側まで見通した上で、隙のない防壁を構築し続けよう。

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