プロトタイプ汚染:モダンJSの根幹を揺るがす「静かなる毒」
フロントエンドからバックエンド(Node.js)まで、JavaScriptがインフラの隅々まで支配するようになって久しい。だが、この言語の持つ独特の動的性質、すなわち「プロトタイプベースの継承モデル」は、設計を誤ればアプリケーション全体を内側から崩壊させる致命的なアキレス腱となる。
世間の多くの開発者は、SQLインジェクションやXSSには神経をとがらせるが、Object.prototype の改ざん——いわゆるプロトタイプ汚染(Prototype Pollution)に対しては、驚くほど無防備だ。この脆弱性は、派手なクラッシュを引き起こすことなく、アプリケーションの実行コンテキストを密かに書き換える。認証バイパス、リモートコード実行(RCE)、あるいは意図しないプロパティの注入など、攻撃者にとってこれほど「コスパの良い」プリミティブはそうそうない。
今回は、このプロトタイプ汚染の低レイヤにおける挙動から、実戦的なエクスプロイトのシーケンス、そして現場で通用する泥臭い防御アーキテクチャまでを徹底的に解剖する。
—
1. 根本原因:JavaScriptのメモリモデルとプロトタイプチェーン
なぜプロトタイプ汚染が起きるのか。その本質を理解するには、V8エンジンなどのJavaScriptエンジンがオブジェクトのプロパティをどのようにメモリ上で解決しているかを知る必要がある。
JavaScriptでは、すべてのオブジェクトが別のオブジェクト(プロトタイプ)への隠し参照(一般に __proto__ または内部スロット [[Prototype]])を持つ。あるオブジェクトのプロパティにアクセスした際、自前でそのプロパティを持っていなければ、エンジンはプロトタイプチェーンを辿り、親オブジェクト(最終的には Object.prototype)からプロパティを探し出す。
ここで問題になるのは、オブジェクトのキーが動的に指定・拡張される言語仕様だ。もし、再帰的なオブジェクトのマージ処理やディープクローン処理において、入力値のキーに対するバリデーションが欠如していた場合、攻撃者は __proto__ や constructor.prototype といった特殊なキーをペイロードに含めることができる。
以下の脆弱なマージ関数の実装を見てほしい。
/**
* 脆弱な再帰的マージ関数(典型的なアンチパターン)
* 入力値のキーに対するサニタイズを行っていないため、プロトタイプ汚染を引き起こす。
*/
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];
}
}
return target;
}
// 攻撃者が外部から注入する悪意あるJSONペイロードの例
const maliciousPayload = JSON.parse('{"__proto__": {"polluted": "1337"}}');
const targetObj = {};
vulnerableMerge(targetObj, maliciousPayload);
// 影響の確認
// targetObj自体には "polluted" は存在しないはずが...
console.log(targetObj.polluted); // 出力: undefined
// なんと、すべての空オブジェクトに汚染が伝播している
const innocentObj = {};
console.log(innocentObj.polluted); // 出力: 1337 (プロトタイプが汚染された!)
このコードが実行された瞬間、メモリ上のすべてのオブジェクトの根源である Object.prototype に polluted プロパティが追加される。これ以降、アプリケーション内で生成される無関係なオブジェクトさえも、この汚染されたプロパティを継承してしまう。これが、単なる変数の書き換えにとどまらない「プロトタイプ汚染」の恐ろしさだ。
—
2. 実戦的攻撃シーケンス:DoSからRCEへの昇格
ペネトレーションテストやレッドチームの現場において、プロトタイプ汚染単体は「面白い挙動をするバグ」に過ぎないことが多い。真の脅威は、このプリミティブをいかにして致命的な影響(RCEや認証バイパス)に昇格させるかにある。
パターンA: プロパティのチェック回避による認証バイパス
アプリケーションがリクエストの権限チェックを行う際、以下のようなコードを書いているケースに出くわすことがある。
// 権限チェックのロジック(脆弱な例)
function checkAccess(userConfig) {
// isAdminプロパティが明示的に設定されていなければ、デフォルトでfalseにしたい
let settings = { isAdmin: false };
// ユーザーからの入力をマージ
vulnerableMerge(settings, userConfig);
if (settings.isAdmin) {
// 特権処理の実行
return "Access Granted";
}
return "Access Denied";
}
ここで、事前に __proto__ を通じて Object.prototype.isAdmin = true を設定させることができれば、ユーザーがどんなに isAdmin: false や権限のない設定を送ろうとも、プロトタイプチェーン経由で常に true が評価されてしまう。結果として、任意のユーザーが特権機能へアクセス可能になる。
パターンB: ライブラリの内部処理を悪用したRCE (Node.js)
Node.js環境において、プロトタイプ汚染はコード実行(RCE)のトリガーになり得る。例えば、古いバージョンのテンプレートエンジンや、内部で child_process などを呼び出すユーティリティライブラリが、オプションオブジェクトのプロパティを安全ではない方法で参照している場合だ。
攻撃者は Object.prototype に shell や execArgv といった、子プロセス起動時に影響を与える設定値を注入する。アプリケーション側が何気なく外部コマンドを実行した際、汚染されたプロトタイプの設定がそのまま引き継がれ、任意のOSコマンドが実行される。
—
3. 防衛アーキテクチャ:なぜ「入力を弾く」だけでは不十分なのか
脆弱性を発見した開発者は、慌てて「__proto__ という文字列を正規表現で弾く」という場当たり的なパッチを当てがちだ。しかし、攻撃者は constructor['prototype'] や ['__pro' + 'to__'] といった難読化や文字列結合を用いて、いとも容易にこのブラックリストをバイパスする。
真にロバストな防衛アーキテクチャを構築するには、レイヤーごとに多層防御(ディフェンス・イン・ディープ)を適用する必要がある。
1. Object.freeze() によるプロトタイプの凍結
アプリケーションの起動時(エントリーポイント)において、グローバルなプロトタイプオブジェクトそのものを凍結し、変更不時にしてしまうのが最も確実な根本対策の一つだ。
/**
* アプリケーションの初期化時に実行するプロトタイプの凍結処理
* これにより、以降の実行時におけるプロトタイプの改ざんを不可能にする。
*/
function hardenPrototypes() {
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
}
hardenPrototypes();
// 万が一、汚染を試みるコードが走っても、厳格モード(strict mode)ではTypeErrorが発生し、
// 非厳格モードであっても変更は無視される。
try {
const payload = JSON.parse('{"__proto__": {"polluted": "true"}}');
vulnerableMerge({}, payload);
} catch (e) {
console.warn("プロトタイプ汚染の試行を検知・ブロックしました:", e.message);
}
2. キーの厳格なホワイトリスト検証と再帰マージの安全な実装
どうしてもオブジェクトをマージ・クローンする必要がある場合は、特殊なキーを完全に除外するロジックを組み込む。
/**
* 安全なプロパティマージ関数
* 危険なキーを明示的に除外する。
*/
const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
function safeMerge(target, source) {
if (source === null || typeof source !== 'object') {
return target;
}
for (let key of Object.keys(source)) {
// 危険なキーやプロトタイプ汚染につながるキーは厳格にスキップ
if (FORBIDDEN_KEYS.includes(key)) {
continue;
}
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) {
target[key] = Array.isArray(source[key]) ? [] : {};
}
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
3. マップ(Map)オブジェクトへの移行
そもそも、動的なキーと値のペアを扱うために平文のオブジェクト({})を使うこと自体が、モダンなセキュリティ設計においてはリスクファクターとなり得る。キーの完全性が求められる文脈では、プロトタイプチェーンを持たない Map オブジェクトを積極的に採用すべきだ。
// Objectの代わりにMapを使用することで、プロトタイプ汚染のリスクを構造的に排除する
const safeMap = new Map();
// キーに '__proto__' を指定しても、単なるMapのエントリとして扱われ、プロトタイプは汚染されない
safeMap.set('__proto__', 'safe_value');
console.log(safeMap.get('__proto__')); // 出力: safe_value
console.log({}.__proto__); // Object.prototypeは安全なまま保持される
—
4. セキュリティ監査の視点:コードレビューと動的解析の勘所
ペネトレーションテスターやセキュリティアーキテクトとしてコードベースを監査する際、プロトタイプ汚染を見つけ出すためのアプローチは明確だ。
1. データフローの追跡(Taint Analysis): 外部からの入力(req.body, location.search, WebSocket のメッセージ等)が、バリデーションなしに lodash.merge, extend, defaultsDeep といった外部ライブラリや、自作の再帰的代入関数に渡されていないかを静的解析ツール(SemgrepやSonarQubeなど)でスキャンする。
2. ブラックボックスファジング: APIのエンドポイントに対し、{"__proto__": {"test": "polluted"}} や {"constructor": {"prototype": {"test": "polluted"}}} といったJSONペイロードを意図的に流し込み、レスポンスの変化や、後続リクエストにおけるオブジェクトの挙動を監視する。
プロトタイプ汚染は、その目立たなさ故に見過ごされがちだが、ひとたび悪用されればシステムの中枢を静かに乗っ取る凶悪な脆弱性である。フレームワークやライブラリのアップデートを怠らず、言語の仕様の隙を突く攻撃手法に対する深い理解をもって、堅牢なアーキテクチャを築き上げてほしい。
コメント