Node.jsにおけるプロトタイプ汚染の防止:脆弱性分析と実践的な防御策

Security tutorial - IT technology blog
Security tutorial - IT technology blog

問題の背景:JavaScriptオブジェクトに潜むロジックボム

多くのバックエンドエンジニアにとって、予期せぬ不具合が本番環境を脅かした記憶は鮮明に残っているはずです。その攻撃ベクトルは、ブルートフォースによるSSH攻撃や分かりやすいSQLインジェクションとは限りません。一見しただけでは見落としがちな、言語仕様の微妙な構造的特徴に潜んでいることが多いのです。Node.jsのバックエンドにおいて、プロトタイプ汚染(Prototype Pollution)ほど静かに、そして壊滅的な被害をもたらす脆弱性は滅多にありません。

ExpressやFastifyで構築されたAPIの、一般的なアカウント更新エンドポイントを想像してみてください。クライアントが請求先住所、電話番号、UI設定を更新するために2 KBのJSONペイロードを送信します。サーバー側では、ヘルパー関数がこの入力を既存のセッションレコードに再帰的にマージします。単体テストは100%のカバレッジで通過し、コードレビューでも怪しい箇所は見当たらず、データは問題なくデータベースに保存されます。

しかし、攻撃者が一見無害に見える __proto__ というプロパティを送信した瞬間に状況は一変します。JavaScriptオブジェクトは継承チェーンを共有しているため、このプロパティはその単一のユーザーレコード内にとどまりません。Node.jsプロセス内のすべてのオブジェクトが共有するルートプロトタイプそのものを書き換えてしまうのです。

その結果、空のオブジェクトが管理者権限を勝手に継承したり、入力バリデータが必須フィールドを見落としたり、ダウンストリームのマイクロサービスがクラッシュしたりします。400万以上の依存リポジトリに影響を与えたLodashのCVE-2018-3721や、任意のリモートコード実行を許したKibanaのCVE-2019-7609といった重大なセキュリティインシデントは、このパターンがエンタープライズシステムを停止に追い込み得ることを証明しました。

基本概念:プロトタイプとプロパティルックアップ

JavaScriptにおけるプロトタイプ継承の仕組み

JavaScriptは、古典的なクラス継承ではなくプロトタイプチェーンを採用しています。ほぼすべてのオブジェクトは上位のプロトタイプオブジェクトにリンクし、ユーティリティメソッドやプロパティを継承します。コードが user.isAdmin を読み取るとき、V8エンジンはまずインスタンス自身をチェックします。そのキーが存在しない場合、エンジンはチェーンを辿って Object.prototype まで探索します。このルックアップは、一致するキーが見つかるか、null に達するまで続きます。

以下は、プロパティ解決の基本的な動作例です:

const user = { name: "Alex" };
console.log(user.isAdmin);  // 出力: undefined
console.log(user.toString); // 出力: [Function: toString] (Object.prototypeから継承)

Node.jsは user.toString を Object.prototype 上で直接解決します。この共有参照により、メモリ内で何百万もの並行オブジェクトを軽量に保つことができます。しかし皮肉なことに、すべてのオブジェクトが共通の攻撃対象領域(アタックサーフェス)を抱えることにもなります。

何がプロトタイプ汚染を引き起こすのか?

この脆弱性は、サニタイズされていないユーザー入力を使用して、アプリケーションがオブジェクトを再帰的にマージ、クローン、または拡張するときに発生します。攻撃者が __proto__ や constructor.prototype といったキーを注入した場合、不十分な再帰関数はそれらを単なる通常のネストされたパスとして処理してしまいます。その結果、マージ処理がローカルのターゲットオブジェクトを抜け出し、ルートプロトタイプに直接書き込んでしまうのです。

一度書き込まれると、注入されたキーはランタイム全体のすべてのオブジェクトを汚染します:

// ルートプロトタイプへの不正な注入
Object.prototype.isAdmin = true;

const regularCustomer = {};
console.log(regularCustomer.isAdmin); // 出力: true

たった1回の汚染リクエストによって、if (req.user.isAdmin) のような標準的な認証ガードが無力化されます。認証されていない訪問者であっても、瞬時に管理者権限を継承してしまうのです。

実践演習:エクスプロイトの再現と無害化

ステップ1:脆弱なオブジェクトマージユーティリティの作成

多くの社内コードベースには、この脆弱性が広く認識される前に作成されたカスタムの再帰マージ関数が今も残っています。動作不良を検証するため、以下のコードを vulnerable-server.js として保存してください:

