概要
Goでビルドしたバイナリは色々な情報を含んでいます。例えばビルドに使用したGoのバージョンを取得できます。
$ go version ./test ./test: go1.15.2
そしてついこの間知ったのですが、 -m
オプションを使うことで利用しているmoduleの情報も取得可能です。
$ go version -m /usr/local/bin/terraform /usr/local/bin/terraform: go1.14.9 path github.com/hashicorp/terraform mod github.com/hashicorp/terraform (devel) dep cloud.google.com/go v0.45.1 dep github.com/Azure/azure-sdk-for-go v45.0.0+incompatible dep github.com/Azure/go-autorest/autorest v0.11.3 dep github.com/Azure/go-autorest/autorest/adal v0.9.0 dep github.com/Azure/go-autorest/autorest/azure/cli v0.4.0 dep github.com/Azure/go-autorest/autorest/date v0.3.0 dep github.com/Azure/go-autorest/autorest/to v0.4.0 dep github.com/Azure/go-autorest/autorest/validation v0.3.0
Terraformは cloud.google.com/go
のv0.45.1に依存していることが分かります。その他にも依存しているモジュール名とバージョンが取得できています。Terraformのgo.sumを見ると確かに一致していそうです。
知らなかったといいつつバイナリにmoduleの情報が含まれていることは以前から知っていて、どうやって取り出すのかなとずっと頭の片隅にはあったのですが、 go version
で取り出せるということを今更ながら知りました。自分の開発しているOSSにとってこれは非常にありがたい情報で、面白い機能が実装できるようになります。これは後の余談で話します。
ということでこのブログではどうやって取り出すのか?という内部の挙動を説明します。いや内部知らなくても取り出せれば十分という人は上の2つのコマンドで終了しているのでこの先は不要です。
内部挙動
go version
の実装がとてもシンプルで読みやすいので、気になる人は読んでみてください。
ELFバイナリの準備
ELFだと理解しやすいと思うので、Linux向けにビルドします。丁度よいサイズのバイナリということで拙作の utern
を使います。
おもむろにビルドします。
$ GOOS=linux GOARCH=amd64 go build .
確かにELFになっています。
$ file utern utern: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=BBs_Ti-Xl6qateQn0t0S/4KAWgjZw9u6WdCn8glDp/DOFaRSFJ6gpI-0q_QngG/8Gn_fV3QzFw8O3fj4dI-, not stripped
.go.buildinfo section
まず readelf
でセクションヘッダを見てみます。すると中に .go.buildinfo
というセクションがあることがわかります。
$ readelf -S ./utern There are 25 section headers, starting at offset 0x1c8: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000401000 00001000 00000000004f76b4 0000000000000000 AX 0 0 32 [ 2] .rodata PROGBITS 00000000008f9000 004f9000 00000000001f3ca0 0000000000000000 A 0 0 32 [ 3] .shstrtab STRTAB 0000000000000000 006ecca0 00000000000001bc 0000000000000000 0 0 1 [ 4] .typelink PROGBITS 0000000000aece60 006ece60 00000000000032d0 0000000000000000 A 0 0 32 [ 5] .itablink PROGBITS 0000000000af0130 006f0130 0000000000000d70 0000000000000000 A 0 0 8 [ 6] .gosymtab PROGBITS 0000000000af0ea0 006f0ea0 0000000000000000 0000000000000000 A 0 0 1 [ 7] .gopclntab PROGBITS 0000000000af0ea0 006f0ea0 000000000024751e 0000000000000000 A 0 0 32 [ 8] .go.buildinfo PROGBITS 0000000000d39000 00939000 0000000000000020 0000000000000000 WA 0 0 16 [ 9] .noptrdata PROGBITS 0000000000d39020 00939020 0000000000039e40 0000000000000000 WA 0 0 32 [10] .data PROGBITS 0000000000d72e60 00972e60 000000000000ed00 0000000000000000 WA 0 0 32 [11] .bss NOBITS 0000000000d81b60 00981b60 00000000000333f0 0000000000000000 WA 0 0 32 [12] .noptrbss NOBITS 0000000000db4f60 009b4f60 0000000000003628 0000000000000000 WA 0 0 32 [13] .zdebug_abbrev PROGBITS 0000000000db9000 00982000 0000000000000119 0000000000000000 0 0 1 [14] .zdebug_line PROGBITS 0000000000db9119 00982119 000000000009a650 0000000000000000 0 0 1 [15] .zdebug_frame PROGBITS 0000000000e53769 00a1c769 0000000000023f48 0000000000000000 0 0 1 [16] .zdebug_pubnames PROGBITS 0000000000e776b1 00a406b1 000000000000422a 0000000000000000 0 0 1 [17] .zdebug_pubtypes PROGBITS 0000000000e7b8db 00a448db 000000000000f06b 0000000000000000 0 0 1 [18] .debug_gdb_s[...] PROGBITS 0000000000e8a946 00a53946 0000000000000040 0000000000000000 0 0 1 [19] .zdebug_info PROGBITS 0000000000e8a986 00a53986 00000000000f25c6 0000000000000000 0 0 1 [20] .zdebug_loc PROGBITS 0000000000f7cf4c 00b45f4c 0000000000097da5 0000000000000000 0 0 1 [21] .zdebug_ranges PROGBITS 0000000001014cf1 00bddcf1 0000000000032712 0000000000000000 0 0 1 [22] .note.go.buildid NOTE 0000000000400f9c 00000f9c 0000000000000064 0000000000000000 A 0 0 4 [23] .symtab SYMTAB 0000000000000000 00c11000 0000000000053d60 0000000000000018 24 672 8 [24] .strtab STRTAB 0000000000000000 00c64d60 000000000008cf90 0000000000000000 0 0 1$
objdump
でこの中身を見てみます。
$ objdump -s -j .go.buildinfo ./utern ./utern: file format elf64-x86-64 Contents of section .go.buildinfo: d39000 ff20476f 20627569 6c64696e 663a0800 . Go buildinf:.. d39010 303cd700 00000000 703cd700 00000000 0<......p<......
まず先頭16バイトについて見ていきます。
最初の14バイトはマジックバイトです。 \xff Go buildinf:
でなければなりません。上の値を見ると確かにそうなっています。
15バイト目はポインタのサイズです。今回は 0x08
なので8バイトです。そして最後がエンディアンで、0の場合はリトルエンディアン、0以外ならビッグエンディアンになります。
Goのバージョン
次に17バイト目以降を見ていきますが、最初の8バイトがビルドに利用したGoバージョンへのポインタになっています。正確にはこの先もさらにポインタなのでポインタのポインタです。
上で見たようにリトルエンディアンなので、アドレスは 0xd73c30
になります。
ということで 0xd73c30
をstart-addressにして16バイトほど取り出してみます。
$ objdump -s --start-address 0x00d73c30 --stop-address 0x00d73c40 ./utern ./utern: file format elf64-x86-64 Contents of section .data: d73c30 f0239e00 00000000 08000000 00000000 .#..............
この先頭8バイトがGoのバージョン情報へのポインタです。後半8バイトはそのバージョンのサイズです。
アドレスが 0x9e23f0
でサイズが8バイトであることが分かります。
ではその値を取り出します。サイズが8バイトなので stop-address
は 0x9e23f8
になります。
$ gobjdump -s --start-address 0x9e23f0 --stop-address 0x9e23f8 ./utern ./utern: file format elf64-x86-64 Contents of section .rodata: 9e23f0 676f312e 31352e32 go1.15.2
ということでGoのバージョンを無事に取り出せました。このバイナリはGo 1.15.2でビルドされたことが分かります。
module情報
先程は.go.buildinfo
の17バイト目から24バイト目までを使ったので、今度は25バイト目から32バイト目を使います。最初の8バイトがポインタになっているので、 0xd73c70
になります。
先程と同様に16バイトほど取り出してみます。
$ gobjdump -s --start-address 0xd73c70 --stop-address 0xd73c80 ./utern ./utern: file format elf64-x86-64 Contents of section .data: d73c70 f713a000 00000000 b9040000 00000000 ................
これもGoのバージョンを取り出したときと同じで、前半8バイトがポインタで後半8バイトがサイズです。つまりアドレスが 0xa013f7
でサイズが 0x04b9
になります。
stop-addressを計算すると 0xa013f7
+ 0x04b9
= 0xa018b0
です。
Goのバージョン同様に 0xa013f7
をstart-address、 0xa018b0
をend-addressに指定して取り出してみます。
$ gobjdump -s --start-address 0xa013f7 --stop-address 0xa018b0 ./utern ./utern: file format elf64-x86-64 Contents of section .rodata: a013f7 30 77af0c92 74080241 e1c107e6 d618e6 0w...t..A....... a01407 70 61746809 67697468 75622e63 6f6d2f path.github.com/ a01417 6b 6e717966 3236332f 75746572 6e0a6d knqyf263/utern.m a01427 6f 64096769 74687562 2e636f6d 2f6b6e od.github.com/kn a01437 71 79663236 332f7574 65726e09 286465 qyf263/utern.(de a01447 76 656c2909 0a646570 09676974 687562 vel)..dep.github a01457 2e 636f6d2f 6177732f 6177732d 73646b .com/aws/aws-sdk a01467 2d 676f0976 312e3235 2e333609 68313a -go.v1.25.36.h1: a01477 34 2b544c2f 59324735 68735231 7a6466 4+TL/Y2G5hsR1zdf a01487 48 6d6a4e47 316f7531 57457173 53576b HmjNG1ou1WEqsSWk a01497 38 76376d31 4761444b 796f3d0a 646570 8v7m1GaDKyo=.dep a014a7 09 67697468 75622e63 6f6d2f62 726961 .github.com/bria a014b7 6e 646f776e 732f7370 696e6e65 720976 ndowns/spinner.v a014c7 31 2e372e30 0968313a 61616e31 684242 1.7.0.h1:aan1hBB a014d7 4f 6f736372 79325458 416b6774 786b4a Ooscry2TXAkgtxkJ a014e7 69 71375365 302b3970 742b5455 576150 iq7Se0+9pt+TUWaP ...
確かにmodule情報が含まれていることが分かります。念の為 go version -m
を見てみます。
$ go version -m ./utern ./utern: go1.15.2 path github.com/knqyf263/utern mod github.com/knqyf263/utern (devel) dep github.com/aws/aws-sdk-go v1.25.36 h1:4+TL/Y2G5hsR1zdfHmjNG1ou1WEqsSWk8v7m1GaDKyo= dep github.com/briandowns/spinner v1.7.0 h1:aan1hBBOoscry2TXAkgtxkJiq7Se0+9pt+TUWaPrB4g= dep github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= dep github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= dep github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= dep github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= dep github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= dep github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= dep github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= dep github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= dep github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= dep golang.org/x/sys v0.0.0-20191115151921-52ab43148777 h1:wejkGHRTr38uaKRqECZlsCsJ1/TGxIyFbH32x5zUdu4=
上に含まれているデータと同じになっているようです。何かシリアライズされていて入っていて、 go version
が整形して出しているのかと思いきや普通に上の表示のまま入っています。つまり改行やタブなどがそのままです。プログラムで使いたい場合は逆に整形する必要があります。
ちなみにgo.modの中でreplaceを使っている場合などは dep
のところが =>
になったりします。
ということで簡単にGoバージョンとmodule情報を取得することが出来ました。 go version
はELFだけでなくPEやMach-Oにも対応しています。
ldflagsについて
バイナリサイズ削減のため、シンボルテーブルを生成しないようにすることがあるかと思います。よく見る以下のようなオプションですね。
-ldflags '-w -s'
ありがたいことにこの場合でも .go.buildinfo
は残りました。
$ GOOS=linux GOARCH=amd64 go build -ldflags '-w -s' . $ go version -m ./utern ./utern: go1.15.2 path github.com/knqyf263/utern mod github.com/knqyf263/utern (devel) dep github.com/aws/aws-sdk-go v1.25.36 h1:4+TL/Y2G5hsR1zdfHmjNG1ou1WEqsSWk8v7m1GaDKyo= dep github.com/briandowns/spinner v1.7.0 h1:aan1hBBOoscry2TXAkgtxkJiq7Se0+9pt+TUWaPrB4g= ...
strip
などしても変わらずでした。 upx
を使うとさすがに消えました。
Goのソースコード
上は readelf
や objdump
などのコマンドラインを使って取り出す方法を説明しましたが、せっかくなのでGoのコードも少しここで追ってみます。簡単なので各自で読んだほうが早いとは思いますが、自分があとで読み返すメモとして残しておきます。
.go.buildinfo
Goのコードを見るとセクション全体を見て .go.buildinfo
のアドレスを取り出しています。
for _, s := range x.f.Sections { if s.Name == ".go.buildinfo" { return s.Addr } }
そして .go.builinfo
には先程何回も見た以下のデータが入っています。
$ objdump -s -j .go.buildinfo ./utern ./utern: file format elf64-x86-64 Contents of section .go.buildinfo: d39000 ff20476f 20627569 6c64696e 663a0800 . Go buildinf:.. d39010 303cd700 00000000 703cd700 00000000 0<......p<......
マジックバイト
この先頭14バイトはマジックバイトになっていてその値は \xff Go buildinf:
でなければなりませんでしたが、Goにそのマジックバイトが定義されています。
var buildInfoMagic = []byte("\xff Go buildinf:")
そして15バイト目はこのあと出てくるポインタのサイズです。今回は 0x08
なので8バイトです。
ptrSize := int(data[14])
4バイトの場合はuint32として取り出してuint64に変換し、8バイトの場合はそのままint64で取り出します。ここで出てくる bo
は後述するエンディアンです。今回のバイナリの場合は8バイトなので bo.Uint64
になります。
var readPtr func([]byte) uint64 if ptrSize == 4 { readPtr = func(b []byte) uint64 { return uint64(bo.Uint32(b)) } } else { readPtr = bo.Uint64 }
16バイト目はビッグエンディアンかリトルエンディアンかを表しています。0の場合はリトルエンディアンになります。ここで先程の bo
に代入しています。
bigEndian := data[15] != 0 var bo binary.ByteOrder if bigEndian { bo = binary.BigEndian } else { bo = binary.LittleEndian }
Goのバージョン情報へのポインタ
そして17バイト目から上の ptrSize
分(今回は8バイト)を readPtr
で取り出し、そこの値を読みに行きます。今回の例では 303cd700 00000000
なので readPtr
した結果は 0xd73c30
になり、そのアドレスの値を読みに行く処理が以下の readString
になります。
vers = readString(x, ptrSize, readPtr, readPtr(data[16:]))
そしてポインタサイズの2倍分を読み込みます。今回は8バイトなので16バイト読み込んでいます。
hdr, err := x.ReadData(addr, uint64(2*ptrSize)) if err != nil || len(hdr) < 2*ptrSize { return "" }
そして前半8バイトが実際のデータへのアドレス、後半8バイトがデータサイズになっています。
dataAddr := readPtr(hdr) dataLen := readPtr(hdr[ptrSize:])
言葉だとわかりにくいと思うので図を再掲しておきます。
前半8バイトがアドレス( 0x9e23f0
)で、後半8バイトがサイズ( 0x08
)になります。
これがそれぞれ dataAddr
と dataLen
に入ります。
あとはそのアドレス( dataAddr
)にサイズ分( dataLen
)アクセスするだけです。
data, err := x.ReadData(dataAddr, dataLen) if err != nil || uint64(len(data)) < dataLen { return "" }
これで以下のように go1.15.2
が得られます。
$ gobjdump -s --start-address 0x009e23f0 --stop-address 0x009e23f8 ./utern ./utern: file format elf64-x86-64 Contents of section .rodata: 9e23f0 676f312e 31352e32 go1.15.2
module情報へのポインタ
先程は前半8バイトのポインタを使ったので、後半8バイトのポインタを使うだけです。実際には ptrSize
をずらすので、ポインタサイズが違えば8バイトではなくなります。
mod = readString(x, ptrSize, readPtr, readPtr(data[16+ptrSize:]))
あとの流れは上と同じで、ポインタのポインタを辿っていくとmoduleデータが手に入ります。同じ readString
が呼ばれているだけなので割愛します。
EXEファイルの処理
ELFだけじゃなくPEやMach-Oにも対応していると前述しましたが、それは openExe()
周りで吸収されています。バイナリの先頭を見て判断しています。あとは debug/elf
の NewFile
を呼んでよしなにやってもらうという感じです。
if bytes.HasPrefix(data, []byte("\x7FELF")) { e, err := elf.NewFile(f) if err != nil { f.Close() return nil, err } return &elfExe{f, e}, nil }
PEやMach-Oの場合は debug/pe
や debug/macho
を呼んでいます。
余談
最近DNSの脆弱性についてブログに書いていて何かそういうセキュリティ関係者と思われている可能性があるのですが、そういった分析は本業と何も関係なくて本業ではTrivyというOSSを開発しています。これは脆弱性スキャンツールで、コンテナイメージなどを渡すと内部の脆弱性をスキャンします。何か未知の新たな脆弱性を見つけるタイプのものではなくて、CVE-IDなどが採番されている既知の脆弱性を見つけるツールになっています。OSパッケージの古いバージョンやプログラミング言語で使われるライブラリの古いバージョンなどに存在する脆弱性を見つけるのが主な役割です。
コンテナイメージ用のツールとして始めたのですが、今ではファイルシステム上のファイルだったりDockerfileに埋め込んで使えたりGitHubリポジトリのスキャンだったりにも対応しています。
Trivyは自分が趣味で開発したOSSで気づけば業務としてやることになっているのですが、Clairというこの界隈における絶対的王者を倒すために作り始めて気付けばGitHubスター数が1000差程度まで来ています。作り始めた時点でClairはスター数が5000以上もあって無理だろうと諦めつつあったのですがここに来て現実味を増してきたので、もしよろしければ応援していただけると嬉しいです(宣伝)。
話がそれましたが、Trivyは現在PythonやRubyなどの言語にも対応しています。どのように検知するかと言うと各言語のパッケージマネージャが生成するロックファイルを使っています。npmでいう package-lock.json
、bundlerでいうところの Gemfile.lock
です。
ですがGoには対応していません。それには2つの理由があって、Goの脆弱性データベースで使えるものがなかったためと、 go.sum
はコンテナイメージ内に残さない可能性が高いためです。関係ないですが go.sum
はロックファイルではないということもユーザの方から教わりました。GoはシングルバイナリにビルドできるのでDockerのマルチステージビルドなどと相性がよく、バイナリだけ置いて動かす事が多いです。スキャンしたかったら go.sum
置いてね、という運用も出来なくはないのですが美しくないですしそもそも良い脆弱性DBもないので後回しにしていました。
そこで改めて公開されているDBを見てみたところ以前はほとんど脆弱性がなかったGitLab Advisory Databaseがかなり充実してきていました。
これなら十分有用だなということで go.sum
を使わない方法を探していたところ、バイナリからmodule情報取り出せるということを1年前にIssueで教えてもらっていたことを思い出しました。
それで色々調べた結果、 go version
が既に対応していることを知り、自分でも実装することが出来ました。以前ブログにも書いたのですが、Issueでユーザの方から教えてもらうことがとても多くていつも助かっています。情報の価値が高い現代において欲しい情報が勝手に集まってくるOSSは良いものだなと思います。
一方でこないだ放置していたOSSを久しぶりにメンテナンスしたら煽られたりしてOSS最悪だわとなったり、感情を揺さぶられがちなので情緒不安定にならないように気をつけましょう。
全くメンテナンスしてないプロジェクトのPR全然気付いてなくてようやくマージしたら、 It's been a year😅😅 とか煽られて放置しておけば良かったってなってるhttps://t.co/ltDflCWtYI
— イスラエルいくべぇ (@knqyf263) 2021年2月1日
ということで脱線して長くなりましたが、結論を言うとGoでビルドしたバイナリをコンテナイメージ内に置いておくだけで脆弱性スキャンが出来るようになります!(まだ実装終わってないから出来るか断定はできないですが多分大丈夫)
コンテナに限らず手元にあるバイナリでも何でも .go.buildinfo
が落とされていなければスキャンできます。個人的にはこういう機能ずっと実装したかったので嬉しい限りです。
このbuildinfoを取り出す処理はGo本体で実装済みなので関数呼ぶだけで済んで便利だな〜と思いきやinternal/の下にあって呼べませんでした。なので仕方なくコピペしつつ移植しました。コンテナイメージ内のファイルに対して使えるように多少の修正が必要だったので結果的には良かったのですが、internal/はやはりコピペを生むので微妙だな派閥になりました。上記のTrivyでもinternal/使ってるのですがexportしてくれ〜と苦情が来たりしていてあまり良い思い出がないです。
さらに余談ですがJavaの対応ももうすぐ入ります。 pom.xml
は依存の依存とかは書いてなくて自前でMavenリポジトリから取得するように実装しました。単にGoでMavenの再実装をしただけになってしまいとても大変でした。それでも全部のプラグインには対応できないので完璧にはトレース出来ませんが、多くのケースでは幸せになるかと思います。他にも pom.xml
ではなくてWARやJARを見てスキャンする機能も作っているので、 war
として固めてコンテナイメージ内に置いただけで pom.xml
はない、みたいなパターンでもスキャンできます。つまりMavenじゃなくてGradleでもOKです。この辺りの詳細はいずれ元気があれば書きます。
まとめ
TrivyがGoのバイナリに対して脆弱性スキャンかけられるようになる!!!(多分)