先日、私のプロジェクトで脆弱性関連のissueが投稿されたので対策を行いました。
指摘内容は主に「プロトタイプ汚染攻撃」でした。自分では対策を行っていたつもりだったのですが、様々な穴がありました。
プロトタイプ汚染攻撃可能な脆弱性は成功すると他の機能や脆弱性との組み合わせによって、任意のコード実行を可能にする危険度の高いものですが、XSSやCSRFに比べて、初学者が触れられる纏まった対策方法の情報が少ないと感じたので、ここに記そうと思います。
プロトタイプ汚染攻撃とは
日本語の情報としては Node.jsにおけるプロトタイプ汚染攻撃とは何か - ぼちぼち日記 が詳しいですが、まず、前提として、JavaScriptは「プロトタイプベースのオブジェクト指向」を採用しており、原則、すべてのプリミティブ型およびオブジェクトのインスタンスは「プロトタイプ」オブジェクトを参照しています1。
また、プロトタイプ・オブジェクト自身も別のプロトタイプ・オブジェクトを参照2しており、これにより、「クラスベース」のオブジェクト指向でいうところの継承関係を表しています。
プロトタイプのメンバーには、インスタンス・メソッド、クラス・メソッド、クラス・プロパティーに相当するものが存在します。
最終的にすべてのインスタンスは継承のrootである「Object」のプロトタイプを参照します。
各「プロトタイプ」オブジェクトは、全く普通のオブジェクトであり、メンバーを自由に追加変更することができます。
プロトタイプのメンバーの変更は、それを参照するすべてのインスタンスが影響を受けます。
この特徴を有益な形で使用すると、例えば、polifillとして古いブラウザーに存在しない機能を供給することができます。
一方で、よく呼ばれるであろう既存の任意のメソッド (例えばtoString
やvalueOf
)を攻撃コードに入れ替えることができれば、これを起点に任意のコード実行が可能となります。
({}).__proto__.toString = () => { alert('attack succeeded') };
({}).toString(); // open the alert dialog.
対策の結論
プロトタイプ汚染攻撃と任意の攻撃用関数の生成の組み合わせで大きな攻撃が可能となるので、
以下に示す対策でユーザーデータの実行(検証, パース, 式やコードのインタープリタ実行等)に対して汚染と機能の漏出を防がなければなりません。
※ 従って、本対策内容はプロトタイプ汚染攻撃阻止に留まっていません。
※ プロトタイプ汚染攻撃のみでもリクエストの偽装やサーバーのクラッシュ等に利用できる可能性があり、また、未発見の他の脆弱性との組み合わせで大きな攻撃となるので放置してはいけません。
- 危険なプロパティー名へのアクセスは、読み取り・書き込みの両方を禁止する
- すべての
__proto__
,__defineGetter__
,__defineSetter__
,__lookupGetter__
,__lookupSetter__
- プロトタイプ汚染攻撃の本体に利用できる
- Functionコンストラクタ-のプロパティー(
Function.prototype
)からもプロトタイプにアクセスできることに注意する-
({}).toString.constructor.prototype
等からもプロトタイプへのアクセスが可能なことに注意-
'a'.toString.constructor.prototype
でも良い
-
-
- 関数スコープの変数名
arguments
,arguments.callee
,arguments.caller
- プロトタイプ攻撃への自体への利用は難しいが、もし漏れるとFunctionコンストラクタ-へのアクセスに至るかもしれない
- すべての
- グローバルオブジェクト自体および、その全プロパティーへのアクセスは、読み取り・書き込みの両方を禁止する
-
globalThis
,window
,global
,this
等から得ることができる- メンバーアクセスではない関数呼び出しの
this
はglobalThis
である
- メンバーアクセスではない関数呼び出しの
- 下述の通り、Functionコンストラクターからも取得できる
-
eval
はもちろんだが、その他ビルトインのオブジェクト、関数を置き換えられた時点で試合終了である
-
- Objectコンストラクタ-自体(
Object
)および、その全プロパティーへのアクセスは、読み取り・書き込みの両方を禁止する- Objectコンストラクタ-かどうかは厳密比較演算子で判定すると良い
-
assign
やdefineProperty
が呼び出し可能になると、プロトタイプ汚染攻撃の本体に利用できる - 非標準ではあるが
watch
,unwatch
も任意コード実行をトリガーできるため危険である - そもそも、メンバーを置き換えられたら終わりである
- Functionコンストラクター自体(
Function
)および、その全プロパティーへのアクセスは、読み取り・書き込みの両方を禁止する- Functionコンストラクターかどうかは厳密比較演算子で判定すると良い
- Functionコンストラクターからは、グローバルオブジェクトの取得が可能である
-
(Function('return this'))()
-
(({}).toString.constructor.call(null, 'return this'))()
等とも記述できる
-
-
- すべての関数からは
constructor
プロパティーによってFunctionコンストラクターにアクセス可能なことに注意 -
Function()
とFunction.call()
の両方が呼び出せれば、任意のコード実行が可能となる -
arguments
,caller
プロパティー経由でプロトタイプや意図しない情報にアクセスされる可能性がある
- そもそも、関数型のオブジェクト
(typeof fun === 'function')
の全プロパティーへのアクセスは、読み取り・書き込みの両方を禁止するべきである- 上述で、Objectコンストラクター、Functionコンストラクターについて言及したが、関数オブジェクトは攻撃に有用なプロパティーを多く持っているので、アクセスさせてはならない
- また、上述2つ以外の有用なコンストラクターがあるかもしれないが、すべての関数はコンストラクターに成り得るため判定できないので、一括で禁止する
- オブジェクトは一般的な言語の「連想配列」ではない。そのまま使うのはプロトタイプ汚染が行われ危険である
-
Map
を使う- こちらが連想配列である
-
Object.create(null)
でプロトタイプの無いオブジェクトを使う -
Object.freeze
を使う - プロパティーのget/set時に都度チェックしてスキップする、またはエラーとする
-
Object.assign()
によるケースもチェックする-
Object.assign({}.__proto__, {toString: () => alert('attack succeeded')})
で成功する-
Object.assign({}, {__proto__: {toString: () => alert('attack succeeded')}})
では成功しない
-
-
-
-
詳細
上述で対策の結論は書いてしまいましたが、ここからは、それぞれの脆弱性ごとに例を交えて見ていきたいと思います。
💀 Critical
危険度 Critical な脆弱性です。
完全敗北です。自由に攻撃できます。
1. 任意のテキストデータに対して globalThis.eval
関数3をコール可能にする
これは、プロトタイプ汚染攻撃ではありませんが、言うまでもなく即時アウトです。
eslintやtslintが入っていれば、呼び出すだけで警告となるでしょう。
10年前ならばあったかもしれないコードは以下のようなものではないでしょうか:
// テキストで送られた通信のJSON形式ペイロードをオブジェクトに変換する
var payload = eval(result.payload);
JSONの標準サポートが無いブラウザーが生き残っていましたからね。
(注:当時からJSON変換用ライブラリーはありました。まともな人は使っていたはずです)
もし、あなたが独自のインタープリタ作者であれば、JavaScriptの名前空間をインタープリタに公開するのは、その必要が本当にあるかどうか検討し、思い留まるべきです。
2. グローバルオブジェクト、オブジェクトコンストラクター、ファンクションコンストラクターを漏出する
グローバルオブジェクト(globalThis
, window
, global
)を漏らせば、そのメンバーからほぼすべてのビルトインオブジェクト・関数にアクセスできます。
eval
もありますし、オブジェクトコンストラクター、ファンクションコンストラクターも得られます。
任意のコード実行が可能です。
ありとあらゆる機能が漏出したといって良いでしょう。
オブジェクトコンストラクター(Object
)からはプロパティー操作に有用な関数にアクセスできますし、Object.prototype
からプロトタイプにアクセスできるので、プロトタイプ汚染攻撃が可能となります。
ファンクションコンストラクター(Function
)からはcall
関数によってグローバルオブジェクトが取得できるほか、任意の関数が呼び出せます。
また、ファンクションコンストラクター自身によって任意の関数を文字列から生成できます。
任意のコード実行が可能です。
こちらも、ありとあらゆる機能が漏出したといって良いでしょう。
// ファンクションコンストラクターから任意の関数を作成し、グローバルオブジェクトを得る
// その1
(Function('return this'))();
// その2
(({}).toString.constructor.call(null, 'return this'))();
😱 High ~ Critical
危険度 High ~ Critical な脆弱性です。
単独の脆弱性で攻撃できます。
ただし、コード実行に至るかどうかはライブラリーやアプリの機能や使用方法に依存します。
3. 利用頻度の高いビルトイン型のプロトタイプ・オブジェクトのメンバーに対し代入可能にする
プロトタイプ・オブジェクトは、インスタンスの __proto__
プロパティーからアクセスできます。
このように代入すると、派生先でtoString
をカスタマイズしていないオブジェクトのtoString
が呼ばれた時点で攻撃コードが実行されます。
// 再帰的にオブジェクトをマージする
function mergeObject(target, source) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (source[key] && typeof source[key] === 'object') {
target[key] = mergeObject(
target[key] && typeof target[key] === 'object' ?
target[key] : {},
source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
const newPropsPicked = ...; // 攻撃者に細工されたオブジェクト
// {__proto__: {toString: () => alert('attack succeeded')}}
// value 部分に関数を注入できる機能または脆弱性があると、
// 攻撃コードが実行できる
const currentProps = { /* 任意 */ };
mergeObject(currentProps, newPropsPicked);
※ Objcet.assign
が狙われることもあります。
対策1
辞書を実装するのにオブジェクトを使うのを止めましょう。オブジェクトは一般的な言語の「連想配列」ではありません。
代わりに Map
を使いましょう。
const map = new Map();
map.set('__proto__', () => alert('attack succeeded')); // どんなキー名でも安全
const v = map.get('__proto__');
課題:
- シリアライズが面倒
- 他のライブラリー/モジュールとのI/Fはオブジェクトであることが多い (変換が発生)
- 古いブラウザー対応が必要
- 機能の漏出は別途対策が必要
対策2
オブジェクトのプロトタイプをnullにします。
const currentScope = Object.create(null);
課題:
- 他のライブラリー/モジュールにオブジェクトを受け渡すと、オブジェクトのメソッドを呼べないことを想定していないことが多い
- 機能の漏出は別途対策が必要
対策3
Object.freeze
を使い、プロトタイプの変更を不能にします
Object.freeze(Object.prototype);
Object.freeze(Object);
課題:
- 他のライブラリー/モジュールが想定していないことがある
- 機能の漏出は別途対策が必要
対策4
危険なプロパティー名へのアクセス(読み取り・書き込み両方)をスキップするか、エラーとする
function checkDangerousThings(target, name) {
...
if (name === '__proto__') {
throw Error(`dangerous property ${name} is accessed`);
}
...
}
// 再帰的にオブジェクトをマージする
function mergeObject(target, source) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
checkDangerousThings(target, key);
if (source[key] && typeof source[key] === 'object') {
target[key] = mergeObject(
target[key] && typeof target[key] === 'object' ?
target[key] : {},
source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
const newPropsPicked = ...; // 攻撃者に細工されたオブジェクト
const currentProps = { /* 任意 */ };
mergeObject(currentProps, newPropsPicked);
課題:
- 使用できないプロパティー名が多く生じる
- 格納する際のキー名をマングリングすれば、すべての名前を安全に使えるが、それをするくらいなら素直に
Map
を使ったほうがよい気がする
- 格納する際のキー名をマングリングすれば、すべての名前を安全に使えるが、それをするくらいなら素直に
(余談) Electronでのプロトタイプ汚染攻撃対策はもっと難しい?
Electron 6.x
ではオプションがかなり安全方向に振られるようになりましたが、安全ではないユーザーコンテンツを表示する際に、Node integration
や<webview>
を有効化した場合、そのままメインプロセス側のAPIコールが可能となったり、remote
モジュール経由でメインプロセス側にプロトタイプ汚染攻撃が可能となったりします。公式ドキュメントをよく読み、オプションやハンドラーの追加で適切に機能を無効化しましょう。