トップ 追記
過去の日記

2010-01-18

iモードブラウザ2.0のXMLHttpRequestでPOSTデータの扱いが困難になった

このエントリでは、iモードブラウザ2.0の制限により、XMLHttpRequestでPOSTメソッドの利用が困難になっていることを確認したので報告する。

iモードブラウザ2.0のJavaScriptを試していて、POSTメソッドでデータが渡せていないことに気がついた。以下のようなプログラムで検証してみた。

【post.html】
<html>
<head>
<script>
function test() {
  try {
    var requester = new XMLHttpRequest();
    requester.open('POST', '/dumppost.php', true);
    requester.onreadystatechange = function() {
       if (requester.readyState == 4) {
           onloaded(requester);
       }
    };
    requester.setRequestHeader("Content-Type" , "application/x-www-form-urlencoded");
    requester.send("aaa=bbb&ccc=ddd");
  } catch (e) {
    res = requester.responseText;
    document.getElementById('result').innerHTML = e.toString();
  }
}

function onloaded(requester) {
  res = requester.responseText;
  document.getElementById('result').innerHTML = res;
}
</script>
</head>
<body>
<input type=button value="go" onclick="test()">
<div id="result"></div>
</body>
</html>

【dumppost.php】
<?php
echo "aaa=" . htmlspecialchars($_POST['aaa'], ENT_QUOTES, 'Shift_JIS') . "<br>";
echo "ccc=" . htmlspecialchars($_POST['ccc'], ENT_QUOTES, 'Shift_JIS') . "<br>";
?>

実行結果は、以下のようになった。まずはChromeのものだが、IEやFirefoxでも同等の結果だ。

Choromeでの結果

次に、ドコモP-07Aによる結果

P-07Aでの結果

ドコモの場合を検証するために、Webサーバーに来ているリクエストをキャプチャしてみた。

POST /dumppost.php HTTP/1.1
X-UE-Version: 1
Host: XXXXXXXXXXXXX
User-Agent: DoCoMo/2.0 P07A3(c500;TB;W24H15)
Content-Type: text/xml
Content-Length: 15

aaa=bbb&ccc=ddd

ご覧のようにPOSTデータそのものは送信されてきているが、Content-Typeがtext/xmlになっているために、Webアプリケーション側で受け取れないようだ。

JavaScript側では、この値をapplication/x-www-form-urlencodedに変更しているが、P-07AでもJavaScriptが再開され、あらたな制限がみつかったで報告したように、setRequestHeaderが無効化されているために、この設定が無視されていることが原因のようだ。

これは明らかに、setRequestHeader無効化の副作用であるが、その代償は大きいように思う。PHP以外に、ASP/ASPX、J2EE(JSP)にこのデータを入力してみたが、いずれも値を読み取ることはできなかった。一方、PerlのCGIモジュールでは、POSTDATAという名称のデータとして、POSTデータ全体を読み取ることができた。Perl以外の場合でも、Webサーバーにデータ自体は到達しているのだから値を利用する手段はあるかもしれないが、私が調べた範囲では分からなかった。

上記の結果として、AjaxでPOSTメソッドを扱うには、標準的でない方法を用いる必要があるわけだが、その方法を検討してみた。

  1. POSTをあきらめてGETを使う
  2. Perlのように、text/xml形式のデータを読み出せる言語を選択する
  3. DOMにより、IFRAME内にFORMを作成してSUBMITする

このうち、上記1.については、セキュリティ上の問題が発生し得る。Ajaxのセキュリティ対策として、「GETメソッドを拒絶する」という方法があるからだ。具体的には、SCRIPT要素を使ってSame Origin Policyを回避してAjaxデータを読み出す手法に対抗して、SCRIPT要素では必ずGETメソッドになることから、POSTのみを許容することで対策するという方法だ。既存のアプリケーションがこのような手法によりセキュリティ対策されている場合に、安易にGETメソッドを許容してしまうと、セキュリティホールが混入することになりかねない。

3.については、私が試した範囲ではうまくいっていない。JavaScriptからIFRAME内のFORM操作がうまくいかないのだ。これも、ひょっとすると制限を掛けているのかもしれない。アドホックな方法ならありそうだが、まだ十分に検証できていない。

一つの疑問は、このような情報がインターネット上に見あたらないことだ。AjaxでPOSTメソッドが使えないというのはとんでもないことだが、検索してもそのような情報がみあたらないのだ。iモードブラウザ2.0のAjaxを誰も使っていないのか、それとも事業者が恐ろしくて口をつぐんでいるのか。

しかし、このような技術情報が流通していかない限り、iモードブラウザ2.0のJavaScriptを使ったコンテンツは普及していかないだろう。NTTドコモにはさらなる情報開示を期待したいし、ケータイWebの開発者にも、もっとブログなどでの情報公開を期待する。

本日のツッコミ(全10件) [ツッコミを入れる]

Before...

