PHPのイタい入門書を読んでAjaxのXSSについて検討した(2)〜evalインジェクション〜

 昨日の日記の続きで、Ajaxに固有なセキュリティ問題について検討します。タイトルはXSSとなっていますが、今回紹介する脆弱性XSSではありません。
 作りながら基礎から学ぶPHPによるWebアプリケーション入門XAMPP/jQuery/HTML5で作るイマドキのWeから、Ajaxを利用したアプリケーションの概念図を引用します(本書P20の図1-23)。

 Ajaxのアプリケーションでは、XMLHttpRequestメソッド等でデータを要求し、サーバーXMLJSON、タブ区切り文字列など適当な形式で返します。ブラウザ側JavaScriptでは、データ形式をデコードして、さまざまな処理の後、HTMLとして表示します。以下に、Ajaxのリクエストがサーバーに届いた後の処理の流れを説明します。

  1. サーバー側でデータを作成、取得
  2. データ伝送用の形式(XMLJSON、タブ区切り文字列等)にエンコード
  3. HTTPレスポンスとして返す(5)
  4. ブラウザ側で、受け取ったデータをデコードして変数に代入する
  5. ブラウザ側処理
  6. HTMLレンダリングして表示

 前回は、FのHTMLレンダリングの際のXSSを扱いました。HTMLエスケープする場所の問題はあるものの、外見上は普通のXSSと変わらないように見えるものでした。
 今回は、BとDにおける、データのエンコード・デコードの箇所に関わる問題を説明します。

本書固有の「<i>」で区切る形式

 本書ではJSONの説明も少し出てくるのですが、実際に使われている形式は、HTMLのi要素「<i>」を用いてデータを区切るというユニークな方法です。以下にデータの例を示します。

30<i>山田<i>ABC<i>234-5678<i>Bコース<i>5<i>2010-04-29<i>2010-04-30

 前のエントリで、「out.split("<i>")」というスクリプトが出てきましたが、この形式をデコードする目的だったわけです。
 データをカンマで区切るCSV形式を採用しなかった理由は、データ中にカンマが出てくる場合などがややこしいと著者が判断したからかもしれません。しかし、データを利用者が登録できるという前提では、「<i>」が入力される可能性はあります。この場合、データが1つずれるということが起こり、状況によっては脆弱性になります。本書の場合、その対処はなされていません。
 データを「<i>」で区切る方法は読者も興味がないと思うので、これに関してはこの程度にとどめます。

JSON形式の説明

 JSON(JavaScript Object Notation)は、JavaScriptのオブジェクトの表記法をデータ記述言語として用いるものです。前述のように、本書にはJSONが少し説明されています。本書には「5.8 [PHP]配列と連想配列およびJSON」という節が用意されているくらいです。しかし、その後JSONは活用されていません。
 とはいうものの、ダウンロードしたサンプルの方には、JSONを活用した例が入っています。Chap7/revxmasjson.htmとChap7/revxmasjson.phpがそれです。これらは、本書から参照されていないようです。ソースの中味を見ると、かなり痛々しい感じで、完成には至っていません。そこで、私が勝手にこのスクリプトを完成させて、JSONのセキュリティ問題の題材とします。
まず、元のスクリプトを引用します。「痛々しい」コメントは削除しました。まずはサーバー側の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;
}

 このままでも動かなくはないですが、検索がヒットしなかった場合も参照成功となることや、参照失敗時のレスポンスがJSON形式でないことから、少し手を加えました。但し、新たな問題が混入していますが、それは説明のためです。コメントアウトのままのheader関数は本来必要ですが、その理由は次回に説明します。1行目にSQLインジェクション脆弱性が観察されますが、本題とは異なるので気にしないことにします。

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

 2行目の参照成功のチェックに、ヒットした行数の確認を加えたことと、参照成功のステータスをJSONとして返すスクリプトを加えています。参照不成功の場合は単純なJSONなので、json_encode関数を使わずに自前でJSONを組み立てていますが、ここに新たな問題が入っています。このスクリプトが返すJSONの例を以下に示します。

