mbstring/libmbfl 正しいUTF-8チェックの強化

非最短形式UTF-8などのmbstringでUTF-8のチェックが甘いという指摘がでてからかなり立ちますが、PHP 5.4に向けて、対策を考えてみました。

libmbfl 1.3.1 からは、UTF-8の変換や検出時に行われる文字コード範囲検出において整形式であることを確認するチェックを導入します。上記の不正なバイト列は無効な文字として判定され、指定した処理が行われます。libmbfl 1.3.1 は PHP 5.5dev, PHP 5.4beta1 に適用予定です。

Unicoe/UTF-8の定義と背景

Unicodeの規格(最新は昨年10月にリリースされたUnicode 6.0)では、Unicodeは21bit空間(0-0x10FFFF)で定義され、約111万のコードポイントを有しますが、Unicode 6.0では約11万個について文字が登録されています。当初、16bit(UCS-2)で文字集合が定義されたUnicodeは当然ながら足りなくなり、31bitのUCS-4まで拡張されたのですが、21bitで落ち着いたようです。Unicodeは、業界団体であるUnicodeコンソーシアムと規格標準化組織であるISO/IEC 10646の共同作業で規格化が行われていますが、前者の方がより実装に近いより詳細な規格を定義しています。

UTF-8は、ASCIIの上位互換(西欧圏の人にやさしい)、先頭バイトの判別が容易などの特徴から使い勝手が良かったこともあり、インターネット環境で広く普及しています。UTF-8は、当初、UCS-4のコード空間をサポートするために最長6バイトの定義でした。当初のUTF-8の定義にはあいまいなところがあり、非最短形式のUTF-8というものが定義できてしまうため、RFC2279で指摘されているようにNULLバイト攻撃やパストラバーサル攻撃を受ける可能性が指摘されてきました。

非最短形式UTF-8のリスク

非最短形式UTF-8とは、例えば、'<' (0x3C)が 0xC0 0xBC、0xE0 0x80 0xBC、0xF0 0x80 0x80 0xBCとしても符号化できてしまうということです。'<'ならそう問題も起きないかもしれませんが、NULL文字(0x00)やドット '.' (0x2E)などは文字列の終端やパスに使用されているので、問題を発生する可能性があります。特にNULL文字などやドットに関するセキュリティチェックは、通常最短形式で行うため、このチェックをすり抜けられる可能性があることで重大な問題を引き起こす可能性があります(実際にIISなどはこのバグをつかれて有名なセキュリティ上の問題(Nimdaワーム)を発生しました)。非最短形式を解釈しないパーサと解釈するパーサが混在する場合に問題は深刻になります。

(なお、後述するように)非最短形式UTF-8についてはPHP 5.3においても一定の対策が行われています。

整形式(Well-Formed) UTF-8


整形式(Well-Formed) UTF-8は、http://www.unicode.org/versions/Unicode6.0.0/ch03.pdf#G7404
で以下のように定義されています。

Code Points First Byte Second Byte Third Byte Fourth Byte
U+0000..U+007F 00..7F
U+0080..U+07FF C2..DF 80..BF
U+0800..U+0FFF E0 A0*..BF 80..BF
U+1000..U+CFFF E1..EC 80..BF 80..BF
U+D000..U+D7FF ED 80..9F* 80..BF
U+E000..U+FFFF EE..EF 80..BF 80..BF
U+10000..U+3FFFF F0 90*..BF 80..BF 80..BF
U+40000..U+FFFFF F1..F3 80..BF 80..BF 80..BF
U+100000..U+10FFFF F4 80..8F* 80..BF 80..BF

2バイト定義の1バイト目がC2から始まること、および *を付けたところがポイントになります。
C0 AF, E0 9F 80などはこの範囲にないので、ill-formedとなります。

以下の例は上記のUnicode 6.0のドキュメントにある例で、不正なバイトを含んでいます。

mb_substitute_character(0xFFFD);
$s = "\x41\xc0\xaf\x41\xf4\x80\x80\x41";
echo mb_convert_encoding($s,"UTF-16","UTF-8");

出力は、以下の通り、Unicodeで推奨されるエラー時の変換結果となります(2011/9/7修正)。

U+0041 U+FFFD U+FFFD U+0041 U+FFFD U+0041

なお、不正なバイト列を検出した時の処理をどうするかも課題です。
現状では、何も返さないことや不正なバイトを返すことは新たなリスクを生み出すため、BAD+XXという形式(longをsubstitute_charに設定した場合)を返しています。また、代替文字にU+FFFDを設定した場合は、Unicode規格の「Constraints on Conversion Processes」にある推奨されるエラー処理結果となります。

PHPバージョン間の違い(2011/9/7追記)

PHP 5.2, 5.3 との違いについてわかりにくいので追記します。 PHP 5.2までは不正なUTF-8に関するチェックは甘かったのですが、PHP 5.3で一定の対策がされるようになっています。これは、例えば 3バイトのUTF-8 の場合は、U+0800〜U+FFFFという範囲にあることを確認するもので、非最短形式UTF-8については無視される(何も出力されない)ようになっています。

ただし、何も出力しないことでその前後のバイトが特別な意味を持ち、新たなリスクを生み出す可能性も指摘されており、基本的には不正な文字をユーザが指定した代替文字に置き換える手段を提供するべきです。また、不正なバイト列を判別し、代替文字を出力するタイミングもUnicodeのドキュメント記載の通り、不正なバイト列と判明した時点で行うことが望ましいと言えます。

以下の例は、Unicode 6.0のドキュメントに記載された不正なバイト列を含んでいます。

mb_substitute_character(0xFFFD);
$s = "\x41\xe0\x9f\x80\x41";
echo mb_convert_encoding($s,"UTF-16","UTF-8");

各バージョンの最新版(開発版)での結果は以下となります。

PHP 5.2.18dev: U+0041 U+07C0 U+0041
PHP 5.3.8dev: U+0041 U+0041
PHP 5.4dev U+0041 U+FFFD U+FFFD U+0041

PHP 5.2では、不正なバイト列を出力してしまっています。PHP 5.3は不正な文字列を判別していますが、その部分には何も出力されません。PHP 5.4では不正なバイト列 E0 9F および 80 に対応した代替文字(U+FFFD)が出力されます。

TODO

今回の処理に関しては、実運用の前に十分なテストを行う必要があります。

想定されるリスクには以下のようなものがあります。

  • UTF-8のチェックが厳しくなることで、下位互換性が失われる
  • UTF-8のチェックが厳しくなることで、通信エラー等に対する耐性が大幅に低下する
  • 厳しいチェックを行うパーサと緩いパーサが混在することによるリスク
  • 実装のバグにより生じる問題(セキュリティリスク等)