UTF-8の冗長なエンコードとは何で、なんでそれがセキュリティ的に危ないのか?を文字コード知識レヴェル3くらいの凡プログラマが考えてみる @ それ図解で。・・・tohokuaikiのチラシの裏
これを読んで、ちゃんと疑問に持ったことを検証したりするのって凄いなとか、自分は文字コードってかなりなんとなくでしかわかっていないな、と思いこれがレベル3であればそれを埋めるための知識をちゃんと理解しようと勉強しつつエントリをまとめてみました。
はじめに
ってことで、文字コードを理解するに当たって現状はというと、
・ 文字コードってメジャーどころだとEUC-JPとかShift-JISとかUTF-8とか色々あるよね
・ その当たりのコード変換すると理由はよくわからないけど文字化けすることあるよね
・ 文字コード表ってよく見るけどマトリックス表に漢字とかが埋められてて、それが文字コードごとに違うんだよね
ってぐらいのレベル(正直ひどい)
まぁ、それでも困らずエンジニアをやってこれたところもあるのですが、やっぱり基礎のところをちゃんと抑えておきたいなと。
アプリケーションのレイヤだけしか気にしていないとあまりこういった文字コード周りも真剣に触れる機会も無かったりします。
どこで対応すべきかって議論はありますけど、基本ミドルでやってくれたらいいなぁ、なんて思いもあったりしますからね。
ゴールとしては、先に紹介した記事につながる感じ。
ここでの知識を持ってあのエントリを読めばやろうとしていたことがわかりましたよ、ってところを目標にしています。
ちなみに、文字コード表なんかを見ると「0x41」とか16進数や2進数で表現されていることが多く、そのあたりの計算方法などは割愛します。(というか掘り下げだしたら数学の授業になる・・・)
※ 頭に「0x」がつくと16進数という意味
レベル1の文字コード入門: ASCIIコード編
とりあえず文字コードの歴史から・・・、となるとかなり長くなりますし普段そこまで多くの文字コードに触れることも無いので割愛。
ここでは、ASCIIとEUC-JP、Shift-JISの文字コードを中心に書いていきます。
歴史的な背景も含めて勉強するのであれば、下記が参考になるかと思います。
まずは基本となるASCIIコードから。
ASCIIコードは、American Standard Code for Information Interchangeの略であるというところからもわかるように、英語圏で使用する文字を定義しているコード体系になっています。
ASCIIコード表(@IT用語辞典) を見てみると、キーボードに並んでいる英数字や記号が管理用のテーブルに並んでいることがわかります。
で、このコードが7ビットのコードでできていると。
1バイトは8ビットであるのになんで?ってところがありますが、英語圏では8ビット(256文字)もイラネってところがあって、その半分の7ビット(128文字)分しか使わないようになったようです。(最上位ビットは常に0)
参考: パソコン・ノート (文字コードの話) @ ハングル工房 東京綾瀬
なので、7ビットのコードに128文字分割り当てているわけです。
(実際には制御文字ってのがあって目に見えない文字になり、コンピュータとの通信用に使われたりする文字が含まれています)
例えば、「0x41」は「A」を表すという具合に。
じゃあ、7ビットで文字が表せるんだよね、ってことで試しに出力するPHPスクリプトを作ってみました。
ちなみにASCIIコードの場合は、0x1fまで(32文字分)が制御文字になっていて、出力しても見えないのでそれ以降を出力するようにしています。
<?php for ($i = 32; $i < 128; $i++) { echo $i . "\t" . // 10進で出力 sprintf("%07s", base_convert($i, 10, 2)) ."\t" . // 2進で出力 sprintf("%04s", base_convert($i, 10, 16)) . "\t" . // 16進で出力 "\"" . pack("H*", base_convert($i, 10, 16)) . "\"" . // 該当コードの文字 "\n"; } ?>
32 0100000 0020 " " 33 0100001 0021 "!" 34 0100010 0022 """ 35 0100011 0023 "#" 36 0100100 0024 "$" 37 0100101 0025 "%" 38 0100110 0026 "&" 39 0100111 0027 "'" 40 0101000 0028 "(" 41 0101001 0029 ")" 42 0101010 002a "*" 43 0101011 002b "+" 44 0101100 002c "," 45 0101101 002d "-" 46 0101110 002e "." 47 0101111 002f "/" 48 0110000 0030 "0" 49 0110001 0031 "1" 50 0110010 0032 "2" 51 0110011 0033 "3" 52 0110100 0034 "4" 53 0110101 0035 "5" 54 0110110 0036 "6" 55 0110111 0037 "7" 56 0111000 0038 "8" 57 0111001 0039 "9" 58 0111010 003a ":" 59 0111011 003b ";" 60 0111100 003c "<" 61 0111101 003d "=" 62 0111110 003e ">" 63 0111111 003f "?" 64 1000000 0040 "@" 65 1000001 0041 "A" 66 1000010 0042 "B" 67 1000011 0043 "C" 68 1000100 0044 "D" 69 1000101 0045 "E" 70 1000110 0046 "F" 71 1000111 0047 "G" 72 1001000 0048 "H" 73 1001001 0049 "I" 74 1001010 004a "J" 75 1001011 004b "K" 76 1001100 004c "L" 77 1001101 004d "M" 78 1001110 004e "N" 79 1001111 004f "O" 80 1010000 0050 "P" 81 1010001 0051 "Q" 82 1010010 0052 "R" 83 1010011 0053 "S" 84 1010100 0054 "T" 85 1010101 0055 "U" 86 1010110 0056 "V" 87 1010111 0057 "W" 88 1011000 0058 "X" 89 1011001 0059 "Y" 90 1011010 005a "Z" 91 1011011 005b "[" 92 1011100 005c "\" 93 1011101 005d "]" 94 1011110 005e "^" 95 1011111 005f "_" 96 1100000 0060 "`" 97 1100001 0061 "a" 98 1100010 0062 "b" 99 1100011 0063 "c" 100 1100100 0064 "d" 101 1100101 0065 "e" 102 1100110 0066 "f" 103 1100111 0067 "g" 104 1101000 0068 "h" 105 1101001 0069 "i" 106 1101010 006a "j" 107 1101011 006b "k" 108 1101100 006c "l" 109 1101101 006d "m" 110 1101110 006e "n" 111 1101111 006f "o" 112 1110000 0070 "p" 113 1110001 0071 "q" 114 1110010 0072 "r" 115 1110011 0073 "s" 116 1110100 0074 "t" 117 1110101 0075 "u" 118 1110110 0076 "v" 119 1110111 0077 "w" 120 1111000 0078 "x" 121 1111001 0079 "y" 122 1111010 007a "z" 123 1111011 007b "{" 124 1111100 007c "|" 125 1111101 007d "}" 126 1111110 007e "~" 127 1111111 007f ""
きちんとASCIIコード表になっていることがわかります。
最後の0x7fは何も出力されていませんが、これは「DEL」を表し制御文字になっています。
16進の1バイトデータを復号(目に見える文字に戻すこと)すれば右端の文字に変わるわけです。
逆に普段使われているASCII文字は内部では16進のデータに符号化(デジタルデータに変換すること)されていることがわかります。
予断ですが、PHPスクリプト内で使っているpack() という関数は、バイナリデータを一括りにするための関数です。
オプションに「H*」をつけると、16進数の文字列で上位ニブルが先という意味になります。
ニブルというのは、4ビットをさします。
つまり、上位から4ビットずつバイナリデータを括るという操作になっています。
参考: pack関数とunpack関数の基本を理解する @ Chaichan-World !WEB相談室
ビット数の並びによって文字のデータを作り出しているわけですね。
文字コード表のイメージがなんとなくつかめたでしょうか?
- ここまででわかったこと
・ 文字コードは決められたデータ(ビット長)に符号化されており、復号する処理で目に見える文字となる
レベル1の文字コード入門: Shift-JISコード編
で、次にShift-JISのコード表を出力してみます。
Shift-JISの場合、Shift-JISコード表 を見ればわかるように、1バイトコードと2バイトコードに分かれており、先ほど書いたように0x00から0x7fまではASCIIコードと同じ配列になっています。
それに加え、ASCIIコードでは余っていた1バイトコードの残りの部分に半角カナの文字を定義しています。
一方で、2バイトコードは上位1バイトが0x81から0x9f、0xe0から0xeeで始まる部分に、様々な漢字や全角記号が定義されています。
それらの部分は、1バイトコード部分を見てみると未定義になっていることがわかります。
まとめるとこんな感じ
1バイト領域 | 0x00~0x1f、0x7f | 制御文字 | |
---|---|---|---|
0x20~0x7e | ASCII文字 | ||
2バイト領域 | 上位1バイト | 0x81~0x9f、0xe0~0xef | 全角文字 |
下位1バイト | 0x40~0x7e、0x80~0xfc |
なので、Shift-JISコードを1バイトを取得してみて「0x81から0x9f」または、「0xe0から0xee」で始まっていれば全角の領域ということに、それ以外は半角文字(半角カナを含む)というロジックがあるわけです。
参考: 第5回■注目される文字コードのセキュリティ問題(P.2) / @ ITpro
で、同じように文字コードを出力してみます。
<?php // 漢字コード出力 // 上位1バイト目が0x81(129)から0xef(240)まで for ($i = 129; $i < 240; $i++) { // 上位1バイト目の0xa0(160)から0xdf(225)までは対象コードなし if ($i > 159 && $i < 224) { continue; } // 下位1バイト目が0x40(64)から0xfc(252)まで for ($j = 64; $j < 253; $j++) { echo sprintf("%02s", base_convert($i, 10, 16)) . // 上位1バイト目(区) sprintf("%02s", base_convert($j, 10, 16)) . "\t" . // 下位1バイト目(点) "\"" . pack("H*", base_convert($i, 10, 16) . base_convert($j, 10, 16)) . "\"" . // 出力文字 "\n"; } } // 半角かなコード出力 // 上位1バイト目の上位4ビットが0xa(10)から0xd(13)まで for ($k = 161; $k <= 223; $k++) { echo sprintf("%02s", base_convert($k, 10, 16)) ."\t" . // 2進 "\"" . pack("H*", base_convert($k, 10, 16)) . "\"" . // 出力文字 "\n"; } ?>
8140 " " 8141 "、" 8142 "。" 8143 "," 8144 "." 8145 "・" 8146 ":" 8147 ";" 8148 "?" 8149 "!" 814a "゛" 814b "゜" 814c "´" 814d "`" 814e "¨" 814f "^" 8150 " ̄" 8151 "_" 8152 "ヽ" 8153 "ヾ" 8154 "ゝ" 8155 "ゞ" 8156 "〃" 8157 "仝" 8158 "々" 8159 "〆" 815a "〇" 815b "ー" 815c "―" 815d "‐" 815e "/" 815f "\" 8160 "~" 8161 "∥" -snip- a1 "。" a2 "「" a3 "」" a4 "、" a5 "・" a6 "ヲ" a7 "ァ" a8 "ィ" a9 "ゥ" aa "ェ" ab "ォ" ac "ャ" ad "ュ" ae "ョ" af "ッ" b0 "ー" b1 "ア" b2 "イ" b3 "ウ" b4 "エ" b5 "オ" -snip-
上位1バイト目の「0xa1から0xb5」までに半角カナを定義してしまっているが故に、単純に1バイト目が特定の領域以降なら半角だ、というような判断ができません。
なんでそんなことをしたのかは、歴史的経緯があるため割愛。
ただ、ASCIIコードとの互換性を持たせるというところに基本があり、余っているところでうまく日本語を使えるようにしましょうや、というところがあるのはわかったりします。
ちなみに、文字コード表を見るとよく区とか点とか書いていますが、2バイトのうちの先行バイトを区、後続バイトを点と読んでいます。
これは、EUC-JPでも同様です。
レベル1の文字コード入門 EUC-JPコード編
次に、EUC-JPのコードです。
こちらも、EUC-JPのコード表 を見ればわかるように、1バイト目はASCIIコードとあわせて定義されています。
Shift-JISとの違いは、1バイト目はASCIIコードとまったく同じで半角カナを別の領域(2バイトコード目)に定義されていることです。
1バイト目で空きになっている「0xa1から0xfe」が全角文字用に割り当てられています。
1バイト領域 | 0x00~0x1f、0x7f | 制御文字 | |
---|---|---|---|
0x20~0x7e | ASCII文字 | ||
2バイト領域 | 上位1バイト | 0xa1~0xfe | 全角文字 |
下位1バイト | 0xa1~0xfe |
こちらの方がShift-JISに比べてすっきりしてわかりやすいですよね。
単純化すると1バイト目が「0xa1」以降であれば全角文字なんだな、ということが判断できるので。
参考: 第5回■注目される文字コードのセキュリティ問題(P.3) @ ITPro
ただし、EUC-JPの場合は、半角カナが2バイトで表されており、1バイト目が「0x8e」の領域に定義されています。
1バイト目が「0x8e」で始まる場合は、半角カナということになっており、Shift-JISと大きく異なります。
<?php // 漢字コード出力 // 上位1バイト目が0xa1(161)から0xfe(255)まで for ($i = 161; $i < 255; $i++) { // 下位1バイト目が0xa1(255)から0xfeまで for ($j = 161; $j < 255; $j++) { echo sprintf("%02s", base_convert($i, 10, 16)) . // 上位1バイト目(区) sprintf("%02s", base_convert($j, 10, 16)) . "\t" . // 下位1バイト目(点) "\"" . pack("H*", base_convert($i, 10, 16) . base_convert($j, 10, 16)) . "\"" . // 出力文字 "\n"; } } // 半角かなコード出力 // 下位1バイト目が0xa1(161)から0xdef(224)まで for ($k = 161; $k < 224; $k++) { echo "8e" . // 上位1バイト目(区)は8eで固定 sprintf("%02s", base_convert($k, 10, 16)) . "\t" . // 下位1バイト目(点) "\"" . pack("H*", "8e" . base_convert($k, 10, 16)) . "\"" . // 出力文字 "\n"; } ?>
a1a1 " " a1a2 "、" a1a3 "。" a1a4 "," a1a5 "." a1a6 "・" a1a7 ":" a1a8 ";" a1a9 "?" a1aa "!" a1ab "゛" a1ac "゜" a1ad "´" a1ae "`" a1af "¨" a1b0 "^" a1b1 " ̄" a1b2 "_" a1b3 "ヽ" a1b4 "ヾ" a1b5 "ゝ" a1b6 "ゞ" a1b7 "〃" a1b8 "仝" a1b9 "々" a1ba "〆" a1bb "〇" a1bc "ー" a1bd "―" a1be "‐" a1bf "/" a1c0 "\" a1c1 "~" a1c2 "∥" -snip- 8ea1 "。" 8ea2 "「" 8ea3 "」" 8ea4 "、" 8ea5 "・" 8ea6 "ヲ" 8ea7 "ァ" 8ea8 "ィ" 8ea9 "ゥ" 8eaa "ェ" 8eab "ォ" 8eac "ャ" 8ead "ュ" 8eae "ョ" 8eaf "ッ" 8eb0 "ー" 8eb1 "ア" 8eb2 "イ" 8eb3 "ウ" 8eb4 "エ" 8eb5 "オ" -snip-
出力する文字は同じでも符号化している体系がShift-JISとはまったく異なっていることがわかります。
1バイトコードのと2バイトコードがはっきり分かれているため、Shift-JISよりはわかりやすいですね。
- ここまででわかったこと
・ 文字コードを符号化する方式が文字コードによって異なっている
・ 文字コードの出力時に行われる復号にはロジックが存在している
レベル2の文字コード入門: 文字化けの原因とUTF-8への道
ここまでの話で文字コードって単純にビットの並びを割り当てただけではなく、復号するのに面倒なことしてるんだな、ということがわかりました。
この複雑さが文字化けを起こしたり、セキュリティの問題を引き起こすことになっています。
例えば、先ほどのITproの記事の中であった、EUC-JPの文字化けの例だと
参考: 第5回■注目される文字コードのセキュリティ問題(P.3) @ ITPro
<?php // 「EMC」の16進表現 $bin = "a3c5a3cda3c3"; $str = pack("H*", $bin); echo $str; ?>
$ php sample.php EMC
正しくは、上記のようになりますが、変数$strが何らかの理由で改ざんされたり上手く読み取れず上位1バイトが抜け落ちると
$ php sample.php 釘唯築
というような表示になります。
「0xc5a3」が「釘」に、「0xcda3」が「唯」と符号化されてしまうわけですね。
最後に「築」が出てますが、表示上は「築」に見えて「築」ではない文字です。
さらには、文字コードの始めに別のデータを付け加えることによって待った区別の文字に変更することもできたりします。
<?php // 「EMC」の16進表現 $bin = "a3c5a3cda3c3"; $bin = "b1" . $bin; $str = pack("H*", $bin); echo $str; ?>
$ php moji.php 隠釘唯築
最初と何の関係も無い文字が出てくるわけですね。
で、この誤認するってところがUTF-8の問題にもつながっていきます。
UTF-8は、Shift-JISやEUC-JPなどに比べて符号化のロジックがもう少し複雑です。
1バイト表現 | 0xxxxxxx | (0x00-0x7f) |
2バイト表現 | 110xxxxx 10xxxxxx | (0xc0-0xdf) (0x80-0xbf) |
3バイト表現 | 1110xxxx 10xxxxxx 10xxxxxx | (0xe0-0xef) (0x80-0xbf) (0x80-0xbf) |
符号化のロジックとしては
・ 1バイトは、ASCIIコードと同様
・ 2バイトは、上位1バイトの最初が「110」で始まり、下位1バイトの最初を「10」で始める
・ 3バイトは、1バイト目の最初が「1110」で始まり、以降のバイトの最初を「10」で始める
となってます。(なんかややこしいですが)
ただ、さっきの表の一番右に取りうる16進数を列挙していますが、これを2進数に変換したらきちんと、上記のような符号化に当てはまるようになってるんですね・・・。
<?php $bin = "e38182"; echo base_convert($bin, 16, 2) . "\t" . pack("H*", $bin); ?>
111000111000000110000010 あ
3バイト表現なので、1バイト目が「110」から始まり、以降バイトの最初が「10」から始まっている。
ただ、あくまで最初の「110」や以降のバイトの最初の「10」というのは、2または3バイトの文字であるということを表すためのフラグのようなものであって、文字としての意味は持っていないようです。
つまりは、
11100011 10000001 10000010
のように太字だけのところで文字が表現されていると。
なので、下記の記事にあるように「/」に複数の表現方法が生まれるというわけですね。
参考: 本当は怖い文字コードの話 @ gihyo.jp … 技術評論社
どうでしょう。
レベル3へつながったでしょうか・・・。