{"book_id":"30","name":"\u5c71\u7530","org":"ABC","addr":"\u677f\u6a4b\u533a","tel":"234-5678","mail":"b@example.jp","course":"B\u30b3\u30fc\u30b9","nums":"5","adddate":"2010-04-29","moddate":"2010-04-30","stat":"\u53c2\u7167\u6210\u529f\uff1abook_id = 30"}

 次にクライアントサイドのスクリプトですが、こちらはさらにイタい感じになっていて、大幅に手を加えないと動きません。以下に引用しますが、コメントアウトされたコードを削除しました。

var query = {};
$(function(){ 
  $("#rev").click(function(){   // id=revのイベントハンドラ
    query["book_id"] = $("#book_id").val();  // XHRのクエリストリング
    $.get("revxmasjson.php", query, show);   // XHRの呼び出し
  });
// 中略
});
function show(out){  // $.getのイベントハンドラ
  alert("out="+out);
  var json = eval(out);
  //var json = eval("("+json+")");
  alert(json);
/* 注:以下他のスクリプトから流用した表示機能のコメントアウトが続く */
}

 デバッグ用のalertが残っていたり、eval時にデータを括弧で囲んだりの試行錯誤の後が伺われます。show関数を以下のように手直ししました。

function show(out){
  var res = eval("("+out+")");
  clearui(); // 画面クリア(別の箇所で定義済み)
  var fields = ['name', 'org', 'addr', 'tel', 'mail', 'course', 'nums', 'stat'];
  $.each(fields, function() {
    if (res[this]) {
      $("#" + this).text(res[this]);
    }
  });
}

 2行目で変数outを括弧で囲んだ理由は、JSONオブジェクトを囲むブレースがブロックと解釈されるのを防ぎ、式として解釈させるための定石です*1jQueryの機能を活用すればもっと良い方法がありますが、後述します。
 その後は、JavaScriptオブジェクトをHTMLレンダリングするスクリプトですが、たまたまJSONのキー(テーブルxmastranの列名)と、表示先となるtd要素のid属性が同じなので、それを積極的に使っています。
 正常系の処理結果を以下に示します。多少の手直しで、とりあえずはうまく動いています。

JSONエスケープとevalインジェクション

 JSONデータを扱う場合は、JSON形式をはみ出してJavaScriptスクリプトが実行される「evalインジェクション」に気をつける必要があります。PHPjson_encode関数を使ってJSONデータを作成する場合は、json_encode関数が適宜データをエスケープするので問題ありませんし、仮に問題があればjson_encode関数の脆弱性です。しかし、上記にjson_encode関数を使わないで手でJSONデータを作成している箇所があるので、ここをチェックしてみましょう。
 問題の箇所は、私がJSON形式を返すよう修正したした以下の行です。

  echo '{"stat":"参照不成功:book_id = ' . $book_id . '"}';

$book_idを埋め込んでいますが、これはユーザの入力値なので、自由に値を入れることができます。ここに、以下の値を入れてみます。

"+alert(1)+"

生成されるJSONは以下となります。

{"stat":"参照不成功:book_id = "+alert(1)+""}

JSON形式の中に、alert関数が注入されました。この式は、JSON形式としては不適合ですが、JavaScritの式としては正常です。そのため、eval関数で評価した結果、以下のようにalertが表示されます。

 これはバグではあり避けなければなりませんが、この状態では脆弱性とまでは言えません。その理由は、このバグを攻撃に使う経路がないからです。今のままでは、攻撃者が自らテキストボックスに攻撃文字列を入力して「参照」ボタンを押すと、攻撃者のブラウザ上でJavaScriptを実行できますが、それでは攻撃にはなりません。攻撃とするためには、このアプリケーションの利用者(被害者)のブラウザでJavaScriptを動かす必要があります(受動的攻撃)。もしも、この画面が、book_idを外部から指定できてボタンを押さなくても予約情報が表示される仕様であれば、外部からの攻撃に利用でき、脆弱性となります。また、このスクリプトの場合は問題ありませんが、攻撃者が登録した情報を利用者(被害者)が閲覧した際にevalインジェクションが起こる場合も脆弱性です。

