UTF-8 エンコーディングの危険性
昨日の記事で公開した Ruby 用の JSON パーサーに UTF-8 の検証機能を追加しましたが、本日はこれについて少し補足説明しようと思います。 UTF-8 は比較的扱いやすいエンコーディングですが、単純にデコードすると ASCII コードと同じ値になってしまうマルチバイトコードが存在するため、注意が必要です。文字エンコーディングについて詳しい方には当たり前のことかもしれませんが、案外広く知られてはいないような気がするので、記事にすることにしました。かくいう私も、少し前に気付いたばかりだったりします(^^ゞ
※誤解を招きやすい部分があるので、補足記事を書きました。よろしければ、ご参照ください。
UTF-8 の概要
UTF-8 は、最長で 6 バイト(Unicode の範囲では 4 バイト)になる可変長の文字コードです。シングルバイトの領域では ASCII に等しく、それ以外の領域では第 1 バイトが 0xC0〜0xFD 、第 2 バイト以降が 0x80〜0xBF で構成されるシーケンスとなります。第 1 バイトの値で 1 文字のバイト数が特定でき、あとは第 1 バイトの下位数ビットと第 2 バイト以降の下位 6 ビット(下表の x の部分)を並べるだけで UCS にデコードできます。
Unicode | UTF-8 |
---|---|
000000〜00007F | 0xxxxxxx |
000080〜0007FF | 110xxxxx 10xxxxxx |
000800〜00FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
010000〜1FFFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
このように UTF-8 は ASCII の上位互換であり、マルチバイトコードとシングルバイトコードの区別も明確で、とても扱いやすいエンコーディングです。しかし、実はこのエンコーディング方法には大きな落とし穴があります。
ASCII 文字を異なるシーケンスで表現できてしまう
上記の表を見て感の良い方は気付いたかもしれませんが、バイト長の長いシーケンスで表現できる領域が、より短いシーケンスの領域を完全に含んでいます。つまり、ある文字を表現できるシーケンスが複数存在するのです。例えば、 '<' (ASCII コードで 0x3C)は以下のコードでも表現できます。
0xC0 0xBC 0xE0 0x80 0xBC 0xF0 0x80 0x80 0xBC
もちろん、これらのコードは規格で使用禁止となっているのですが、それだけで話が済むほど平和な世の中でもありません。やはりアプリケーション側できちんと対処してやる必要があります。あまり説明するまでもないかもしれませんが、このような不正なシーケンスが脆弱性に繋がる最も単純なケースとしては、例えば以下のプロセスが考えられます(わかりやすさ優先で、かなり無理やりに脆弱な処理にしています)。
不正なシーケンスを含んだ UTF-8 文字列に対してエスケープ処理を行っても、マルチバイトで表現されたコードはすり抜けてしまいます。「何らかの処理」がデータベースへのアクセスであれば SQL インジェクションが可能になりますし、 UTF-8 に戻した際に不正なコードが通常の ASCII に変換され、 XSS などを許すことにもなります。
対処方法
基本的に、まともな国際化ライブラリを使っていれば、上記のような不正な文字コードはきちんと処理してくれるはずです。実際、 Opera, Firefox, IE ともに適切にエスケープしてくれました。また、 UCS に変換した後にエスケープ処理を行うことでも対処できるかもしれません。しかし、複数のモジュールで構成されるような規模の大きいアプリケーションでは、そのすべてが適切な処理を行っていると保証するのも、なかなか難しいかと思います。ここはやはり、すべての外部入力に含まれる不正なシーケンスを、水際で正規化するという処理を徹底するのが一番かと思います。
例えば Ruby の場合、不正な UTF-8 コードを検出する最も簡単な方法は、 String#unpack を使って UCS へ変換してみることです(昨日の記事への kazutanaka さんからのはてぶコメントにて、 iconv でも同様なことができるとご教示いただきました。まだきちんと確認していないのですが、 Ruby 以外でも使える汎用的なテクニックとして有用だと思います)。
utf8_str.unpack("U*")
※コメントで PuniPuni さんがご指摘のように、 String#unpack, Iconv ともにサロゲートペアの領域が検出できないようです。サロゲートペアの領域も検出する必要がある場合は、やはり自分で検証を行わなければなりません。
もし utf8_str に不正なシーケンスが含まれていれば、例外が発生します。しかし、この方法だと不正なシーケンスを含んだ文字列は全体を捨てるしか方法がありません。単純に処理を中断することが許されない場合は、やはり自分で正規化を行うしかありません。参考までに、私の JSON パーサー で使っている正規化処理を単体で使えるようにしたものを再掲載しておきます。この関数は、不正なシーケンスを見つけると引数 malformed_chr で指定された文字に置き換え、その結果の文字列を返します。
# str は正規化する UTF-8 文字列、 malformed_chr は不正なシーケンスを置き換える文字です。 # malformed_chr は文字列ではなく Unicode の整数を与えてください。 def validate_utf8(str, malformed_chr) code = 0 rest = 0 range = nil ucs = [] str.each_byte do |c| if rest <= 0 case c when 0x01..0x7f then rest = 0 ; ucs << c when 0xc0..0xdf then rest = 1 ; code = c & 0x1f ; range = 0x00080..0x0007ff when 0xe0..0xef then rest = 2 ; code = c & 0x0f ; range = 0x00800..0x00ffff when 0xf0..0xf7 then rest = 3 ; code = c & 0x07 ; range = 0x10000..0x10ffff else ucs << malformed_chr end elsif (0x80..0xbf) === c code = (code << 6) | (c & 0x3f) if (rest -= 1) <= 0 if !(range === code) || (0xd800..0xdfff) === code code = malformed_chr end ucs << code end else ucs << malformed_chr rest = 0 end end ucs.pack('U*') end
やっていることは、 UCS にいったん変換した上で不正なコードを malformed_chr に置き換え、再度 UTF-8 に戻しているだけです。ですので、最後の行で配列 ucs をそのまま返せば、 UTF-8 → UCS-4 変換になります。他にうまいアルゴリズムなどがありましたら、教えていただけると嬉しいです。
文字コード展開に関して
"\xnn" のような文字コード展開を内部処理中で行う場合は、さらに細心の注意が必要です。なぜなら、これらの展開で不正なシーケンスが生成される恐れがあるからです。例えば "\xC0\xBC" が不正なシーケンスになるのは明らかだと思います。このような処理がある場合は、文字コード展開を正規化の前に処理するか、文字コード展開時に不正なシーケンスが生成されないように工夫する必要があります。 JSON の "\u" シーケンスのような Unicode ベースの展開にはこの危険はありませんが、念のため私の JSON パーサーもエスケープシーケンスの展開後に正規化処理を行っています。なんらかのバグで不正なシーケンスが紛れ込むとまずいので・・・(^^ゞ
というわけで、 UTF-8 のデコードは簡単なようでいて、実はなかなか奥が深い(?)、というお話でした。それにしても、文字コードっていうのは難儀なものですね。どうやら、たとえ世界が Unicode で統一されても、我々開発者は思ったほど楽にはならないようです(笑)。
2007/2/23 追記
UTF-8 の不正なシーケンスを正常な文字に置き換える処理は「正規化」と呼ぶのが正しいようですので、該当部分を修正しました。ご指摘くださった皆さん、ありがとうございます。基本的に門外漢なので、専門の方からすると稚拙な部分が多々あるかと思います。お気づきの点があればどうかご指摘ください。さすがにいつまでもはてぶコメントをチェックし続けるのは無理なので、コメントなどでお願いします m(_ _)m
2007/2/24 追記
葉っぱ日記さんによると、昔 Nimda が利用した IIS の脆弱性がまさにこの性質によるものだったようです。たぶん URL 中の不正なパスを UTF-8 の段階で検出し、その後に内部エンコーディングである UTF-16 に変換していたのだと思います。用語「Unicode Web Traversal」@鳩丸ぐろっさり (用語集)さんに詳しい解説がありますので、興味のある方はどうぞ。
2007/2/26 追記
文字コード展開に関する注意を加えました。 JSON パーサーの作成時にはこれを考慮してエスケープの展開後に正規化を挟んだのですが、記事を書くときにはすっかり忘れていました。だめだなぁ・・・orz
2007/3/2 追記
PuniPuni さんのご指摘により、 validate_utf8 関数で 0x110000 以上のコードを不正なコードとして検出するようにし、 String#unpack などがサロゲートペア領域を不正扱いしない旨の注意書きを入れました。 PuniPuni さん、ご指摘ありがとうございます。
2007/3/21 追記
validate_utf8 のバグをいくつか修正しました。
2008/3/20 追記
コメントにて Psychs さんにご指摘いただいたバグを修正しました。
詳しくはこちらの記事をどうぞ!
この記事にコメントする