UTF-8のデコードをするべく我々はジャングルの奥地へと向かった

例えばbashでecho -e "\u65E5\u672C\u8A9E"を実行すると「日本語」と返してくれるが、逆の変換を行ってくれるツールは、Linux標準環境で探した限り見当たらない(2019/12/19 追記:iconvを使ってシェル芸すれば実現容易であることがわかった、詳細はこちら)。
ないなら作るしかない。

はじめに

Unicodeはマルチバイト文字をシングルバイト文字のみで表現できる規格である。
その中でも様々な符号化スキームがあるが、今回はUTF-8を対象とする。

ちなみにRFCを読んで気付いたのだが、コードポイント(U+65E5)→バイナリ(”日”)方向の変換が「エンコード」であり、バイナリ→コードポイント方向の変換を「デコード」と呼ぶらしい、感覚とは逆だった。

以前、デコードをしようと思って結局オンラインのデコードツールに頼ったことがあり、手元でコマンド1発で変換できないのはおかしい!と思って今回デコードに挑戦することになった。
当時何をしたかったちゃんと覚えていないが、確かpͪoͣnͬpͣoͥnͭpͣa͡inͥをデコードしたかったとかそんなしょうもない理由だったと思う(このネタをしばらく温めているうちに忘れた)。

UTF-8のデコード法

RFC3629に詳しく書いてあった。

まずUTF-8のコードポイントと、UTF-8のバイナリ表現は以下のように対応している。

 Char. number range  |        UTF-8 octet sequence
(hexadecimal) | (binary)
--------------------+---------------------------------------------
0000 0000-0000 007F | 0xxxxxxx
0000 0080-0000 07FF | 110xxxxx 10xxxxxx
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

今回はデコードなので、右列から左列方向に変換を行う。
その手順は以下のようになる。

  1. 先頭オクテットの先頭ビットを見ることで、何オクテットを取得するか判断する。
  2. 右列のx部分(1行目なら7桁、2行目なら5+6=11桁)のみを抽出して並べる。
  3. 出来上がったビット列を16進変換する。

BashにおけるUnicodeの扱い

動作確認にはbash-5.0を使用した。
以下は僕の愛読書でもある(!?) man bashに記載されている。

QUOTING
- 中略 -
\uHHHH
the Unicode (ISO/IEC 10646) character whose value is the hexadecimal value HHHH (one to four hex digits)
\UHHHHHHHH
the Unicode (ISO/IEC 10646) character whose value is the hexadecimal value HHHHHHHH (one to eight hex digits)

かいつまんで言うと、\uから始まる場合は最大4文字、\Uから始まる場合は最大8文字をUnicodeとして認識する。
したがって、α (U+03B1) は"\u3b1"でも"\u03b1"でも"\U3b1"でも"\U000003b1"でもエンコードしてくれる。
しかし、"αa"を表現しようとした際に"\u3b1a"と書いてしまうと、期待と全く違う"㬚"が表示されてしまうため、ゼロ埋めをする方が無難だろう。
"\u03b1a"は最初の4桁でエンコードが終わるので、正しく"αa"が出力される。

デコードスクリプト

英語の仕様を見ながら数日格闘した結果以下のスクリプトができた。

#!/usr/bin/env bash

# array which elements are two bytes hex string
tbts=($(echo -n $1 | xxd -c1 | cut -f 2 -d " "))