対策

 このevalインジェクション問題を避けるためには、JSONデータを組み立てる際の適切なエスケープが必要です。さしあたっては、二重引用符「"」を「\"」とし、バックスラッシュ「\」を「\\」とすべきですが、わざわざ手でエスケープするよりも、json_encode関数を利用すべきでしょう。修正例を以下に示します。

  echo json_encode(array('stat' => "参照不成功:book_id = $book_id"));

 また、クライアント側も、eval関数の直接呼び出しを避け、jQueryの機能を活用するとよいでしょう。jQueryJSON形式のデータを受け取ると、内部で文字列からJavaScriptオブジェクトに変換してくれる機能があるので、それを活用します。その方法にはいくつかありますが、例えば、getメソッドをgetJSONメソッドに変更することです。これに伴い、evalの呼び出しも不要なので削除します。

    $.get("revxmasjson.php", query, show);   // XHRの呼び出し
   ↓
    $.getJSON("revxmasjson.php", query, show);   // XHRの呼び出し

function show(out){
  var res = eval("("+out+")");
     ↓
function show(res){
  // evalは不要となる

 このように、evalの明示的な呼び出しを避け、jQueryのgetJSONメソッドを用いると、JSONとしての形式チェックが働くため、evalインジェクションが防止されます。
 このように、JSONエンコード・デコードのロジックをハードコーディングせず、できるだけPHPjQueryの機能を活用することをお勧めします。上記のどちらか一方でevalインジェクション脆弱性は回避できますが、両方の実施を推奨します。
 しかし、実はそれだけでは別の問題が残ります。これについては、次回に説明します。

まとめ

 Ajax/JSONでは、XMLJSONCSVなどのデータ形式を利用してデータの受け渡しを行いますが、その際に適切なエスケープを怠ると、脆弱性の原因になることを示しました。典型的には、JSONのevalインジェクションの問題があります。
 対策は、エンコード、デコードともに適切なライブラリ関数を利用することです。

補足

 私が追記した部分で、以下のコードは少し気持ち悪いなーと思いながら書いていました。

  $("#" + this).text(res[this]);

 textメソッドの呼び出しは常に安全ですが、$("#" + hogehoge)という部分は常に安全というわけではないからです。このあたり、詳しくは、以下のブログエントリをご覧下さい。

 例をあげて説明しましょう。以下はid指定ではなく、s要素の作成となります。つまり、「<s>this is a pen</s>」に相当するオブジェクトができるわけですね(タグの前の#等の文字列は無視されるようです)。

  $("#" + "<s>").text("this is a pen.");

 これだけだと、単にオブジェクトが生成されるだけなので脆弱性にはならないのですが、ばけらさんのエントリにあるように、「onerrorイベント付きのimg要素を作らせるような技」により、スクリプトを実行させられます。実例を以下に示します。

 var id = '<img src="!" onerror="alert(1)">';
 $('#'+id).text(text);

 画像ファイル「!」が存在しない場合、onerrorイベントでalert関数が動きます。
 さて、元のスクリプトは「気持ち悪い」ながらも3行上まで遡れば問題ないことがわかります。もし仮に、もっと広範囲をチェックしなければ正しいことを確認できないならば、このコードは採用しなかったと思います。一般論として、プログラムをチェックする範囲を限定することによりバグを出にくくすることができますが、バグの一種である脆弱性についても同じことが言えます。


[PR]

*1:本書P144にはJSONの動くサンプルが記載されていますが、こちらは配列形式のJSONなので括弧で囲む必要がないのでした