前提
Node.jsのプロトタイプ汚染について書いているのですが、プロトタイプの説明(prototype
と __proto__
の関係とか)を定期的に見直さないと綺麗サッパリ忘れる程度にはNode.js触っていないので、何かおかしいところあればご指摘お願いします。
概要
Node.jsではここ数年プロトタイプ汚染攻撃が流行っています。概要は以下を見れば分かると思います。
そもそもプロトタイプって何?という人は以下の記事が分かりやすいです。自分はお守りのように定期的に読んでます。
外部から送られてきたJSONなどをパースして変換し、そのオブジェクトをmergeやcloneする際に __proto__
を上書きすることで Object.prototype
を汚染するというものです。このオブジェクトが書き換えられると、新しく作られたオブジェクトの __proto__
が Object.prototype
を指していてかつプロトタイプチェーンによって __proto__
を辿っていくため、全く関係ないように見えるオブジェクトまで影響を受けてしまうというものです。toString や valueOf なども Object.prototype
に定義されているためそれらを上書きすることも可能です。ただ基本的には関数の処理を定義できるわけではないため、どこまで出来るかは実装次第かと思います。
この攻撃方法は色々なライブラリが影響を受けたため各OSSで修正されました。どのように修正したかと言うと __proto__
がkeyの場合にはskipするというものです。対策としてはObject.freezeを使う、Mapを使う、など色々あるのですがfreezeを想定していないライブラリが動かなくなったり、Mapに全て置き換えるのは変更点が大きすぎる、など修正が容易ではないと判断され、一旦 __proto__
の除去で落ち着いたのではないかと考えています。
こちらのはせがわさんの記事でもJSON.parse内で __proto__
を除去する方法を取っています。
ですが、実は __proto__
の除去では修正が不十分だったということでライブラリのいくつかはその後に追加で修正しており、その内容について今回は紹介します。
一部では当たり前の内容だとは思いますが、意外とまとめている記事が見つからなかったので書いてみました。
詳細
オブジェクトのmerge/cloneなど
上のQiita記事内のプロトタイプの図を見た方は気付く方もいるかと思いますが、 Object.prototype
に至る道は __proto__
のみではありません。 obj.constructor.prototype
でも Object.prototype
にアクセス可能です。この場合、 __proto__
というプロパティ名は出てこないため __proto__
の除去では防ぐことが出来ません。
Snykのブログでlodashが追加で修正された旨が説明されています。
どうでも良いのですが、自分は脆弱性スキャナーを作っておりこのlodashの脆弱性がテストプロジェクトでずっと検知されていました。Snykが公表したのが2019/07/04だったためそこで脆弱性データベースに修正バージョン無しで登録され、lodashが4.17.12を2020/07/09に公開するまで1年間検知され続けていました。
さて、では実際に脆弱性のサンプルを見てみます。まずはシンプルなプロトタイプ汚染です。
function isObject(obj) { return obj !== null && typeof obj === 'object'; } function merge(a, b) { for (let key in b) { if (isObject(a[key]) && isObject(b[key])) { merge(a[key], b[key]); } else { a[key] = b[key]; } } return a; } const obj1 = {a: 1, b:2}; const obj2 = JSON.parse('{"__proto__":{"polluted":1}}'); merge(obj1, obj2); const obj3 = {}; console.log(obj3.polluted); // 1
これは __proto__
が上書きできてしまうため脆弱です。では __proto__
を除去してみましょう。
function isObject(obj) { return obj !== null && typeof obj === 'object'; } function merge(a, b) { for (let key in b) { if (key === '__proto__') { continue } else if (isObject(a[key]) && isObject(b[key])) { merge(a[key], b[key]); } else { a[key] = b[key]; } } return a; } const obj1 = {a: 1, b:2}; const obj2 = JSON.parse('{"__proto__":{"polluted":1}}'); merge(obj1, obj2); const obj3 = {}; console.log(obj3.polluted); // undefined
__proto__
を除去しているため、JSONの入力として __proto__
を渡されてもプロトタイプ汚染は起きません。では先程の constructor.prototype
を使うケースはどうでしょうか?mergeとisObjectは同じなので割愛しています。
const obj1 = {a: 1, b:2}; const obj2 = JSON.parse('{"constructor":{"prototype": {"polluted": 1}}}'); merge(obj1, obj2); const obj3 = {}; console.log(obj3.polluted); // undefined
実はこれは安全です。ちょっと煽り気味にここまで溜めましたが、この例では実はバイパスできません。理由は isObject
の実装にあります。typeof obj === 'object'
していますが、obj2.constructor
はfunctionになるためここでfalseが返ります。
> typeof {}.constructor < "function"
そうすると再帰でアクセスされずelseの方に入り単に上書きされます。つまり、単に {"prototype": {"polluted": 1}}
というオブジェクトが constructor
というプロパティに代入されるだけになります。constructorの指しているオブジェクト(攻撃者が書き換えたいやつ)に対してはアクセスしてくれません。
ではlodashの defaultsDeep
はなぜ影響を受けたのか?というと以下を見てもらえれば分かります。
lodash/lodash.js at 4.17.11 · lodash/lodash · GitHub
function isObject(value) { var type = typeof value; return value != null && (type == 'object' || type == 'function'); }
このように、functionの場合もtrueが返ります。最初見た時はObjectだけじゃないの?!と思いましたが上記のコメントにも書いてある通りfunctionもオブジェクトですしプロパティも持てるので、厳密にコピーしようとするならfunctionの場合も各プロパティをコピーしてあげる必要があるのかと思います。
> var a = function(){} > a.b = 1
対策としてこちらのPRで constructor
もskipするような処理が追加されています。
つまり、もしmergeやdeep copyなどを自前実装していて、functionも正しくコピーしようとしている場合は __proto__
だけ除去しても影響を受けます。可能性としては低いような気がするのですが、実際にlodashなどがそのように扱っていることを考えると影響を受ける人が0かというとそうではないかなと思ったためまとめました。
プロパティの設定
プロパティの設定でも同じです。以下のようにオブジェクトに値を設定する場合ですね。
setValue(obj1, "__proto__.polluted", 1);
setValueの実装は以下のようなものです。isObjectは同じ。
function setValue(obj, key, value) { const keylist = key.split('.'); const e = keylist.shift(); if (keylist.length > 0) { if (!isObject(obj[e])) obj[e] = {}; setValue(obj[e], keylist.join('.'), value); } else { obj[key] = value; return obj; } }
以下は express-fileupload
で見つかったプロトタイプ汚染の話ですが、まさにプロパティの設定系の問題でした。
parseNested
というオプションがあり、それをtrueにすると {"a.b.c": 1}
が {"a": {"b": {"c": 1}}}
に変換されます。これ以上は説明しなくても察すると思います。
そして例によって修正PRで __proto__
を除去しました。
しかしconstructorでバイパスできるぞと指摘を受け、今度はconstructorも除去します。
ただこうなると、本当にこの2つで防げているのか?ということが気になってきます。そこでObjectとArrayの持つ全てのプロパティをブロックしてしまおう、ということで現在はその様になっています。
抜粋しておきます。
const OBJECT_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype); const ARRAY_PROTOTYPE_KEYS = Object.getOwnPropertyNames(Array.prototype); ... const IN_ARRAY_PROTOTYPE = ARRAY_PROTOTYPE_KEYS.includes(k) && Array.isArray(current); if (OBJECT_PROTOTYPE_KEYS.includes(k) || IN_ARRAY_PROTOTYPE) { continue; }
getOwnPropertyNames
で Object.prototype
と Array.prototype
のプロパティ名を列挙してそれらを全部弾くという方法ですね。
これだとtoStringとかvalueOfとかも全部弾かれるので、typeの確認ぐらいはしたほうが親切なのかなと思ったりもしましたが、余程のことがない限りは問題ないような気もするので安全側に倒すなら良いのかもしれません。
こういったプロパティの設定やドット繋ぎをネストに変換するという処理はobjectの確認をせずに行うこともあるようなので、オブジェクトのmerge/cloneよりバイパスされる可能性が少し高そうです。
おまけ
Hidden Property Abusing
先日のBlack Hatで以下のNode.jsに関する発表がありました。今回の調査内で一緒に調べたので発表を見れば分かることでありますが、軽くまとめておきます。
発表スライドは以下にあります。
こちらも勝手にプロトタイプ汚染なのかなと思って見てみたのですが、こちらはアプリケーション側が想定していないプロパティを送りつけることで意図しない挙動を起こさせる、というものでプロトタイプ汚染とは全然関係ありませんでした。
発表内で constructor
を上書きする攻撃方法も紹介されていますが、こちらも __proto__
の値を書き換えるわけではなくてオブジェクトに constructor
という値を新しく入れるだけです。つまりそのオブジェクトのみが影響を受けるだけでグローバルな汚染は起きていません。
アプリケーションにおいて constructor
が重要な役割を果たす場合に、プロトタイプチェーンによって本来 __proto__
の __constructor
が呼ばれるところをそのオブジェクトが持つ constructor
を呼ばせることで悪さするというものです。
ということでこの脆弱性は意図しないプロパティを上書きされるという観点から見るとRuby on RailsのMass Assignmentに似ていると考えられます。実際に発表内でも同系統として触れられています。
ちなみにここにPHPのObject Serializationが入るのはよく分かっていなくて、それだと他にもJavaとかRubyの安全でないデシリアライゼーションもここに入ってくるんでしょうか?今回のはオブジェクトのデシリアライズとは少し違うような気もしますが、JSONなどをオブジェクトに変換するという点でデシリアライズと言えなくはないから同じ扱いなんですかね。
言葉で説明しても分かりにくいので具体例を見てみます。
アプリケーション固有の値の上書き
以下の例ではaccessプロパティは内部関数によってのみ変更され、ユーザが変更できるものではないとします。さらにユーザがageを変更するためのAPIとして update(input)
を提供していたとします。通常であれば {age: 60}
というinputを受け取ってその値を使って更新するだけになりますが、受け取った入力をそのままオブジェクトにして利用する場合だと {age: 60, access: "admin"}
などと言った値を受け取ることでaccessをadminに変更してしまう可能性があります。
このaccessのように外部から変更されることが想定されていない隠れたプロパティを上書きできてしまうというのがこの攻撃方法に基本になります。
MongoDB (CVE-2019-2391)
上の例ではアプリケーションが利用するために保持しているプロパティで、かつ変更が想定されていないものを上書きするという方法でしたが、こちらは内部のロジックで使われるようなプロパティの話になります。バリデーション通ったあとにオブジェクトに __validated: true
のプロパティを保存しておく、といったものです。もし __validated
を外部から制御できてしまったら、SQLインジェクションのバリデーションなどをすり抜けてしまうかもしれません。
Node.jsの公式MongoDBドライバでは内部で _bsontype
というプロパティを使っているそうです。ここに有効でない値が入っている場合、シリアライズをしなくなるという問題があるため攻撃者が意図的に _bsontype
という値をアプリケーションに渡すことで不正を引き起こすという脆弱性です。
以下の例ではidに対してnew ObjectIdでインスタンスを生成し、内部でシリアライズしているのだと思いますがそこを失敗させることでクエリの条件を無効にすることが出来ます。つまり、このケースでは必ず最初のユーザが返されるため他人になりすましが可能となります。
発表を聞いた感じはこのidを細工して _bsontype
という値を渡す必要がありそうですが、そもそもstringが来るところにオブジェクトを入れられるんだろうか...辺りがよく分からずでした。仮にオブジェクトを渡せるとして、ユーザから渡ってきたidをそのまま利用する状況も想像できませんでした。さらに仮にユーザから渡された値を使うとしても、CookieやJWTトークンと比較して本当にそのユーザであるかの検証ぐらいはすると思うので、stringの場所にオブジェクトを渡してそういう検証をパス出来るものなのだろうか...などと腑に落ちてません。
発表では不正な _bsontype
を注入すれば行けるぜ!ぐらいしか触れてなかったので、もしかしたら自分と違ってNode.js詳しい人からすると何か当たり前の前提があるのかもしれません。
今回の修正はv1系にしか影響を与えないため、v4系を使っていれば問題なさそうです。
taffyDB (CVE-2019-10790)
インメモリDBであるtaffyDBの例ですが、こちらも攻撃自体はシンプルで内部的にインデックス目的で利用されている ___id
を悪用してSQLインジェクションします。___id
は各レコードに対応しており、 ___id
が渡された場合は他のクエリ条件を無視してそのレコードを返します。
つまり、usernameとpasswordでDBから取得しようとするようなケースでは ___id
を渡してしまえば認証をスキップできるということです。この ___id
の値もT000002R000002
などで推測が容易とされています。
こちらのOSSは既にdeprecateされており、この脆弱性も修正されていません。
影響するOSS
発表スライドに影響を受けるOSS一覧が載っていたので自分が利用していないか確認することを推奨します。
ただあくまで現時点で発表者が見つけたものであり、今後も同じ方法で見つかる可能性があります。今後の動向は追っていく必要があるかと思います。
Hidden Property Abusingまとめ
既にRailsなどでMass Assignmentなどと戦っていた人からするとそこまで新しい攻撃方法とは感じなかったかもしれません。Railsでは既存のDBカラムなどを上書きされないように守るというのがメインでしたが、こちらは内部で利用されているパラメータを狙ったりする点で多少異なりそうです。新しくパラメータを生やすことも出来ます。
このHidden Property Abusingは完全にアプリケーション依存です。
- 内部的に使っているプロパティが上書きされて困るケース(
__bsontype
などのケース) - 外部から変更されることが想定されていないプロパティが上書きされて困るケース(
admin: true
など)
これらがないか、というのは各サービス開発者じゃないと判断が難しそうです。今回の発表を受けて一度見直してみると良いのではないかと思います。外部入力(JSONなど)をオブジェクトに変換するところで影響を受ける可能性があります。
今回の発表ではどちらかと言うと、このコンテキスト依存という難しい脆弱性を自動で検知するツールを作ったというところがメインに感じます。さらにそれをOSSとして公開してくれたということなので皆さん是非使いましょう。
自分もよ〜し使うぞ〜と思ってリポジトリ見に行ったらComing soon...となっていて発表に間に合わなかったんか...となりましたが、期待して待ちましょう。
このHidden Property AbusingもJSON.parseなどで入り込む可能性が高く、__proto__
の除去では防げないためプロトタイプ汚染ではないですがこの記事でまとめて説明しました。
まとめ
protoの除去でプロトタイプ汚染を防げないケースということで紹介しましたが、影響を受けるアプリケーションは限定的だと考えています。とはいえ実際にいくつもOSSが対策の不十分さを指摘され、実際にバイパスされることが証明されていることを考えると必ずしも影響がないとは言い切れないかと思います。
Object.freezeやMapの利用などでしっかりと対策できる場合はそちらが推奨されますが、それが難しい場合は外部からの入力を受け取る口でObject/Arrayのプロパティが上書きされないように弾くなどするほうが良さそうです。それも難しい場合は constructor
の除外だけで済ませているケースもあります。さらにその場合もより影響を少なくするためにtypeofでfunctionかどうかを確認すると良さそうです。自分のアプリケーションではバイパスできないかもしれませんが、弾いて問題になるケースがなさそうなら対策しておくに越したことはないのかなと考えています。
そしてプロトタイプ汚染に限らず、そもそも受け取ったJSONをオブジェクトに変換するような場合はHidden Property Abusingの可能性もあるということを紹介しました。外部には露出していないプロパティを上書きされて意図しない挙動が起きることがないか、というのはアプリケーション依存なので各自で確認することが推奨されます。アプリケーションに問題なくても利用しているOSSが影響を受ける可能性があるので npm audit や他のツールで影響がないかを確認しましょう。
プロトタイプ汚染はglobalが汚染されるため一度POSTで汚染してから次のGETで発動させる、といったことも可能であり、その点でHidden Property Abusingとは異なります。Node.jsの特性上、こういった脆弱性は今後も見つかる気がしているので気をつけましょう。