PHPのイタい入門書を読んでAjaxのXSSについて検討した(3)〜JSON等の想定外読み出しによる攻撃〜

 昨日の日記の続きで、Ajaxに固有なセキュリティ問題について検討します。今回はJSON等の想定外読み出しによる攻撃です。これら攻撃手法は本来ブラウザ側で対応すべきもので、やむを得ずWebアプリケーション側で対応する上で、まだ定番となる対策がないように思えます。このため、複数の候補を示することで議論のきっかけにしたいと思います。
 まず、作りながら基礎から学ぶPHPによるWebアプリケーション入門XAMPP/jQuery/HTML5で作るイマドキのWeから、Ajaxを利用したアプリケーションの概念図を引用します(同書P20の図1-23)。

 前回前々回は、(5)のHTTPレスポンスの前後で、JSON等のデータ作成(エンコード)に起因するevalインジェクションや、(5)のレスポンスを受け取った後のHTMLレンダリングの際のXSSについて説明しました。
 しかし、問題はこれだけではありません。正常系では(5)はXMLHttpRequestオブジェクト(以下XHR)により受け取るレスポンスですが、このレスポンスをXHR以外の方法で呼び出してクロスサイト・スクリプティング(XSS)等を起こす攻撃手法があります。

 以下順に説明します。

JSONの直接ブラウジングによるXSS

 この手法は、サイト利用者(被害者)のブラウザにJSONを直接表示させ、JSONに仕込んだJavaScript等を表示・実行させることによるクロスサイト・スクリプティング(XSS)です。
 この攻撃が成立するにはいくつかの条件があります。

  1. ブラウザからの直接リクエストを受け付けること
  2. レスポンスがブラウザによりtext/htmlと解釈されること
  3. レスポンス中にJavaScript等で攻撃文字列を書き込めること

 前回のエントリで私が *勝手に* 手直したrevxmasjson.phpはこれらの性質を満足します。以下に、主要部分を再掲します。

$rows = mysql_query("select * from xmastran where book_id = '$book_id'");
if($rows > 0){
  $row = mysql_fetch_assoc($rows);
  //header("Content-Type: text/javascript; charset=utf-8"); 
  echo json_encode($row);
}else {
  echo "参照不成功:book_id = ".$book_id;
}

 前記(A)と(B)を満たしていることは明らかです。(B)に関しては、header関数でContent-Typeを指定しているのに、残念なことになぜかコメントアウトされています。
 (C)に関しては検討が必要です。json_encode関数はデフォルトで、「"」、「\」、「/」をエスケープします。攻撃者はタグの記述に用いる小なり記号や大なり記号は使えますが、閉じタグが使えないので、script要素にJavaScriptを書くことができません。しかし、イベントハンドラJavaScriptを記述することで攻撃可能です。攻撃文字列の例を以下に示します。

<body onload=alert(1)>

 結果は、以下のようにJavaScriptが起動しています。

対策の考え方

 この問題の対策としては、前述の「攻撃が成立するための3条件」のいずれかを潰すことです。以下のいずれかでよいことになります。

  1. ブラウザからの直接リクエストを受け付けない
  2. レスポンスがブラウザによりtext/htmlと解釈されないようにする
  3. レスポンス中にJavaScript等で攻撃文字列を書き込めないようにする

 これらのうち、脆弱性の根本原因は(B)ですので、B、A、Cの順に説明します。

レスポンスがブラウザによりtext/htmlと解釈されないようにする(B)

 JSONなどのデータ形式のレスポンスによりXSS攻撃ができてしまう根本的な原因は、HTMLとして解釈されることを想定していないコンテンツをブラウザがHTMLと誤認してしまうところにあります。従って、HTMLと誤認しないようにするのが対策の本筋です。ということで、まずはContents-Typeを正しく設定することが基本になります。JSONの場合は、RFC4627により、Content-Typeは application/json と定められています。

  • Content-Typeをapplication/jsonに設定する(必須)

 ところがこれだけでは実はダメです。IEが、Content-Typeヘッダを無視してファイルの内容によりコンテンツの形式を決定するという仕様があるためです(参考:[無視できない]IEのContent-Type無視 (1/2):教科書に載らないWebアプリケーションセキュリティ(2) - @IT)。IE8以降では、以下のレスポンスヘッダにより、「ファイルの内容によりコンテンツの形式を決定する」ことが禁止されますが、IE7以前では効果がありません。

X-Content-Type-Options: nosniff

 従って、Content-Type: application/jsonとX-Content-Type-Options: nosniffの2つのレスポンスヘッダは正しく出力すべきですが、これだけでは、IE7以前の利用者に対応できません。そこで、AまたはCの方法を併用することになります。

ブラウザからの直接リクエストを受け付けない(A)

 Aについては、POSTのパラメータに秘密情報(トークンなど)を渡す*1か、HTTPヘッダに固有の文字列を埋め込んで、サーバー側でチェックするという実装が考えられます。jQueryprototype.jsなど主要なJavaScriptライブラリは、自動的に以下のHTTPリクエストヘッダを付与するので、サーバー側でこれをチェックすることで、XHR以外からの呼び出しを制限できます。