function unsafeMerge(target, source) {
    for (const key of Object.keys(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;
}

module.exports = { unsafeMerge };

この実装は、ネストされたオブジェクトを見つけるたびに階層を深く進みます。しかし、入力ペイロードがプロトタイプチェーンを標的にしたときに何が起きるか見てみましょう。

ステップ2:攻撃ペイロードのシミュレーション

V8が JSON.parse() を介して "__proto__" を含むJSON文字列をパースすると、プロトタイプを直接設定するのではなく、通常の列挙可能なプロパティキーを作成します。これが unsafeMerge に渡されると、target["__proto__"] は Object.prototype として評価されます:

const { unsafeMerge } = require("./vulnerable-server");

// HTTPリクエストボディからの未加工のJSONペイロード
const maliciousPayload = JSON.parse('{"__proto__": {"role": "system_admin"}}');

const userProfile = {};
unsafeMerge(userProfile, maliciousPayload);

// 後からインスタンス化された無関係なオブジェクトを確認
const freshAccount = {};
console.log("freshAccount.role:", freshAccount.role); 
// 出力: freshAccount.role: system_admin

マージ処理によって変更されたのは userProfile だけにとどまりません。アプリケーションプロセスのグローバルなメモリ空間全体が侵害されてしまいました。

ステップ3:アプリケーションコードにおける脆弱性の緩和

プロトタイプ汚染を無害化するには、多層防御が必要です。コードベースに直接実装できる3つの有効な手法を紹介します。

防御策1:危険なプロパティキーをブロックする

再帰的な代入を実行する前に、危険なトラバーサルキーを明示的に拒否します:

function safeMerge(target, source) {
    const bannedKeys = new Set(["__proto__", "constructor", "prototype"]);

    for (const key of Object.keys(source)) {
        if (bannedKeys.has(key)) {
            continue; // 危険なキーを除外
        }

        if (typeof source[key] === "object" && source[key] !== null && !Array.isArray(source[key])) {
            if (!target[key] || typeof target[key] !== "object") {
                target[key] = {};
            }
            safeMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

防御策2:プロトタイプを持たないディクショナリを使用する

動的なキーと値のペアを集約したり、ルックアップキャッシュを構築したりする場合は、Object.create(null) を使用してオブジェクトを生成します。これらのオブジェクトは Object.prototype を継承しないため、プロトタイプの探索経路を完全に遮断できます:

// 継承されたプロトタイププロパティを持たないクリーンなハッシュマップ
const safeStorage = Object.create(null);
safeStorage["__proto__"] = "custom_value";

console.log(safeStorage.toString); // 出力: undefined(プロトタイプが存在しない)
console.log(Object.prototype.custom_value); // 出力: undefined(ルートプロトタイプは未変更)

任意の文字列ルックアップには、JavaScriptの組み込みデータ構造である Map を利用するのもさらに優れた選択肢です。Mapのキーは厳密に隔離されたデータとして保持され、プロパティルックアップの影響を受けません。

防御策3:起動時にグローバルプロトタイプを凍結する

ネットワークリスナーを初期化する前に、Object.freeze() を使ってルートプロトタイプをロックします:

// メインエントリファイル(index.js / server.js)の先頭で実行
Object.freeze(Object.prototype);

const testObject = {};
try {
    testObject.__proto__.polluted = true;
} catch (err) {
    console.error("書き込みがブロックされました: プロトタイプは不変です");
}

console.log({}.polluted); // 出力: undefined

ただし、Object.freeze() を適用すると、初期化時に組み込みプロトタイプを拡張する古いnpmパッケージが壊れる可能性がある点に注意してください。本番環境へデプロイする前に、必ずテストスイートを実行してこの変更を検証してください。

ステップ4:Node.jsランタイムの堅牢化

バージョン12.19.0および14.12.0以降、Node.jsにはコードを書き換えることなくプロトタイプ攻撃を緩和するためのネイティブCLIフラグが導入されました。プロセスの起動コマンドからプロトタイプアクセスを直接制限できます:

# Object.prototype.__proto__ へのアクセス時に致命的な例外をスロー
node --disable-proto=throw server.js

# またはプロセス起動時に Object.prototype.__proto__ をサイレントに削除
node --disable-proto=delete server.js

即座にランタイム保護を適用するには、これらのフラグをDockerfileの ENTRYPOINT や本番用の npm start スクリプトに追加してください。

まとめ:Node.jsセキュリティの重要なポイント

プロトタイプ汚染は、JavaScriptランタイムの基本的な仕組みを突いた攻撃です。信頼できないJSONをパースする際、構造的な境界を意識的に強制しない限り、オブジェクトの分離性は崩壊します。

本番サービスを守るために、以下の4つの習慣を徹底してください:

  • 自作の再帰マージやディープクローン処理では、__proto__、constructor、prototype の各キーを確実に除外する。
  • Zodや additionalProperties: false を設定したAjvを使い、受信したJSONペイロードを厳格なスキーマで検証する。
  • ユーザー制御の動的なキー・値マップには、Map または Object.create(null) を使用する。
  • コンテナ環境では、--disable-proto=throw フラグを付与してNode.jsプロセスを起動する。
Share: