tohokuaikiのチラシの裏

技術的ネタとか。

UTF-8の冗長なエンコードとは何で、なんでそれがセキュリティ的に危ないのか?を文字コード知識レヴェル3くらいの凡プログラマが考えてみる

何故かあたり前にならない文字エンコーディングバリデーション | yohgaki's blog
ってあるように、いまいち文字コードの不正な判定による危険性ってのが分かってない。

SJISの問題は、(2/3)SQLインジェクションを根絶!セキュア開発の極意 - 第5回■注目される文字コードのセキュリティ問題:ITproの記事がわかりやすかった。

というか、やっぱりPHP使ってると誰でも一度は「なんじゃこの『¥』は?」って思うもんなんで。

なるほど、確かに↓の図のように「あるバイト」が2つの意味を持つっていう文字コード形態はやばいんだなと。

EUC-JPはそんなことはしないで、1つのバイトには1つの意味しか取らせない。

だけど、これでも文字化けが起こることがある。経験的には、「マルチバイトをXX文字で切り落としたい」とかやった場合。ちゃんと文字コードを判定してくれるPHPでいえばmb_substr何かを使えば問題ないけど、バイト数で切っちゃうsubstrだと、奇数文字で切っちゃう時は1文字が半分にちぎれる可能性がある。

んー、じゃあUTF-8は何が危ないの?

そのあたりの問題とか、他言語で文字マップ領域が被るっていう問題を解消したのがUincodeで、その実装の一つであるUTF-8なら問題ないんじゃないの〜っていう感じなんだけど、それは違うみたい。

それが書かれた記事が「第4回 UTF-8の冗長なエンコード:本当は怖い文字コードの話|gihyo.jp … 技術評論社」っていう記事。

この記事を指して最初に引用したohgakiさんのブログでは

このよう事は簡単に理解できると思っていたので

と書かれているが、これ全然簡単じゃないよ・・・って感じで色々考えてみたのがこのエントリ。(前置き長っ)

あくまで、「そうなんじゃないかな〜」って自分が考えただけなんで、間違ってたら突っ込みなどどうぞ。

そもそも、UTF-8ってどうやって文字を判別してるのか?

UTF-8は、マルチバイト1文字を表すのに、1バイト〜4バイトの可変なバイト数領域を使う。

固定にすれば良いのに、何でそんな面倒なことするんかっていうと、ASCII文字と互換性を持たせるためらしい。

で、第4回 UTF-8の冗長なエンコード:本当は怖い文字コードの話|gihyo.jp … 技術評論社によると、

Unicode文字範囲UTF-8でのバイト列
U+0000〜 U+007F0xxxxxxx
(00〜7F)
U+0080〜 U+07FF110xxxxx  10xxxxxx
(C2〜DF) (80〜BF)
U+0800〜 U+FFFF1110xxxx  10xxxxxx  10xxxxxx
(E0〜EF) (80〜BF)  (80〜BF)
U+10000〜
U+10FFFF
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
(F0〜F7)(80〜BF)(80〜BF)(80〜BF)

ってなってる。

自分が、「単に1と0のデータを文字として出力するマシン」になったつもりで、「このデータはUTF-8の文字列ですよ」ってデータが来た場合に

「あ、これは『あ』だ」
「これは、『\』だ」

とか見分けるために、「何バイトで1文字を表現してるのか」を判定するにはどうしたらいいかを、↑の表を見て考えてみた。

すると、UTF-8はすこぶる簡単に「これは3バイトで1文字だな」とかが分かるようになっている。

  1. 最初の1バイトの1ビット目が0なら「1バイトで1文字」。
  2. 最初の1バイトの1ビット目と2ビット目が1なら「2バイトで1文字」なので、次の1バイトも合わせて1文字を出力する。
  3. 最初の1バイトの1〜3ビット目が1なら「3バイトで1文字」なので、次と次の次の2倍とも合わせて1文字を出力する。

という感じ。

つまり、最初の1バイトは「何バイト一緒に食えばいいか」というのを教えてくれてる。

そして、「10」のビットで始まるバイトは「文字の中間」を構成するバイトということになる。

ところで、上のテーブルは110xxxxx (C2〜DF)ってなってるけど、C0〜DFじゃないかな?)

冗長なエンコードとは?

じゃあ、冗長なエンコードって何?ってことなんだけど。

それは「本来なら1バイトで済むデータを3バイト必要なように見せる」っていうことだと思う。

その例として、第4回 UTF-8の冗長なエンコード:本当は怖い文字コードの話|gihyo.jp … 技術評論社によると、「/」を挙げているのだけど。

正しいエンコード0x2F
不正なエンコード
0xC0 0xAF  (2バイト表現)
0xE0 0x80 0xAF  (3バイト表現)
0xF0 0x80 0x80 0xAF (4バイト表現)

何でこんなんが全部「/」として認識されるんだ!?って感じだけど、実際にやってみる。PHPで。

<?php
echo $sl1 = hex2bin('002f')."\n";
echo $sl2 = hex2bin('c0af')."\n";
echo $sl3 = hex2bin('e080af')."\n";

function hex2bin($hex_str)
{
	return pack("H*" , $hex_str);
}

ってやってコマンドラインから実行してみる。ターミナルの出力はUTF-8。なので、ターミナルが「UTF-8である」と解釈された文字が私の目に映るはず。

果たして

というように、3つの「/」が出力された。

じゃあ、これが本当にバイナリとして同じデータなのかどうかを確かめてみる。簡単に、別の文字コードに変換してみればいい。

EUC-JPに変換して出力してみると、

ってなった。

つまり、最初の「/」は本物の1バイトのスラッシュだけど、後の2つは1バイトのスラッシュではないということ。

SJISに変換しても

となる。

これは何が悪いのかというと、それは「変なUTF-8エンコードを見抜けないターミナル」(このキャプチャの場合はTeraTermPro UTF-8版)なんだけど、世の中にはそういうアプリが多いらしい。それはブラウザも多分にもれず、FirefoxやI.Eも同じ勘違いをしてしまうらしい。

なんでそんな勘違いをするの?

じゃあ、何でそんな勘違いを?って感じだけど、それはなんとなくだけど・・・
第4回 UTF-8の冗長なエンコード:本当は怖い文字コードの話|gihyo.jp … 技術評論社にある

の絵を見て、「0xE0 0x80 0xAF」を処理するときの動作として。。。

Firefox「現在の文字コードはUTF-8。次のバイトは、0xE0 ・・・1110....とすると3バイトで1つの処理だな」
Firefox「3バイトを1つに並べてみると、1100000 10000000 10101111・・・・」
Firefox「???最初の2バイトは意味のある部分のビットが全て0・・・最後の1バイトは『0101111』これは『00101111』を表してるのかな?」

ということなんだろうか?

この辺はよーわかりませんが、なんとなくそんな感じかも。

これがXSSにつながるって言うのは

要するに、「3バイト食いますよ〜」っていうフラグを立てておきながら(つまり1100000のバイトを送る)、2バイトしか送らなかった場合、次の文字も巻き込んでしまうんじゃないかってことかな?これって、EUC-JPやSJISであったのと同じ問題で。それがUTF-8でも起きるっていう。

そうすると、HTMLの閉じタグとかクォートを食っちゃえばXSSが簡単に成立するてことで。



しかし、これを「このよう事は簡単に理解できると思っていたので、」と言っちゃうohgakiさんには

      r ‐、
      | ○ |         r‐‐、
     _,;ト - イ、      ∧l☆│∧  セキュリティ エバンジェリストの諸君!
    (⌒`    ⌒・    ¨,、,,ト.-イ/,、 l  われわれPHPerにとって大切なのは
    |ヽ  ~~⌒γ⌒) r'⌒ `!´ `⌒)  すぐ使えるサンプルか、コピペで動くコードだけだ。
   │ ヽー―'^ー-' ( ⌒γ⌒~~ /|  PHPerを買いかぶらないでもらいたい
   │  〉    |│  |`ー^ー― r' |
   │ /───| |  |/ |  l  ト、 |
   |  irー-、 ー ,} |    /     i
   | /   `X´ ヽ    /   入 

と、言いたい。