プライバシーポリシー

2009年9月30日水曜日

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

補足

この記事は旧徳丸浩の日記からの転載です。元URLアーカイブはてなブックマーク1はてなブックマーク2
備忘のため転載いたしますが、この記事は2009年9月30日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり


このエントリでは、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という関数があったようですが

0 件のコメント:

コメントを投稿