「うちのWebアプリはOWASP Top 10はひと通りチェックしている」という現場の声をよく聞きます。しかし、プロトタイプ汚染(Prototype Pollution)は見落とされがちな脆弱性の代表格です。JavaScriptのオブジェクト継承機構の深部を突く攻撃で、認証バイパスやサービス停止、さらには任意コード実行につながる事例も報告されています。
この記事では、プロトタイプ汚染の仕組みを攻撃者の視点から整理し、Node.jsやフロントエンドアプリで実践できる防御手順をわかりやすく解説します。JavaScriptを扱うシステムを管理しているエンジニアの方はぜひ最後まで読んでみてください。
プロトタイプ汚染(Prototype Pollution)とは?
プロトタイプ汚染とは、攻撃者が外部から渡した悪意あるデータを通じて、JavaScriptのオブジェクトプロトタイプ(すべてのオブジェクトが継承する共通の設計図)に不正なプロパティを追加・上書きする脆弱性です。
JavaScriptでは、すべてのオブジェクトは「プロトタイプチェーン」と呼ばれる継承の連鎖を持っており、その起点となるのが Object.prototype です。ここを汚染されると、アプリケーション内のすべてのオブジェクトに影響が及ぶ可能性があります。
OWASP Top 10(2021年版)の「A03:インジェクション」の亜種として扱われることもありますが、JavaScriptのプロトタイプ継承という特性に根ざした固有の問題です。フロントエンド・バックエンド(Node.js)を問わず発生するため、JavaScriptを使うすべての開発者・運用者が理解しておく必要があります。
なぜ危険なのか?攻撃者の視点から見る影響範囲
プロトタイプ汚染が危険とされる最大の理由は、アプリケーション全体のオブジェクトの振る舞いを一度に変えられる点です。攻撃者が Object.prototype.isAdmin = true を設定できてしまった場合、認証チェックで if (user.isAdmin) と記述しているすべての箇所が突破されます。
実際の被害例としては次のようなケースが確認されています。
・認証バイパス: 管理者フラグを全オブジェクトに付与し、権限チェックをすり抜ける
・サービス停止(DoS): 必須プロパティを上書きして例外を連鎖させる
・任意コード実行(RCE): Node.jsの child_process 等でシェル実行オプションを汚染する
・クロスサイトスクリプティング(XSS): テンプレートエンジンのプロパティを書き換える
人気ライブラリのlodashでは、CVE-2019-10744として深刻な脆弱性が報告されました(CVSS v3スコア: 9.8 Critical)。lodash.merge() 関数が入力のキーを検証せずに処理するため、__proto__ キーを含む悪意あるJSONを渡すとプロトタイプが汚染されます。詳細はNVDの公式ページ(CVE-2019-10744)でご確認ください。
攻撃の仕組み(敵を知る)
1. JavaScriptのプロトタイプ継承とは
JavaScriptはプロトタイプベースのオブジェクト指向言語です。オブジェクトに存在しないプロパティを参照しようとすると、自動的にプロトタイプチェーンをさかのぼって探します。
# JavaScriptのプロトタイプ継承の例 const obj = {}; console.log(obj.toString); // Object.prototypeから継承される # __proto__ はオブジェクトのプロトタイプへの参照 console.log(obj.__proto__ === Object.prototype); // true # Object.prototypeに追加したプロパティは全オブジェクトから見える Object.prototype.greeting = "Hello"; const other = {}; console.log(other.greeting); // "Hello"(直接定義していないのに見える)
この性質を意図せず悪用されるのがプロトタイプ汚染です。
2. 汚染が起きる典型的なコード
安全でないディープマージ(deep merge)関数が最も一般的な攻撃経路です。
# 脆弱なディープマージの実装例(防御理解のための参考) function unsafeMerge(target, source) { for (const key in source) { if (typeof source[key] === 'object' && source[key] !== null) { target[key] = target[key] || {}; unsafeMerge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } # 攻撃者が送り込むJSON(APIリクエストボディ等から) const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}}'); # マージ後、Object.prototype.isAdmin が true になる unsafeMerge({}, maliciousPayload); console.log({}.isAdmin); // true ← 汚染成功
__proto__ はJavaScriptでオブジェクトのプロトタイプを直接参照する特殊なキーです。再帰的なマージ処理でこのキーが通常のプロパティとして扱われると、意図せず Object.prototype への書き込みが発生します。
3. 実際の悪用シナリオ
攻撃が成立するまでの典型的な流れを整理します。
ステップ1: 攻撃者はAPIエンドポイントを調査します。プロフィール更新APIや設定保存APIなど、JSONを受け取ってオブジェクトにマージする処理が対象になります。
ステップ2: リクエストボディに {"__proto__": {"role": "admin"}} などのペイロードを含め、脆弱なマージ処理を通じて Object.prototype.role を “admin” に設定します。
ステップ3: 以降のリクエストで、アプリケーションの認証・認可ロジックが user.role === 'admin' をチェックするとき、汚染されたプロトタイプから “admin” が返ってくるため、権限チェックをパスできます。
__proto__ 以外にも constructor.prototype を経由した汚染経路があります。一方のルートだけをブロックするブラックリスト方式では見落としが生じるため、注意が必要です。
具体的な防御手順
1. Object.freezeでプロトタイプを凍結する
アプリケーション起動時に Object.prototype を凍結(freeze)すると、外部からの書き込みを完全に防ぐことができます。
# Node.jsアプリのエントリーポイント(app.jsなど)の最初に追加 Object.freeze(Object.prototype); Object.freeze(Function.prototype); Object.freeze(Array.prototype); # 凍結後の確認 try { Object.prototype.isAdmin = true; // strict modeではTypeError、非strictではサイレントに失敗 } catch (e) { console.log("プロトタイプへの書き込みをブロックしました:", e.message); } console.log({}.isAdmin); // undefined(汚染されていない)
注意点として、アプリケーションや依存ライブラリがプロトタイプを正当な用途(polyfillなど)で拡張している場合、凍結によって動作が壊れることがあります。テスト環境での動作確認を十分に行った上で本番環境に適用してください。
2. 危険なキーをフィルタリングする
ユーザー入力からオブジェクトを構築する処理では、危険なキーを事前に除去します。
# マージやオブジェクト構築の前にキーを再帰的に検証する関数 function sanitizeKeys(obj) { if (typeof obj !== 'object' || obj === null) return obj; const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype']; const sanitized = Object.create(null); // プロトタイプなしの純粋オブジェクト for (const key of Object.keys(obj)) { if (DANGEROUS_KEYS.includes(key)) { continue; // 危険なキーはスキップ } sanitized[key] = sanitizeKeys(obj[key]); } return sanitized; } # 使用例 const userInput = JSON.parse(requestBody); const safeInput = sanitizeKeys(userInput); merge(target, safeInput);
3. 脆弱なライブラリを最新版に更新する
プロトタイプ汚染の脆弱性が確認された主要ライブラリと、修正済みバージョンをまとめます。
| ライブラリ | 脆弱なバージョン | 修正済みバージョン | 影響する関数 |
|---|---|---|---|
| lodash | 4.17.20以前 | 4.17.21以降 | merge, mergeWith, defaultsDeep |
| jQuery | 3.3.x以前(一部条件下) | 3.4.0以降 | $.extend(deepコピー時) |
| deep-extend | 0.5.0以前 | 0.5.1以降 | deepExtend |
npm audit コマンドを定期的に実行し、既知の脆弱性があるパッケージをすぐに特定できる体制を整えておきましょう。
# 脆弱性スキャンと自動修正 npm audit npm audit fix # 重大度が high 以上のみ表示 npm audit --audit-level=high
4. Object.create(null)でプロトタイプなしのオブジェクトを使う
任意のキーを格納するディクショナリ(連想配列)として使うオブジェクトには、Object.create(null) で生成した「プロトタイプなし」オブジェクトを使うと安全です。
# 通常のオブジェクト(__proto__が存在する) const normalObj = {}; console.log(normalObj.__proto__ === Object.prototype); // true # プロトタイプなしオブジェクト(純粋なディクショナリ) const safeDict = Object.create(null); console.log(safeDict.__proto__); // undefined(プロトタイプなし) # __proto__ キーを持つ入力も安全に格納できる safeDict['__proto__'] = { isAdmin: true }; console.log({}.isAdmin); // undefined(Object.prototypeは汚染されない)
5. JSON Schemaで入力を厳格に検証する
ユーザーからのJSON入力を処理する前に、JSON Schemaで許可するプロパティを明示的に定義します。
# ajvライブラリを使ったスキーマバリデーションの例 const Ajv = require('ajv'); const ajv = new Ajv({ allowUnionTypes: true }); const schema = { type: 'object', properties: { username: { type: 'string', maxLength: 50 }, email: { type: 'string', format: 'email' } }, required: ['username', 'email'], additionalProperties: false // スキーマ定義外のキー(__proto__等)を拒否 }; const validate = ajv.compile(schema); const data = JSON.parse(requestBody); if (!validate(data)) { return res.status(400).json({ error: 'Invalid input' }); } // バリデーション通過後のみ処理を続行
additionalProperties: false の設定により、スキーマで定義していないキー(__proto__ など)を含む入力はバリデーション段階ではじかれます。
中小企業でも今日からできること
専任のセキュリティエンジニアがいなくても、すぐに取り組める対策があります。
・npm auditの定期実行: 月1回以上 npm audit を実行し、重大な脆弱性を早期に把握する
・lodashのバージョン確認: package.json でlodashのバージョンを確認し、4.17.20以前なら即時更新する
・CI/CDパイプラインへの統合: GitHub ActionsやJenkinsで npm audit --audit-level=high を自動実行し、高リスクの脆弱性があればビルドを失敗させる
・Dependabotの有効化: GitHubを使っている場合はDependabotを有効化し、脆弱なパッケージを自動的に更新するプルリクエストを生成させる
・入力バリデーション方針の見直し: ホワイトリスト方式(許可するキーを明示定義)でバリデーションを実装し、予期しないキーをデフォルトで拒否する
CIパイプラインに組み込む設定例を示します。
# GitHub Actions の設定例(.github/workflows/security.yml) name: Security Audit on: push: branches: [main] schedule: - cron: '0 9 * * 1' # 毎週月曜9時に自動実行 jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '20' - run: npm ci - run: npm audit --audit-level=high
よくある誤解と注意点
【誤解1】「JSONのパースだけなら安全」
JSON.parse() 自体はプロトタイプ汚染を引き起こしません。問題はパース後のデータを検証せずにディープマージやオブジェクトコピー処理に渡すときです。「パースは安全」と「マージも安全」は別の話だと理解してください。
【誤解2】「__proto__さえフィルタすればいい」
__proto__ の他にも constructor.prototype というルートがあります。また、エンコーディングを使った迂回手口も報告されており、ブラックリスト方式には必ず抜け穴ができます。ホワイトリスト方式(許可するキーを明示定義)と組み合わせてください。
【誤解3】「フロントエンドだけの問題」
Node.jsのバックエンドでも同じ問題が発生します。むしろNode.jsでの汚染は child_process.spawn のオプションを経由した任意コード実行(RCE)につながる危険性があり、フロントエンドより深刻なケースも多いです。
【誤解4】「Strict Modeにすれば解決する」
JavaScriptのStrict Mode('use strict')はプロトタイプへの書き込みを検出してエラーにしますが、根本的な汚染を防ぐわけではありません。エラーをわかりやすくする補助手段であり、Object.freeze() と組み合わせるのが正しいアプローチです。
【注意】不正利用と法律
実際のシステムへの無断アクセスや脆弱性の悪用は、不正アクセス禁止法に抵触します。本記事の内容は自社システムの防御目的での理解を支援するものです。ペネトレーションテストや脆弱性診断を実施する際は、対象システムの管理者から書面による許可を取得した上で実施してください。法律の詳細は専門家にご確認ください。
本記事のまとめ
プロトタイプ汚染は、JavaScriptのプロトタイプ継承という特性を突いた脆弱性です。一か所の汚染がアプリ全体に波及する性質があるため、発見が遅れると深刻な被害につながります。対策の要点を表にまとめます。
| 対策 | 効果 | 難易度 |
|---|---|---|
| Object.freeze(Object.prototype) | プロトタイプへの書き込みを完全ブロック | 低(1行追加) |
| 危険キーのフィルタリング | __proto__等を入力段階で除去 | 低~中 |
| ライブラリの最新化(npm audit) | 既知の脆弱性を排除 | 低(定期実施) |
| Object.create(null)の活用 | ディクショナリ用途のオブジェクトを安全化 | 中 |
| JSON Schemaバリデーション | 許可キー以外の入力を完全拒否 | 中 |
| CI/CDへのaudit統合 | 脆弱なパッケージを継続的に検知 | 中 |
「理解すれば防御は難しくない」脆弱性の一つです。まずは npm audit の実行と、lodashなど利用頻度の高いライブラリのバージョン確認から始めましょう。
姉妹サイトLinuxMaster.JPでは、Node.jsを動かすLinuxサーバーのセキュリティ設定(SELinux・firewalld・権限管理)について詳しく解説しています。バックエンドのOSレベルの堅牢化と組み合わせることで、多層防御をより強固なものにできます。