yamagata [ストリームとして取得して自分で分解する方法で対応しようと思えば出来る状況だとしても、(それまでに比べて)手順がフクザ..]

 [記事とは関係ないコメントですいません http://nat-q.jp/ctg3/q_detail.php?no..]

徳丸浩 [姫さん 質問を見てみましたが、これはご本人がドコモショップに相談に行くのがよいと思います。あくまで想像ですが、..]


2009-10-19 [Perl][PHP][SQL]

quoteメソッドの数値データ対応を検証する

このエントリでは、PerlのDBI、PHPのPDOMDB2にて用意されているquoteメソッドが数値データをどのように扱えるかを検証しました。結論としてMDB2が合格、それ以外は不合格で、とくにDBD::mysqlを使用した場合、脆弱性といってもよいような結果となりました。

概要

DBI、PDO、MDB2は、いずれもデータベースアクセスを抽象化したモジュール(クラス)であり、汎用的な記述によりさまざまなデータベースを利用できるように工夫されています。これらモジュール(クラス)にはquoteというメソッドが用意されています。DBIのquoteメソッドの呼び出し例を示します。

my $dbh = DBI->connect('DBI:mysql:dbname:localhost', 'user', 'pass');
print $dbh->quote("a\\'");    # 「a\'」という文字列を指定

【処理結果】
'a\\\''

ごらんのように、あるいはquoteという名称が示すように、これらメソッドは入力データをSQL文字列リテラルとしてエスケープした上で、シングルクオートでくくります。SQLの文字列リテラルのエスケープ方法はデータベースソフトウェア依存であり、MySQLの場合はシングルクオートとバックスラッシュをエスケープしますが、標準SQLではシングルクオートのみです*1。quoteメソッドを利用することにより、データベースの種類を自動的に考慮して、SQLを安全かつ簡便に動的組み立てできます。

さて、quoteメソッドには省略可能な第二引数があり、データの型を指定できるようになっています(デフォルトは文字列型)。以下に、整数型を指定した場合の呼び出し方を示します。

DBI:  $dbh->quote($n, SQL_INTEGER)
PDO:  $dbh->quote($n, PDO::PARAM_INT)
MDB2: $dbh->quote($n, 'integer')

ごらんのように、呼び出し方はほとんど同じです。では、この処理結果は、どうなる *べき* でしょうか。私の過去の日記「数値項目に対するSQLインジェクション対策のまとめ」や「SQLの暗黙の型変換はワナがいっぱい」で説明したように、SQLは本来厳格な型をもった言語であり、数値リテラルをシングルクオートで囲むことは好ましくありません。したがって、quoteメソッドに数値型を指定した場合は、たんなる数値が返るべきだと考えます(SQL組み立てが目的なので、型は文字列型でもよい)。そこで、入力データのサンプルとして「1a\'」を第一引数に、整数型指定を第二引数としてquoteメソッドを呼び出してみました。

DBI:  $dbh->quote("1a\\'", SQL_INTEGER)
PDO:  $dbh->quote("1a\\'", PDO::PARAM_INT)
MDB2: $dbh->quote("1a\\'", 'integer')

結果は以下のようになります。PDOとMDB2は、MySQLとPostgreSQLで同じ結果です。

モジュール名 結果
DBI(DBD::mysql) 1a\'
DBI(DBD::PgPP) '1a\\\''
PDO '1a\\\''
MDB2 1 (int型)

先に書いた「あるべき結果」になったのはMDB2だけでした。PDOとDBD::PgPPは、文字列型として指定したのと同じ結果であり、SQLインジェクション対策として機能しますが、なんのためにわざわざ整数型を指定したのかわかりません。DBD::mysqlはエスケープもクオートもしていないので、入力として「1 union select table_name from information_schema.tables」を与えられると、一覧表を表示する画面でテーブルの一覧が表示させられるような結果となります。これはまずい。現実問題として、何もしないのでは、わざわざquoteをメソッドを呼ぶ動機がありません。

また、文字列としてエスケープする方法も、先のエントリで説明したように、さまざまな副作用がありますので、quoteメソッドを使うよりは、SQL呼び出し直前でバリデーションするか、sprintfで%dの書式を与えることで、数値のみが出力されるように制限する手法が考えられます。

今回の調査で、MDB2のquoteメソッドは、わりといい線行っていると思いました。MDB2のquoteでは、'decimal'という指定もできるのですが、20桁程度の数値を与えても、破綻なく動作します。

入力 出力
123456789012345678901 (文字列型) 123456789012345678901 (文字列型、クオートなし)
1a2b3 (文字列型) 123 (文字列型、クオートなし)

'1a2b3'に対する結果が「123」となるところ、「サ、サニタイズかよ」と思ってしまいますが、まぁ数値としてのバリデーションは入り口でやるとして、SQL発行時の「最後の砦」としては、まぁ許容できる範囲ではないかと思います。「例外を発生させるべきじゃないの?」と思うのですが、他のモジュールのだめさ加減との比較では上出来でしょう。

なお、DECIMAL型の場合に、文字列型で結果を返すのは妥当な処理です。PHPに標準で用意されているintegerやfloatでは、DECIMALの桁数(MySQLでは最大65桁、PostgreSQLでは最大1,000桁)を表現するのに十分なビット長がないからです。演算はどうするのだという疑問がありそうですが、DBの性能に余裕があればSQLで演算するという手もありますし、BCMath 任意精度数学関数を使う手もあります。BCMathは文字列型のままで数値演算ができます。

まとめ

DBI、PDO、MDB2のquoteメソッドについて、第二引数明示により数値として処理する内容について検証しました。期待どおり動作するのはMDB2であり、一方DBD::mysqlではなにもしていないので脆弱性対策としては使えないことがわかりました。

また、今回のエントリでは示していませんが、PDOは処理が高速なために人気があるものの、内部処理をつっこんで見ていくと、不安が出てきました。その内容については別途何かの形で発表したいと思いますが、現時点では、id:moriyoshiさんの以下のエントリが参考になると思います。

参考: PDOとプリペアードステートメント

*1 詳細については、「 SQLのエスケープ再考 」および「SQLエスケープにおける「\」の取り扱い 」に詳しく書きましたので参考になさってください


2009-10-14 [php]

htmlspecialchars/htmlentitiesはBMP外の文字を正しく扱えない

PHPの安定版(PHP5.3.0、PHP5.2.11)のhtmlspecialcharsおよびhtmlentitiesには、Unicodeの基本多言語面 (BMP)範囲外の文字、すなわち、U+10000以降の文字を正しく扱えない問題があります。

もっともシンプルな再現コードを以下に示します。

<?php
  $c = "\xF0\x90\x80\xBC"; // U+1003C
  $a = htmlspecialchars($c, ENT_QUOTES, 'UTF-8');
  echo bin2hex($a) . ':' . $a;

【処理結果】
266c743b:&lt;

U+1003Cは、Wikipediaの説明によると、大昔のギリシャの「線文字B」を表すそうで、小なり記号とは関係ないので、本来そのまま出力しなければならないものです。htmlspecialcharsおよびhtmlentitiesの内部処理で、コードポイントの下位16ビットしかみていないようで、このような結果となります。

線文字Bを扱う人口は少ないと思われますので、もう少し身近な例を探してみました。

<?php
  $c = "\xF0\xA2\x89\xBF";  // U+2227F(𢉿 …マダレに馬という字)
  $a = htmlentities($c, ENT_QUOTES, 'UTF-8');
  echo bin2hex($a) . ':' . $a;

【処理結果】
26736373696d3b:&scsim;

𢉿は、京都府長岡京市の地名で𢉿子ケ岳(からねがたけ)に使われている文字です(参考:稀少地名漢字リストGoogle Map)。一方、&scsim;はSGMLの文字実体参照のマッピングに出てくる記号で、≿(カーブした大なり記号の下に〜)を表します。この実体参照形式はFireFoxなどのブラウザでは表示できませんが、そのような文字実体参照に変換される経緯は、id:moriyoshiさんの「PHPのhtmlentities()で (HTML4.0的に) 余計に実体参照に変換されてしまう文字の一覧」に説明されています。

BMP外の文字を扱う機会は少ないとは思いますが、正常系のデータが正しく扱えないという意味では、先の文字エンコーディングのチェック不備よりも重い問題だと考えます。幸い、id:moriyoshiさんが先に気づかれて、文字エンコーディングの問題と合わせて修正されていますので、おそらくPHP5.3.2からは修正されるものと思われます。PHPの最新のスナップショットにて修正ずみであることを確認しています。

影響を受けるケース

この問題を受けるのは、文字エンコーディングとしてUTF-8を使用している場合に、BMP範囲外の文字が与えられた場合です。

対策

PHP側の対応が完了するまでの間は以下のようにすればよいと思います。htmlentitiesよりはhtmlspecialcharsの方が影響が少ないこと、通常htmlentitiesを使う理由はない(参考:htmlspecialcharsと不正な文字の話 )ことから、htmlspecialcharsを使った上で、影響のある文字を扱わなければならない場合は個別に手当するしかないでしょう。Unicode5.1の範囲で、htmlspecialcharsにより不正に変換される文字は、以下の13種です。htmlentitiesを使用すると616種に増えます。𢉿もその一つです。

U+10022 線文字B音節文字
U+10026 線文字B音節文字
U+1003C 線文字B音節文字
U+20022 𠀢
U+20026 𠀦
U+20027 𠀧
U+2003C 𠀼
U+2003E 𠀾
U+E0022 言語タグ
U+E0026 言語タグ
U+E0027 言語タグ
U+E003C 言語タグ
U+E003E 言語タグ

個別に対応する方法を考えてみましたが、簡単な方法は思いつきません。いったん他の文字列に置き換えておいて、htmlspecialcharsの処理結果から、元の文字に戻す方法があると思いますが、面倒な処理になります。あるいは、影響を受ける文字を数値文字参照(&#x20022;など)に変換しておいて、htmlspecialcharsの第四パラメータ$double_encodeを0にして実行する方法もありますが、$double_encodeを0にする副作用もあります。元々実体参照や数値文字参照の形になってる文字列を変換しなくなるからです。このため現状では上記問題を許容した上で、PHP側で対応されるのを待った方がよい場合が多いような気がします。

まとめ

PHPのhtmlspecialcharsおよびhtmlentitiesには、Unicodeの基本多言語面 (BMP)範囲外の文字を正しく扱えません。次バージョン(PHP5.2.12、PHP5.3.2)では修正されると思われます。それまでの間は以下の方法で対処可能です。

  • htmlentitiesではなくhtmlspecialcharsを使用する
  • htmlspecialcharsでも影響を受ける13文字は個別に対応する、あるいは許容する

2009-10-09 [php][xss]

htmlspecialcharsのShift_JISチェック漏れによるXSS回避策

このエントリでは、PHPのhtmlspecialchars関数の文字エンコーディングチェック不備をついたクロスサイト・スクリプティング(XSS)脆弱性について、PHP側のパッチが提供されない状況での回避策について説明します。

何が問題か

PHPにおいて、XSS対策にはhtmlspecialcharsによって記号をエスケープすることが行われますしかし、htmlspecialcharsを利用していても、Shift_JISの先行バイトを利用して、XSSが発生する場合があります。

例えば、以下のようなINPUTがあり、外部から属性値を変更できる箇所が2カ所以上ある(以下の例では、AAAとBBB)とします。

<INPUT name="AAA" value="BBB">

ここで、AAAとBBBにそれぞれ以下のような値を与えます。文字エンコーディングはShift_JISとします。

AAA: %F1
BBB: onmouseover%3dalert(document.cookie);//

この場合、\xF1と後続の「"」が、合わせて一文字と見なされ(「■」で表します)以下のようなHTMLが与えられたとブラウザにより認識されます。

<INPUT name="■ value="onmouseover=alert(document.cookie);//">

すなわち、value=で与えたはずの「onmouseover=...」が属性値をはみ出し、イベントハンドラと見なされます。これにより、XSSが発生すると言う問題です。

一般的に、このような「Shift_JISの先行バイト」を用いたXSSは、\xF1以外にも、0x81〜0x9Fおよび0xE0〜0xFCの範囲のバイトが使用できます。htmlspecialchars(PHP5.2.5以降)は、これら単独の先行バイトをチェックしますが、なぜか0xF0〜0xFCについてはチェックされず素通しになっています。このチェックもれが問題になっています。

今までの流れ

最近この問題が話題になっている流れを時系列に示します。

  1. 元々、htmlspecialcharsは文字エンコーディングの妥当性チェックをほとんど行っていませんでした。その当時の事情は、寺田さんの調査「htmlspecialcharsと不正な文字の話」に詳しく書かれています。
  2. その後、最近になって、「PHP5.2.5以降では文字エンコーディングのチェックが入っているけど、ちょっと中途半端だ」という調査結果を私が「htmlspecialcharsは不正な文字エンコーディングをどこまでチェックするか」にて指摘しました。
  3. このブログで、「XSSの攻撃に対する抜けが生じるかと言えば、突破の方法はちょっと思いつきません」と書いていたところ、id:t_komuraさんが「Shift_JIS では、htmlspecialchars() を使用しても XSS が可能な場合がある」にて、その突破方法を報告して下さっています。
  4. これに対して、id:IwamotoTakashiさんが、「htmlspecialcharsのパッチ私案」にて対策パッチを公開され、バグレポートを提出されましたが、「htmlspecialcharsに関する残念なお知らせ」で報告されているように、現時点ではPHP開発チームから却下されている状況です。
  5. このあたりから、一連の流れが広く知られるようになって、「もっと効果的な訴求方法があるよ」とか、海老原昂輔さんからもバグレポートが投稿されるなどの働きかけが始まっているようです。海老原さんのレポートには私のエントリも英訳されていて、本当にありがとうございます。
  6. さて、PHP本体が修正されるのが一番よいのですが、このエントリでは、PHP側の修正前に、どのようにこの問題に対策すればよいかを説明します(←イマココ)

問題が発生する条件

幸いなことに、この問題が発生するためには、色々条件がつきます。それを以下に示します。すべてAND条件です。

  1. PHPが内部で扱う文字エンコーディングがShift_JISである
  2. 入力から出力までの過程で文字エンコーディングが変換されない
  3. 入力値のバリデーションとして文字エンコーディングを検査していない

すなわち、1.と2.を合わせますと、入口・処理・出口まで一貫してShift_JISで扱っている、という条件が攻撃には必須ということになります。

入口(HTTP Request)と出口(HTTP Response)がShift_JISというサイトは珍しくなくて、ケータイサイトは大抵こうなっています。問題は、内部処理がShift_JISというところです。PHPはShift_JISの文字列リテラルをうまく扱えないので、いわゆる5C問題が発生します。そのため、「PHPの内部エンコーディングではShift_JISを避けよう」というノウハウがかなり普及しているのではないかと思います。このあたりの詳しい説明は、「Shift_JISを利用することの是非」や「第8回■主要言語の文字エンコーディングの対応状況を押さえる(ITpro)」をご覧下さい。

既存サイトの回避策

既存サイトで、一貫してShift_JISで処理している場合もやはりあるでしょう。その場合にどうこの問題に対処すればよいでしょうか。

既に稼働しているサイトの文字エンコーディングを変更するとなると、改修もさることながら、サイト全体に対するテストをしっかりやらないといけないので、そう簡単にはできないでしょう。また、文字エンコーディングのバリデーション処理を追加するのは、とてもよいことではありますが、やはりコードの改修・追加とテストが大変です。

最終的には文字エンコーディングのバリデーション処理の追加を推奨しますが、それがすぐにできない場合の暫定対応として、入口でShift_JIS→Shift_JISの変換をする、という方法があります。php.iniに以下のような設定をすることにより、入力データをShift_JIS→Shift_JISの変換を指示します。

[mbstring]
mbstring.language = Japanese
mbstring.internal_encoding = sjis-win
mbstring.http_input = sjis-win
mbstring.http_output = sjis-win
mbstring.encoding_translation = On

Shift_JIS→Shift_JISの変換というと、何もしないのではないかと思われるかもしれませんが、この指定により、不正なShift_JISに相当するバイトは除去されます。このため、Shift_JISの先行バイトを使用したXSS攻撃も防止することができます。

PHPによるアプリケーション開発に、そもそもShift_JISを使うこと自体が好ましくありませんので、あくまで暫定的・緊急に問題を回避するための手法として紹介します。また、設定変更後はサイトの動作検証を行って下さい。

新規開発する場合はどうか

これから新規開発するサイトの場合はどうでしょうか。この場合は、ぜひ以下の二点を実施して下さい

  1. アプリケーション仕様として適切な文字エンコーディングを選択する
  2. 文字エンコーディングのバリデーションを実施する

これらの内容は既にITproに詳しく書いていますので、そちらを参照して下さい。

また、これらの回に先立ち、なぜそうすべきかも説明しているので合わせてご覧いただければと思います。

まとめ

PHPのhtmlspecialcharsがShift_JISの先行バイトをきちんとチェックしていないために、半端な先行バイトを悪用したXSSが可能となることが指摘されています。これに対して、問題が発生する条件と、暫定的な対策、根本的な対策を説明しました。

文字エンコーディングの問題は、後から対策しようとするとやっかいですが、上流工程で考慮しておけば大幅に労力を削減することができます。この機会に、文字エンコーディングの問題に関心を持っていただければ幸いです。

[PR]Webアプリケーションのセキュリティ対策はHASHコンサルティングまで

本日のツッコミ(全1件) [ツッコミを入れる]

海老原昂輔 [ご紹介いただいてありがとうございます。今回僕が行動できたのも、徳丸さんのわかりやすいエントリがあってこそです。こちら..]


2009-09-30 [php][security][xss]

htmlspecialcharsは不正な文字エンコーディングをどこまでチェックするか

このエントリでは、PHPのhtmlspecialchars関数の第三パラメータ(文字エンコーディング指定)により、どこまで文字エンコーディングの妥当性チェックをしているかを報告します。 2007年4月に、寺田氏(id:teracc)の素晴らしいエントリ「htmlspecialcharsと不正な文字の話」により、htmlspecialcharsは、第三パラメータの文字コードを実質的に無視しており、不正な文字エンコーディングが指定された場合、その文字はチェックされずにすり抜けてしまうという結果が報告されています。

寺田氏のエントリには、検証に使用したPHPのバージョンが明記されていませんが、エントリが書かれた時期から考えて、PHP5.2.1またはそれ以前のバージョンと考えられます。以下は、PHPに同梱されるnews.txtからの引用です。

08 Feb 2007, PHP 5.2.1
03 May 2007, PHP 5.2.2

しかし、その後PHP5.2.5に至って、文字エンコーディングのチェックがなされるように改善されました。PHP5.2.5のnews.txtから引用します。

08 Nov 2007, PHP 5.2.5
【中略】
 - Fixed htmlentities/htmlspecialchars not to accept partial multibyte sequences. (Stas)

部分的なマルチバイト・シーケンスを受け付けないようにしたとのことですので、本当にそうなっているかを以下のコードを用いて検証しました。

<?php
function test($s, $enc) {
  $e = htmlspecialchars($s, ENT_QUOTES, $enc);
  echo bin2hex($s) . ':' . $s . ' -> ';
  echo bin2hex($e) . ':' . $e . "\n";
}

  echo phpversion() . "\n";

  test("\xC0\xAF", 'UTF-8');                  // 「/」の冗長表現(2バイト)
  test("\xE0\x80\xAF", 'UTF-8');              // 「/」の冗長表現(3バイト)
  test("\xF0\x80\x80\xAF", 'UTF-8');          // 「/」の冗長表現(4バイト)
  test("\xF8\x80\x80\x80\xAF", 'UTF-8');      // 「/」の冗長表現(5バイト)
  test("\xFC\x80\x80\x80\x80\xAF", 'UTF-8');  // 「/」の冗長表現(6バイト)
echo "-------------------------------\n";
  test("\xC0\xBC", 'UTF-8');                  // 「<」の冗長表現(2バイト)
  test("\xE0\x80\xBC", 'UTF-8');              // 「<」の冗長表現(3バイト)
  test("\xF0\x80\x80\xBC", 'UTF-8');          // 「<」の冗長表現(4バイト)
  test("\xF8\x80\x80\x80\xBC", 'UTF-8');      // 「<」の冗長表現(5バイト)
  test("\xFC\x80\x80\x80\x80\xBC", 'UTF-8');  // 「<」の冗長表現(6バイト)
echo "-------------------------------\n";
  test("A\xC2", 'UTF-8');                     // C2 (2バイトパターンなのに1バイト)
  test("A\xC2<", 'UTF-8');                    // C2 に < が続く
  test("A\xC2/", 'UTF-8');                    // C2 に / が続く
  test("A\xE6\xBC", 'UTF-8');                 // E6 BC (3バイトパターンなのに2バイト)
  test("A\xE6\xBC<", 'UTF-8');                // E6 BC に < が続く
  test("A\xE6\xBC/", 'UTF-8');                // E6 BC に / が続く
echo "-------------------------------\n";
  test("A\x8A", 'Shift_JIS');                 // 8A (Shift_JISの先行バイト)
  test("A\x8A/", 'Shift_JIS');                // 8A / が続く
  test("A\x8A<", 'Shift_JIS');                // 8A < が続く
echo "-------------------------------\n";
  test("A\xB4", 'EUC-JP');                    // B4 (EUC-JPの先行バイト)
  test("A\xB4/", 'EUC-JP');                   // B4 に / が続く
  test("A\xB4<", 'EUC-JP');                   // B4 に < が続く

このスクリプトをPHP4.4.9(PHP4系の最終バージョン)、PHP5.2.0〜PHP5.2.4で実行した結果は以下のようになります。バージョン表示以外は同じ結果です。

5.2.4
c0af:/ -> c0af:/
e080af:/ -> e080af:/
f08080af:/ -> f08080af:/
f8808080af:/ -> f8808080af:/
fc80808080af:/ -> fc80808080af:/
 -------------------------------
c0bc:< -> 266c743b:&lt;
e080bc:< -> 266c743b:&lt;
f08080bc:< -> 266c743b:&lt;
f8808080bc:< -> 266c743b:&lt;
fc80808080bc:< -> 266c743b:&lt;
 -------------------------------
41c2:A■ -> 41c200:A■ 
41c23c:A■< -> 41266c743b:A&lt;
41c22f:A■/ -> 41c22f:A■/
41e6bc:A■ -> 41e6bc00:A■ 
41e6bc3c:A■< -> 41266c743b:A&lt;
41e6bc2f:A■/ -> 41e6bc2f:A■/
 -------------------------------
418a:A■ -> 418a:A■
418a2f:A■/ -> 418a2f:A■/
418a3c:A■< -> 418a266c743b:A■&lt;
 -------------------------------
41b4:A■ -> 41b4:A■
41b42f:A■/ -> 41b42f:A■/
41b43c:A■< -> 41b4266c743b:A■&lt;

不正な文字エンコーディングは■で表示しています。UTF-8の冗長表現の場合でも記号がエスケープされているので、文字エンコーディング指定がまったく無視されているわけではありませんが、Shift_JISやEUC-JPに関しては、文字エンコーディングISO-8859-1が指定されているかのような動作です*1。ISO-8859-1はhtmlspecialcharsのマニュアルによれば、文字エンコーディング指定が省略された時のデフォルト値ですから、「htmlspecialcharsの第三パラメータは指定しても意味がない」と思う人が出ても不思議ではありません。続いて、PHP5.2.5〜PHP5.2.11、PHP5.3.0の結果を示します(PHP5.2.7は欠番になったので試していません)。やはり、バージョンの表示以外は同じ結果です。

5.2.5
c0af:/ -> c0af:/
e080af:/ -> e080af:/
f08080af:/ -> f08080af:/
f8808080af:/ -> f8808080af:/
fc80808080af:/ -> fc80808080af:/
 -------------------------------
c0bc:< -> 266c743b:&lt;
e080bc:< -> 266c743b:&lt;
f08080bc:< -> 266c743b:&lt;
f8808080bc:< -> 266c743b:&lt;
fc80808080bc:< -> 266c743b:&lt;
 -------------------------------
41c2:A■ -> :
41c23c:A■< -> :
41c22f:A■/ -> :
41e6bc:A■ -> :
41e6bc3c:A■< -> :
41e6bc2f:A■/ -> :
 -------------------------------
418a:A■ -> :
418a2f:A■/ -> 418a2f:A■/
418a3c:A■< -> 418a266c743b:A■&lt;
 -------------------------------
41b4:A■ -> :
41b42f:A■/ -> 41b42f:A■/
41b43c:A■< -> 41b4266c743b:A■&lt;

確かに一部の■が取り除かれていますが、全てではありません。上記結果より、以下のような処理となっていることが伺えます。

  1. UTF-8の冗長表現は許容している
  2. UTF-8の冗長表現であっても、記号のエスケープは行われる
  3. UTF-8として不正な文字(冗長表現は別)が1カ所でもあれば、出力は空になる、
  4. Shift_JIS、EUC-JPの「半端な先行バイト」が入力の末尾にあると、出力は空になる
  5. Shift_JIS、EUC-JPの先行バイトに続くバイトが不正の場合、先行バイトは単独の文字として扱われる

個人的には、どうしてこんな中途半端な仕様にしたのだろうと思いますが、上記の仕様でXSSの攻撃に対する抜けが生じるかと言えば、突破の方法はちょっと思いつきません(上記第4項が防御に貢献しています)。しかしながら、半端な先行バイトやUTF-8の冗長表現を許容する点で危なっかしいことも確かですし、不正なシーケンスを出力する必要は全くないわけですから、以下のような仕様にすべきだと私は考えます。

  • 冗長なUTF-8は不正なエンコーディングとして扱う(出力を空にする)
  • Shift_JIS、EUC-JPの2バイト目が不正な場合も、エラーとして出力を空にする

htmlspecialcharsの変遷

htmlspecialcharsの仕様の変化を時系列で整理します。PHPのマニュアルによると、htmlspecialcharsの第三パラメータが追加されたのはPHP4.1.0(2001年12月)、そのパラメータが実際に機能しだしたのは、前述のようにPHP5.2.5(2007年11月)です。その他も含め、以下にhtmlspecialcharsの変遷をまとめました。

PHP3.0    1998/06/06    htmlspecialcharsの提供
PHP4.0.3  2000/10/11    第2パラメータ(quote_style)追加
PHP4.1.0  2001/12/10    第3パラメータ(charset)追加
                        UTF-8、Shift_JIS、EUC-JPもサポートされている
PHP5.2.5  2007/11/08    文字エンコーディングの検査が追加(完全ではない)

ごらんのように、htmlspecialcharsは、PHP3からサポートされている*2由緒正しい関数で、早い時期に文字エンコーディング指定ができるようになりました。しかし、文字コードのセキュリティという観点から言えば、当初の仕様では役にたたず、2007年11月のPHP5.2.5に至ってようやく最低限のチェックがなされるようなりました。

PHPプログラマの意識はどうか

次に、現場のPHPプログラマの意識について考察してみます。長い間charsetパラメータが有効でなかったため、「htmlspecialcharsのcharsetパラメータは指定しても無意味」という意識が、一部のPHPプログラマに定着しているのではないかと感じています。例えば、過去にはてなの日記「Shift_JISを利用することの是非」にて取り上げた「はじめてのPHPプログラミング 基本編5.3対応」という書籍には、以下のような記述があります。

(htmlspecialcharsは)通常は第2引数まで指定すればほぼ問題ありませんが、念には念を入れるのであれば第3引数(文字エンコーディング)まで指定した方が良いでしょう。
「はじめてのPHPプログラミング基本編5.3対応」P203より引用

これを読んだ読者は、第3引数は指定する必要はないのだなと思うことでしょう。また、同じエントリのコメント欄も興味深い内容です。

テストしましたがPHPに元々入っているhtmlspecialcharsに関してはSJISを第三引数に指定しても全く意味がないようです。

SJISとして認識できない半端文字の場合はSJIS指定しても貫通する様子なのですがどうでしょう。

[id:arrayさんのコメントより引用]

統計をとったわけではありませんが、この意見が現状のPHPプログラマの意識を象徴しているように感じます。ですが、現在のhtmlspecialcharsは不完全ながら文字エンコーディングをチェックしていますので、意識を変えていかなければいけませんね。

まとめ

htmlspecialcharsの第三パラメータにより文字エンコーディングを指定することにより、不正な文字エンコーディングによる攻撃をある程度防御していることが分かりました。しかし、前述のように中途半端な挙動であるため、入口でのバリデーションにより文字エンコーディングの妥当性チェックをしておくべきでしょう。また、PHP5.2.5以降を使用することが必須で、特別な理由がない限りPHPの最新バージョンを使うべきです。

また、今回のテーマに関連して、id:t_komuraさんの素晴らしいプレゼンテーションが公開されていますので、あわせて参考になさってください。私が指摘していない内容についても言及されています。

追記(2009/10/05)

id:t_komura さんがこの件に関して検証して下さいました。そのブログエントリ「Shift_JIS では、htmlspecialchars() を使用しても XSS が可能な場合がある」によると、
PHP の htmlspecialchars() では、SJIS(Shift_JIS) の場合、\xf0 - \xfc を単独で指定しても排除しない(PHP 5.3.0 で確認)

だそうです。

まぁ、入力・内部処理・出力を全てShift_JISで通す場合に問題になるものですので、通常このような文字エンコーディングの選択はしない方がよいとは思いますが、あり得ないことではないので注意が必要ですね。

文字コードの選択には、ITproに書いた解説をご参照ください。また、Shift_JISを通して使うことの問題については、はてなダイアリーに書いた「Shift_JISを利用することの是非」をご参照ください。

*1 UTF-8の際に不正な文字エンコーディングがあると末尾にナル文字がついていますが、これは単純なバグでしょうね。

*2 PHP/FIにはHtmlSpecialCharsという関数があったようですが


2009-09-24 [SQLインジェクション][SQL]

SQLの暗黙の型変換はワナがいっぱい

このエントリでは、SQLにおいて「暗黙の型変換」を使うべきでない理由として、具体的な「ワナ」をいくつか紹介します。 数値項目に対するSQLインジェクション対策のまとめにて説明したように、RDBの数値型の列に対してSQLインジェクション対策をする方法として、以下の三種類が知られています。

  1. バインド機構を用いる
  2. パラメータの数値としての妥当性確認を行う
  3. パラメータを文字列リテラルとしてエスケープする

このうち、方法3を使うべきでない説明の補足です。具体的には、方法3には、「暗黙の型変換」が発生しますが、それが思わぬ事故を招く可能性が高いことを説明したいと思います。
 方法3に従うと、最終的には以下のようなSQLを発行することになります。age列は年齢を示す列であり、整数型を想定しています。

SELECT * FROM sample1 WHERE age = '27'

挿入の場合は以下のようになります。

INSERT INTO sample1 (age) VALUES ('27')

これらに出てくる '27' という文字列リテラルは、SQL実行時に「暗黙に」型変換されます。

変換されるのは文字列型にか、数値型にか

暗黙の型変換では、プログラマは型の変換内容を明記しないため、RDB毎に持つ型変換ルールに従って型が決定されます。ISO/JISのSQLでは文字列型と数値型の「暗黙の型変換」は規定されていないため、データベースソフトウェア毎に処理系依存のルールがあります。

一番ありがちな以下のようなケースで考えてみましょう。数値型の列と文字列リテラルの比較です(ageは数値型の列)。

… WHERE age > '27'

左辺が数値型、右辺は文字列型です。この場合はどのように型変換されるのでしょうか。二種類の可能性があります。

  1. ageを文字列型に変換する
  2. '27'を数値型に変換する

どっちでも似たようなものと思われるかもしれませんが、この違いは重要です。処理結果とパフォーマンスに大きく影響するからです。結論としては、現在広く利用されているDBMS(MySQL、PostgreSQL、Oracle、MS SQL Server)では数値型に合わせられます。しかし、仮に文字列型にあわせるようなRDBMSがあったとすると、以下のような結果になります。

'9' > '27'

文字列の大小比較は辞書式順序に従うためです。

現実には、数値型にそろえる形で変換されるので、先のSQLは以下のように変形され、実行されます。

… WHERE age > 27

これはこれで大丈夫なのですが、こんどは列が文字列型、リテラルが数値型の場合で考えてみます。日本オラクル社にはかつて社員番号0を持つ社員犬「ウェンディ」がいたそうですので、彼女を検索してみましょう。

…WHERE employeeid = 0

社員番号を持つ列employeeidは、実際には文字列型であると想定します*1。この場合、列employeeidの方が数値型に変換されながら検索が実行されます。これはパフォーマンスの低下をもらたらします。インデックスが使用できないからです。実際問題として、数値型に変換後に 0 に一致する文字列は、「0」の他、「00」、「00000」など無数にあるため、そのようなインデックスは作成できないのです。結果として、文字列型の列と数値リテラルを比較すると、インデックスが使用されないためにパフォーマンスが低下します。

文字列型からどの数値型に変換されるのか

次に、文字列リテラルから数値型に「暗黙に」変換された結果は、どのような数値型になるのでしょうか。私が調べた範囲では、以下のような可能性があります。

  1. 浮動小数点数型になる
  2. 文脈に応じて柔軟に型が決定される

私が調べた範囲では、MySQLは常に 1.の浮動小数点数になり、PostgreSQL、MS SQL、Oracleは 2.の文脈に応じた型になるようです。あぁ、浮動小数点数と聞いただけでイヤな感じがしたあなた、あなたのイヤな感じは現実のものになります。以下、MySQL 5.1での実験結果を紹介します。まず、次のようなテーブルを用意します。

mysql> create table dtest0 (d0 decimal(20, 0));
Query OK, 0 rows affected (0.01 sec)

ご覧のように、十進20桁の列を一つ持つテーブルです。これに「文字列」を挿入してみます。

mysql> insert into dtest0 values('12345678901234567890');
Query OK, 1 row affected (0.00 sec)

mysql> select * from dtest0;
 +----------------------+
 | d0                   |
 +----------------------+
 | 12345678901234567890 |
 +----------------------+
1 row in set (0.00 sec)

挿入においては、期待通りに動いています。念のため、以下のSELECTでも確認してみましょう。

mysql> select * from dtest0 where d0 = 12345678901234567890;
 +----------------------+
 | d0                   |
 +----------------------+
 | 12345678901234567890 | 
 +----------------------+
1 row in set (0.00 sec)

問題ないですね。それでは、問題の「暗黙の型変換」を伴うパターンです。

mysql> select * from dtest0 where d0 = '12345678901234567890';
Empty set (0.00 sec)

あれ、見つからないですね。それでは、この列を挿入したらどうでしょうか。

mysql> insert into dtest0 values (12345678901234570000);
Query OK, 1 row affected (0.05 sec)

mysql> select * from dtest0 where d0 = '12345678901234567890';
 +-----------------------+
 | d0                    |
 +-----------------------+
 | 123456789012345670000 |
 +-----------------------+

今度は見つかりましたが、期待に反して、「12345678901234570000」という値がヒットしています。なぜでしょうか。
 実は、WHERE句の実行に先立ち、'12345678901234567890'が浮動小数点数に変換されているからです。その様子を実験してみましょう。

mysql> select '12345678901234567890'+0;
 +--------------------------+
 | '12345678901234567890'+0 |
 +--------------------------+
 |    1.23456789012346e+019 |
 +--------------------------+
1 row in set (0.00 sec)

0を加算することによって、数値への「暗黙の型変換」結果を表示させました。ご覧のように、浮動小数点数になっています。先の表示「12345678901234570000」とは末尾が少し異なりますが、これは丸めによるものです。このあたりが浮動小数点数のイヤラシイところですね。しかし、ここで示した挙動はMySQLのリファレンスマニュアルにちゃんと書いてあります。

次のルールは、比較の演算に対してどのように変換が行われるかを示しています :

  • 一方か両方の引数が NULL の場合、比較の結果は、NULL-safe <=> 等値比較演算子以外は、NULL になります。NULL <=> NULL の場合、結果は true です。

  • 【中略】

  • 他のすべてのケースでは、引数は浮動少数点 ( 実 ) 数として比較されます

[11.1.2. 式評価でのタイプ変換より引用]

ふつー、こんなとこまで読まないよと言いたくなりますが、「暗黙の型変換」を利用する場合は、このあたりのことも知った上で、SQLの実行結果を予測しなければならないということです。佐名木智貴氏が、「セキュアWebプログラミングTips集(ソフト・リサーチ・センター)」の中で述べられた名文句を思い出します。

ぜひ読者諸氏には今一度、自分の使っているデータベース・ソフトウェアのSQLリファレンスを通読することを推奨する(同書P213)。

私はとても「リファレンスを通読」する根性はないので、できるだけ「安全第一」のプログラミングにより、予期せぬ挙動が入らないようにしています。「暗黙の型変換を避ける」というのもその一つで、セキュリティ上の安全にもつながると信じます。

他のDBMSはどうか

ここで、他のDBMSにも少し触れます。PostgreSQL、MS SQL Server、Oracleは、調べた範囲ではMySQLほどイヤラシイことにはならなかったのですが、油断は禁物です。先に、MySQLで実行した「'12345678901234567890'+0」をPostgreSQL(8.4)で実行すると、以下のようになります。

test=# select '12345678901234567890' + 0;
ERROR:  値"12345678901234567890"は型integerの範囲外です
行 1: select '12345678901234567890' + 0;
             ^

以下は、MS SQL Server(SQL Server 2008)の実行結果

varchar の値 '12345678901234567890' の変換が int 型の列でオーバーフローしました。

これらPostgreSQLとMS SQL Serverでは、文字列リテラルと数値型の演算に際して、演算対象の数値型に型をあわせているようです。PostgreSQLのマニュアルには以下の記述があります。

文字列リテラルに型が指定されていない場合、後述するように、後の段階で解決されるようにとりあえず場所を確保するための型であるunknownが割り当てられます

[10.1. 概要より引用]

a. 二項演算子の1つの引数がunknown型であった場合、この検査のもう片方の引数と同一の型であると仮定します。

[10.2. 演算子より引用]

これを確かめるため、以下の実験をしてみました。PostgreSQL8.4の結果を示しますが、MS SQL Server 2008でも同様の結果です。

test=# select '12345678901234567890' + cast(0 as decimal(20,0));
       ?column?
 ---------------------
 12345678901234567890
(1 行)

数値0を decimal(20,0) という型にキャストしたので、文字列リテラルの方も同じ型にあわせてくれました。便利ですね…なんてことはないのであって、素直に以下のように書けばよいのです。

tokumaru=# select 12345678901234567890 + 0;
       ?column?
 ---------------------
 12345678901234567890
(1 行)

DB2はどうか

Oracleは割合融通の利くRDBMSであり、暗黙の型変換についても期待したとおりに動いてくれます。一方、IBM DB2は、SQL仕様を厳格に実装しているという印象があり、従来「暗黙の型変換」を許していませんでした。これは、寺田氏(id:teracc)が「DB2の文字列→数値変換」として検証結果をまとめておられます。そこでは、DB2のV9.5とV8.2にて、文字列から数値への暗黙の型変換がエラーになることが示されています。私も、DB2 Express-C 9.5にて追試をして、同様の結果を確認しました。

ところが、最近DB2 V9.7(試用版)にて同様の確認をしたところ、V9.7に至って暗黙の型変換が認められるようになっていました。以下は、V9.5での結果。

db2 => select '1'+1 from dual
SQL0402N  算術関数または演算 "+"
のオペランドのデータ・タイプが、数値ではありません。  SQLSTATE=42819

続いて、V9.7での結果です。

db2 => select '1'+1 from dual

1
 ------------------------------------------
                                         2

  1 レコードが選択されました。

db2 => select '12345678901234567890'+0 from dual

1
 ------------------------------------------
                      12345678901234567890

  1 レコードが選択されました。

まだ十分確認ができていないのですが、DB2 V9.7は、Oracleとの互換性をウリにしているため、暗黙の型変換もOracleの挙動に合わせたものではないかと想像しています。DB2のこの仕様変更は、暗黙の型変換の仕様がDBMS毎に異なるだけではなく、同一DBMSのバージョンによっても挙動が異なることを示す良い例だと考えています。

まとめ

SQLの「暗黙の型変換」による予期しない動作について紹介しました。暗黙の型変換の詳細仕様は実装依存であり、時として、利用者の想定以外の動作をする場合があり、危険です。暗黙の型変換を避け、型変換が必要な場合は、CASTによる明示的な変換を行うようにするべきです。予期しない動作の例として以下のようなものを説明しました。

  • 検索時にインデックスが使用できずにパフォーマンスが低下する
  • 暗黙の型変換により精度が損なわれる
  • 暗黙の型変換により、型の範囲外になるというエラーが発生する
  • DBのバージョンアップにより暗黙の型変換の仕様が変わる

というわけで、このエントリの結論は以下のようになります。

  • 例えば、暗黙の型変換を避ける
  • どうしても暗黙の型変換を使いたかったら、SQLリファレンスを通読してからにする(冗談です)

参考:

追記(2009/09/25)

IBM DB2の上記仕様変更について、id:umqさんから指摘を頂き、IBM DB2のリファレンスに以下の記述が追記されていることがわかりました。

ストリング・データ・タイプを使用するオペランドは、算術演算を実行する前に、CAST 指定の規則を使用して DECFLOAT(34) に変換されます。詳しくは、『データ・タイプ間のキャスト』を参照してください。このストリングには、数値の文字ストリング表記が含まれていなければなりません。

[DB2 Version 9.7 for Linux, UNIX, and Windowsより引用]

この記述は、Version9.5のリファレンスには存在せず、Version9.7で追加されたことが分かります。また、DECFLOAT(34)という型は、十進浮動小数点数で34桁の精度を持つという意味ですので、DECIMAL型の31桁、BIGINTの19桁をカバーする数値型として選ばれているようです。なんだかIBMらしい、富豪的な仕様ですね。

ともあれ、DB2 Version9.7から「暗黙の型変換」が正式にサポートされたことがはっきりしました。やはり、「暗黙の型変換」などというマイナーな仕様は、いつの間にか仕様が変更されることは十分あり得るわけで、そのようなあやふやなものに依存するプログラミングは危険だということだと思います。

*1 社員番号を文字列型で保持することは業務システムでは広く行われます


トップ 追記