X-Requested-With: XMLHttpRequest

 チェックのためにはサーバー側に以下のようなスクリプトを追加します。呼び出し側(JavaScript)は変更ありません。

if ($_SERVER['HTTP_X_REQUESTED_WITH'] !== 'XMLHttpRequest') {
  die(json_encode(array('stat' => "参照不成功:不正な呼び出し")));
}

レスポンス中にJavaScript等で攻撃文字列を書き込めないようにする(C)

 Cについては2種類のアプローチが考えられます。

  • JSONエスケープ時に、「<」や「>」もエスケープ対象とする
  • JSONとして返すデータをあらかじめHTMLエスケープしておく

 前者に関しては、json_encodeのオプションパラメータの指定により実現できます。

  echo json_encode($row, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);

 呼び出し例を以下に示します。

<?php
  $a = array('x'=>'<>\'"&');
  echo json_encode($a, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
【実行結果】
{"x":"\u003C\u003E\u0027\u0022\u0026"}

 さらには、「英数字以外すべてエスケープする」、「英数字も含めて全ての文字をエスケープする」という「過剰エスケープ」も候補として考えられます。これらは、後述する「UTF-7を用いた攻撃」にも有効です。

 後者に関しては、第1回で説明したHTMLエスケープのタイミングと関連します。第1回の説明では、データ処理の原則としてJSONでは生データを渡して、HTML表示の直前にエスケープする(テキストとして表示する)のがよいと書きましたが、安全性を重視してJSONのデータ生成時にあらかじめHTMLエスケープしておくという実装も用いられます。
 「安全性を重視して」というのは、JSONの直接表示に備えると言う意味と、JavaScriptによる表示の際の対策漏れという意味があります。

JSONハイジャック

 次にJSONハイジャックという手法について説明します。今回のサンプルは秘密情報を扱わないのですが、仮にJSONを返すサーバーが認証を要求していて、Cookieにより認証を確認した後で結果のJSONを返す仕様だとします。
 この場合に、罠のページからscript要素を用いてJSONにアクセスすることにより、機密情報としてのJSONデータを盗む手法が考案されました。この手法をJSONハイジャックといいます。JSONハイジャックに用いる罠サイトは以下のような構成です。

これは罠サイト
<script>
  ここにJSONを読み出す仕掛けを置く
</script>
以下で機密情報のJSONにアクセスする
<script src="http://example.jp/getjson.php"></script>

 この場合、JavaScriptは罠サイトのドメイン上で動作しますが、script要素で読み出したgetjson.phpのリクエストには、example.jpドメインCookieが送信されるので、Cookieのみで認証の管理をしている場合、JSONのデータは正常に読み出せることになります。
 しかし、ここまでなら問題ないはずなのです。JavaScriptとしてみた場合、JSONは単なるオブジェクトのデータにすぎません。それがJavaScript上のソースとしておかれても、罠から読み出す方法がないからです。この様子を以下に模式的に示します。

これは罠サイト
<script>
 ここにJSONを読み出す仕掛けを置く
// script要素で読み出したJSON
[{"name":"Yamada", "mail":"yama@example.jp"}]
</script>

 罠サイトの末尾に、var x = などと書いても、シンタックスエラーになるだけです。
 ところが、このような「ソース上にごろんと置かれたオブジェクト」を読み出す方法が考案され、JSONハイジャックと呼ばれます。JSONハイジャックを実現する手法として、Arrayオブジェクトのコンストラクタを再定義する方法やsetterというものを使う方法がありますが、ここでは後者を紹介します。Firefox3.0.x以前で動作します。

<body onload="alert(x)">
罠サイト
<script>
var x = "";
Object.prototype.__defineSetter__("user",
  function(v) {
    x += 'user = ' + v + "\n";
  });
Object.prototype.__defineSetter__("mail",
  function(v) {
    x += 'mail = ' + v + "\n";
  });
// 以下がJSONデータ
[{"user" : "Yamada", "mail":"y@example.jp"},{"user" : "Tanaka", "mail":"t@example.jp"}]
</script>
</body>

 実行結果は以下となります(Firefox3.0.12で検証)。

 このようなJSONハイジャック攻撃は、現在主要ブラウザの最新バージョンでは動作しないようブラウザ側で対策がとられています。すなわち、ObjectのsetterやArrayのコンストラクタの再定義が禁止されています。しかし、はせがわようすけ氏に教えていただいたところによると、Androidのブラウザでは、まだ上記のコードが動きます。手元のXperia arcで確認したところ、確かにJSONハイジャックが動作します。
 Android端末のシェアを考慮すると、まだJSONハイジャックは現実的な脅威として、対策が必要と考えられます。

JSONハイジャックが成立する条件と対策の考え方

 JSONハイジャックが成立する必要条件として以下があります(AND条件)。

  • script要素からJSONデータを読み出せること
  • JSONデータがJavaScriptとして実行した場合にエラーにならないこと。{...} というオブジェクト形式の場合はエラーになる(オブジェクトではなくブロックと解釈される)ので不可。[...]という配列形式はOK
  • 被害者が、Firefox3.0.xやAndroid端末など、JSONハイジャック可能なブラウザを利用していること

 これらから、対策の方針が導かれます。以下のいずれかを実施すればよいことになります。

  • script要素からのリクエストにはレスポンスを返さないようにする
  • JSONデータを、JavaScriptとして実行できない形にする
  • JSONハイジャック可能なブラウザを使わない(利用者側でとれる対策)

 script要素からのリクエストにレスポンスを返さない方法の一つとして、POSTメソッドにのみ応答するというものがありますが、私は好みません。GETとPOSTの使い分けはRFC2616で定められており、参照系のリクエストはGETを使うべきとされています。他に手段がないのならともかく、JSONハイジャック対策だけのためにPOSTメソッドにするのはよくないと思います。このため、先に紹介したトークンあるいはリクエストヘッダX-Requested-Withを利用する方法が有効です。

 JSONデータをJavaScriptとして実行できない方法としては、以下の二つの方法が説明されているようです。

  • JSONデータの先頭にwhile(1); などを書いて、script要素から呼ばれた場合無限ループになるようにする
  • JavaScriptとして文法的にエラーにする。例えば、先頭の「[{」を除去しておき、Ajaxで読み出し後にスクリプト側で補う

 これらの方法は現実に使われていて、例えばGmailで返されるJSONデータの先頭にはwhile(1); が入っています。しかし、これらはいかにもバッドノウハウという感じで、個人的に使いたくない気がします。
 また、jQueryJavaScriptライブラリの機能をフルに使おうとすると、これらの方法は使いづらいと考えられます。

その他の攻撃:UTF-7と誤認させる方法

 IE7以前においては、罠ページのscript要素に文字エンコーディングとしてUTF-7を指定することで、機密情報を漏洩させる手法が知られています。詳しくは、はせがわようすけ氏の解説をご覧下さい。
 対策としては、前記と関連して、以下がよいと思います(いずれか、あるいは両方)。

  • X-Requested-Withヘッダのチェック
  • JSONエスケープ時にプラス記号「+」もエスケープする

結局どうすればよいか

 JSONデータを*想定外の*呼び出しによりXSS等の攻撃ができることを示しました。これらは、本来ブラウザ側で対応すべきものをWebアプリケーション側で対応せざるを得ないという意味合いがあるため、対策についても複数の案があります。ここで、これらをまとめます。

直接ブラウズXSS JSONハイジャック UTF-7による攻撃
Content-Type ×
Content-Type と X-Content-Type-Options: nosniff ×(IE7以前)
X-Requested-Withヘッダのチェック
「<」「>」のエスケープ × ×
「<」「>」「+」のエスケープ ×
HTMLエスケープ × ×
JavaScriptとして不完全なJSON ×
while(1);等 ×
POSTのみ許容 ×
攻撃対象ブラウザ IE Android IE7以前

 現実的な対策案を検討するにあたって、以下のように考えればよいと考えます。
 前述のように、これらは本来ブラウザ側で対応するべきものをWebアプリケーション側で対応するわけですから、アプリケーションとして本来実装すべき内容の延長として無理なく対応できるものがよいと考えます。逆に、HTTPの使い方として無理があるもの(POSTの強制)や、壊れたJavaScriptや無限ループとするもの(while(1); など)は採用したくないと考えます。
 さらに上表に示す対策のカバー範囲をあわせて考えて、以下で対応することを提案致します。

  • 対策の基本はX-Requested-Withヘッダのチェック
  • Content-Typeは正しく application/json; charset=UTF-8 と指定
  • IEに顔を立てて、X-Content-Type-Options: nosniff を指定(あるいは不要か?)
  • JSON生成ライブラリで設定できる最大限のエスケープ

 PHPによる実装例を示します。

header("Content-Type: application/json; charset=UTF-8");
header("X-Content-Type-Options: nosniff");
if ($_SERVER['HTTP_X_REQUESTED_WITH'] !== 'XMLHttpRequest') {
  die(json_encode(array('stat' => "参照不成功:不正な呼び出し")));
}
// ...様々な処理
echo json_encode($row, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);

 残る課題としては、HTMLエスケープをサーバー側で行うか、クライアント側で行う(エスケープする、あるいはタグ生成しない)かがあります。これについては、どちらかとは決められないような気もしますが、少なくとも、プロジェクト毎にはルールを決めておかないと、過剰エスケープ等バグや、脆弱性の原因になります。

 Ajaxのセキュリティについては、これまで攻撃手法の紹介や、それらに対する個別のバッドノウハウ的な対策が紹介されることが多く、体系的・総合的な対策が十分に議論されてこなかったように思います。本エントリが、これら議論のきっかけになれば幸いです。

[PR]

*1:URLに秘密情報を渡すことは好ましくありません