while [[ ${#tbts[@]} -gt 0 ]]
do
        # 0xxxxxxx
	if [[ $((0x${tbts[0]}>>7)) -eq 0 ]]
	then
		echo -en \\u${tbts[0]}
		tbts=(${tbts[@]:1})

        # 110xxxxx 10xxxxxx
	elif [[ $((0x${tbts[0]}>>5)) -eq 6 ]]
	then
		echo -n \\u$( printf "%04x" $(( (0x${tbts[0]}&31)<<6|(0x${tbts[1]}&63) )) )
		tbts=(${tbts[@]:2})

        # 1110xxxx 10xxxxxx 10xxxxxx
	elif [[ $((0x${tbts[0]}>>4)) -eq 14 ]]
	then
		echo -n \\u$( printf "%04x" $(( ((0x${tbts[0]}&15)<<6|(0x${tbts[1]}&63))<<6|(0x${tbts[2]}&63) )) )
		tbts=(${tbts[@]:3})

        # 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
	elif [[ $((0x${tbts[0]}>>3)) -eq 30 ]]
	then
		echo -n \\U$( printf "%08x" $(( (((0x${tbts[0]}&7)<<6|(0x${tbts[1]}&63))<<6|(0x${tbts[2]}&63))<<6|(0x${tbts[3]}&63) )) )
		tbts=(${tbts[@]:4})
	else
		echo "Convert Error" >&2
		exit 1
	fi
done
echo

簡単に解説する。

# array which elements are two bytes hex string
tbts=($(echo -n $1 | xxd -c1 | cut -f 2 -d " "))

まず引数をxxdして2バイト1行で表示する。
xxdの出力は左右に余計なものが出るので、cutでhex部分だけ抽出する。
それを配列としてtbts (two bytes)に格納する。

while [[ ${#tbts[@]} -gt 0 ]]

後述の処理でtbtsの要素を先頭から取り出す。全てなくなるまで繰り返す。

# 0xxxxxxx
if [[ $((0x${tbts[0]}>>7)) -eq 0 ]]
then
	echo -en \\u${tbts[0]}
	tbts=(${tbts[@]:1})

ifの部分は先頭オクテットのバイナリパターンによる条件分岐である。
最初は0xxxxxxxの場合なので、先頭bit(0x${tbts[0]}で整数展開し、7ビット右シフト)が0であればよい。
この場合は通常のascii文字と変わらないので、\\u${tbts[0]}をエスケープ文字列として出力する。
これによって、\u0061ではなく"a"とダイレクトに出力することができる。

最後の行は、${tbts[@]:1}tbtsの1番目以降の要素を全出力する。
これを再代入することによって、強引に先頭のシフトをしている。

# 110xxxxx 10xxxxxx
elif [[ $((0x${tbts[0]}>>5)) -eq 6 ]]
then
	echo -n \\u$( printf "%04x" $(( (0x${tbts[0]}&31)<<6|(0x${tbts[1]}&63) )) )
	tbts=(${tbts[@]:2})

続いて110xxxxx 10xxxxxxのパターン。
先頭3bitが5(0b110)であればよい。

$(( (0x${tbts[0]}&31)<<6|(0x${tbts[1]}&63) ))

この部分は先頭バイト(110xxxxx)のx部分だけ取るために00011111(31)との論理積を取り、左に6bitシフト、さらに2バイト目の後方6bitを取るために00111111(63)との論理積をとって結合(論理和)している。

その後printfでゼロ埋め4桁hex出力("%04x")し、最後の行で配列の先頭シフトをしている。
1110xxxx 10xxxxxx 10xxxxxxパターンも同様の処理である。

# 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
elif [[ $((0x${tbts[0]}>>3)) -eq 30 ]]
then
	echo -n \\U$( printf "%08x" $(( (((0x${tbts[0]}&7)<<6|(0x${tbts[1]}&63))<<6|(0x${tbts[2]}&63))<<6|(0x${tbts[3]}&63) )) )
	tbts=(${tbts[@]:4})

最後のパターンは表からもわかるように5桁以上のコードになってしまうため、"\Uxxxxxxxx"形式の8桁表示をする。

セキュリティ

Implementers of UTF-8 need to consider the security aspects of how they handle illegal UTF-8 sequences.

RFC3629 Section-10

例えば、2F 2E 2E 2Fをデコードすると/../になるのはいいが、2F C0 AE 2E 2Fも同じ結果になっちゃうことがあるし、それを認めると思わぬ脆弱性を生み出すからな!ということらしい。

$ xxd invalid 
00000000: 2fc0 ae2e 2f                             /.../
$ bash decode.sh $(cat invalid)
/\u002e./

たしかに実行してみると\u002eが…
これはエンコードすると"."になってしまい、ディレクトリトラバーサルが実行されそうな匂い…
本格的に作るにはこういう無効バイトをブロックしておく必要があるな。

まとめ

$ bash decode.sh pͪoͣnͬpͣoͥnͭpͣa͡inͥ
p\u036ao\u0363n\u036cp\u0363o\u0365n\u036dp\u0363a\u0361in\u0365

上手にできました。
あとでリポジトリにでもしてみようかな。

UTF-8のデコードをするべく我々はジャングルの奥地へと向かった」への1件のフィードバック

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です