Node.jsにおけるプロトタイプ汚染攻撃とは何か
1. はじめに
最近わけあってNodeのセキュリティ調査をしているのですが、今年の5月に開催された North Sec 2018 でセキュリティ研究者の Olivier Arteau 氏による 「Prototype pollution attacks in NodeJS applications」という面白い発表を見つけました。
この発表の論文や発表資料、デモ動画などもgithubで公開されていますし、ちょうどタイミングよくセッション動画も最近公開されました。 github.com
Olivier Arteau -- Prototype pollution attacks in NodeJS applications
この発表で解説されているのは、悪意のある攻撃者が、JavaScript言語固有のプロトタイプチェーンの挙動を利用して、Webサーバを攻撃する方法です。
発表者は、npmからダウンロードできるユーザモジュールを調べあげ、lohdash を始めとして多くのモジュールにプロトタイプ汚染の脆弱性があることを発見し、報告を行いました。そして、実際に脆弱性のある Ghost CMS に対して、パスワードリセットのリクエストを細工してサーバ上で計算機アプリを実行するデモまで成功しています。
JavaScriptの実行環境においてプロトタイプ汚染が発生してしまうことの危険性は、古くから言われていたことですが、これがNode.jsの環境でWebサーバへの攻撃に使われるということは、これまであまり意識されてなかったのではないかと思います。
自分の備忘録を兼ねて、ここではその攻撃の仕組みなどを解説してみます。
2. __proto__ の現状
オブジェクトのプロトタイプを参照する __proto__ は、昔から仕様外で裏技っぽく使われてきた機能でした。しかし現状実装の追認とブラウザ間での機能互換を持たせるため ECMAScript2015 で仕様に入りました。 developer.mozilla.org
他にも __proto__ への setter/getter と同様の機能である Object.setPrototypeOf/getPrototypeOf も規定されました。しかし、MDNではプロトタイプオブジェクトを変更することは基本非推奨の扱いです。今のNode.js環境では、もちろんどちらも使えます。
3. プロトタイプ汚染
プロトタイプ汚染とはどういうものでしょうか?
やり方はいろいろありそうですが、今回狙われたのは、オブジェクトリテラルの __proto__ が Object.prototype と同一であることを利用して、他のオブジェクトのプロパティアクセスに影響を与えるやり方です。
const obj1 = {}; console.log(obj1.__proto__ === Object.prototype); // true obj1.__proto__.polluted = 1; const obj2 = {}; console.log(obj2.polluted); // 1
上記の例では、obj1のプロトタイプオブジェクトを操作して、全く関係ない obj2 のプロパティ値(obj2.polluted)が undefined から 1 に改変されています。
発表では、以下の3つのパターンでオブジェクトのプロトタイプ汚染が起きることが紹介されています。いずれも __proto__ を key に持つ不正なデータをオブジェクトに登録させることによって、Object.prototype の操作を狙ったものです。
- プロパティの設定
function isObject(obj) { return obj !== null && typeof obj === 'object'; } 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; } } const obj1 = {}; setValue(obj1, "__proto__.polluted", 1); const obj2 = {}; console.log(obj2.polluted); // 1
- オブジェクトのマージ
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
- オブジェクトのクローン
function clone(obj) { return merge({}, obj); } const obj1 = JSON.parse('{"__proto__":{"polluted":1}}'); const obj2 = clone(obj1); const obj3 = {}; console.log(obj3.polluted); // 1
これらに近い機能を提供するユーザモジュールの多くに、プロトタイプ汚染の脆弱性が見つかり修正されています。 いくつか修正部分を見てみましたが、key が __proto__ の場合に処理をスキップする対応でした。
攻撃者は、外部から Object.prototype を操作できることから、上記の様に undefined のプロパティを改変するだけでなく、 for-in loop を狙ったり、toString や valueOf などのメソッドをオーバライドしたりすることも可能です。DoSなら簡単に起こせそうです。
4. 実際の攻撃
発表では、実際のCMSサーバに対してパスワードリセットで送信するJSONを操作して攻撃を行うやり方が解説されていました。
皮肉なことに、オブジェクトのプロトタイプ汚染攻撃が成功した場合に、サーバをクラッシュさせず動かしたまま攻撃するのは、なかなか難儀な技です。 デモでは、様々な工夫をしてCMSテンプレートを操作してテスト用に残されているファイルに改変し、そこから任意のJavaScriptをサーバ上で実行(計算機アプリを立ち上げ)させています。
ここでは、JSONを受けて処理する簡単なWeb APIサーバが、プロトタイプ汚染攻撃によってレスポンスが操作されるサンプルを作ってみましょう。
- 脆弱性があるサーバコード
外部から受信したJSONをそのまま別のオブジェクトに clone しています。
function isObject(obj) { return obj !== null && typeof obj === 'object'; } function merge(a, b) { for (let key in b) { // 本当は、 key が __proto__ の時に処理をスキップすべき if (isObject(a[key]) && isObject(b[key])) { merge(a[key], b[key]); } else { a[key] = b[key]; } } return a; } function clone(obj) { return merge({}, obj); } const express = require('express'); const app = express(); app.use(express.json()); app.post('/', (req, res) => { // ここで外部から不正なJSONをそのままcloneして、オブジェクトのプロトタイプ汚染が起きる const obj = clone(req.body); const r = {}; // プロトタイプ汚染によって r.status が改変 const status = r.status ? r.status: 'NG'; res.send(status) }); app.listen(1234);
- プロトタイプ汚染攻撃コード
クライアントの攻撃コードは、__proto__のプロパティを持つJSONをサーバに送信するだけです。
const http = require('http'); const client = http.request({ host: 'localhost', port: 1234, method: 'POST' }, (res) => { res.on('data', (chunk) => { console.log(chunk.toString()); }); }); const data = '{"__proto__":{"status":"polluted"}}'; client.setHeader('content-type', 'application/json'); client.end(data);
攻撃結果です。送り込んだJSONによってサーバ上のオブジェクトプロトタイプが汚染され、response値が NG から polluted に改変されています。
$ node client.js polluted
5. 対策
この攻撃を緩和させる対策として、以下の3つの方法が挙げられています。
Object.freeze を使う。
Object.prototype や Object を freeze して改変を不可能にする方法です。副作用で動かなくなるモジュールがでるリスクがあります。
JSON schema を使う。
Map を使う。
key/value を保存するためだけなら、オブジェクトを使わず Map を使う方法です。ES5以前の古い環境では使えません。
ちゃんと意識していないと忘れてしまいそうです。
6. まとめ
言われてみればそうなんですが、外部からのJSONを別のオブジェクトにdeepコピーしただけで攻撃されることになるのは、ちょっと驚きでした。 やっぱり外部からのデータ処理は慎重にです。
脆弱性が指摘されたユーザモジュールの多くは、既に修正されています。心当たりのある方は、一度 npm audit で確認をしてみましょう。
$ npm audit === npm audit security report === # Run npm install lodash@4.17.11 to resolve 1 vulnerability Low Prototype Pollution Package lodash Dependency of lodash Path lodash More info https://nodesecurity.io/advisories/577 found 1 low severity vulnerability in 1 scanned package run `npm audit fix` to fix 1 of them.