はじめに
セキュリティエンジニアの桑原です。 プログラミング言語には様々なものがあります。 その中でも近年 Go 言語がシンプルなコードであり並行処理を簡単に書けるという特徴から人気が出ています。 しかしながら、マルウェアの作成者も Go 言語に目をつけはじめました。 その結果、WellMess や TellYouThePass といった Go 言語製のマルウェアが出現しています。
こうした Go 言語製の実行ファイルは C 言語等で作成された実行ファイルと異なる点が多く、それを知らずに解析を始めると戸惑うことがあります。 そこで本ブログでは、Go 言語で解説用に作成した無害な実行ファイルの解析を通して、その特徴を紹介します。
以下は今回使用したツールと環境です。
- Windows 10 x64 20H2
- Ghidra 10.1.1
- x64dbg Mar 16 2021, 01:13:24
- Go 1.17.5 windows/amd64
実行ファイルの準備
まず始めに解析で使用する実行ファイルを用意します。 以下は今回解析する実行ファイルのソースコードです。 コードには以下の Go 言語の特徴を解析しやすくするための処理が盛り込まれています。
- 文字列の構造
- 引数の扱い
- 戻り値の扱い
- slice の構造
- 軽量スレッド Go ルーチン
package main import ( "fmt" "time" ) func testFunc(a string) (int, int, int) { fmt.Printf("testFunc: %s\n", a) return len(a), 16, 32 } func manyArg(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, arg14, arg15 int) { fmt.Printf("manyArg: %v %v %v %v %v %v %v %v %v %v %v %v %v %v %v\n", arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, arg14, arg15) } func manyRet(arg1 int) (int, int, int, int, int, int, int, int, int, int, int, int, int, int, int) { fmt.Printf("manyRet: %v\n", arg1) return 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, arg1 } func goroutineFunc(arg1, arg2, arg3, arg4 int) { fmt.Printf("goroutineFunc: %v %v %v %v\n", arg1, arg2, arg3, arg4) } func main() { // 文字列の構造を解析するための処理 var str = "hello" n1, n2, n3 := testFunc(str) fmt.Printf("testFunc()=%v\n", n1+n2+n3) // 引数の扱いを解析するための処理 manyArg(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) // 戻り値の扱いを解析するための処理 arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, arg14, arg15 := manyRet(0xff) fmt.Printf("manyRet(0xff)=%v\n", arg1+arg2+arg3+arg4+arg5+arg6+arg7+arg8+arg9+arg10+arg11+arg12+arg13+arg14+arg15) // sliceの構造を解析するための処理 var sli = []int{0x1440, 0x0441} sli = append(sli, 0x7777) // sliceの各要素と要素数、確保されたサイズを出力 fmt.Printf("slice=[%v %v %v] len=%v cap=%v\n", sli[0], sli[1], sli[2], len(sli), cap(sli)) // Goルーチンについて解析するための処理 go goroutineFunc(0x1, 0x10, 0x100, 0x1000) // Goルーチンが途中で終了しないように一定時間待機 time.Sleep(100) }
以下のコマンドにより上記のソースコードをビルドできます。
> go version go version go1.17.5 windows/amd64 > go build -o test.exe test.go
実行結果は以下の様になります。
引数と戻り値を使用した標準出力が各関数内と呼び出し後に行われています。
関数内では:
、呼び出し後は=
の後ろに引数と戻り値を出力しています。
> test.exe testFunc: hello testFunc()=53 manyArg: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 manyRet: 255 manyRet(0xff)=360 slice=[5184 1089 30583] len=3 cap=4 goroutineFunc: 1 16 256 4096
解析
作成された実行ファイルを Ghidra で見てみます。
Symbol Tree の Functions を見るといくつかの名前解決された関数が確認できます。
その中にはmain.testFunc
やmain.manyArg
などのソースコードに記載された関数と同名の関数が存在します。
これらの関数も気になりますがまずはmain.main
関数を確認してみましょう。
以下はmain.main
関数の Ghidra によるデコンパイル結果です。
main.testFunc
などの関数名が解決されている関数の位置関係から、main.main
関数が main 関数と同等の処理を行っていると推測できます。
しかし、main.testFunc
には引数がなく、main.manyArg
には引数が 1 つしかないため引数の解決は単純にはできないようです。
上図のアセンブリには関数main.main
とラベルmain.main
が存在しており、非常に紛らわしいです。
このため、ラベルの方のmain.main
をlabel_unknown
に変更しておきます。
文字列と関数の引数・戻り値
main.testFunc
関数呼び出し直前のアセンブリコードを確認し testFunc 関数の引数であるstring
型の文字列"hello"
を探します。
DAT_004a75de
は"helloinit ..."
という文字列へのポインターですが、"hello"
の後ろにヌル終端文字が存在しません。
また、引数の設定処理を探しても x64 バイナリの呼び出し規約で定義されている rcx や rdx への値の設定は見つかりません。
こうしたアセンブリにおける関数の引数と文字列の表現方法は Go 言語の特徴の 1 つです。
main.testFunc
関数の引数は rax と rbx を経由して渡されます。
さらに引数が増えた場合については後述します。
Go 言語のstring
型は以下の構造になっており、len によって文字列の長さを指定しています。
ptr は文字列へのポインターです。
type string struct { ptr unsafe.Pointer len int }
main.testFunc
関数呼び出し時に第 1 引数 rax には"hello"
から始まる文字列へのポインター、第 2 引数 rbx には"hello"
の長さである 5 が代入されています。
これらの値は Go 言語の string 型のメンバーと一致するため、"hello"
を格納するstring
型がmain.testFunc
関数の引数に与えられていることが分かります。
次にmain.manyArg
関数呼び出しを見てみましょう。
呼び出し処理を見ると引数に指定した 1 から 15 までの値がレジスタやスタックに格納されていることが分かります。
Go 言語では引数はまず決められたレジスタに格納され、それでも足りない場合はスタックを先頭から順に使用します。
上図のアセンブリを見ると、格納される値からどのレジスタやスタックが第何引数に対応するかが判断できます。
引数の渡し方は Go 言語のバージョンやアーキテクチャーによって異なります。
ちなみに、これらの仕様についてはこちらに記載されています。
これらは戻り値についても同様です。
Go 言語では複数の戻り値を返すことが許容されており、これを実現するために引数と同じレジスタやスタックに戻り値が格納されます。
以下の図にてmain.manyRet
の戻り値をアセンブリで確認するとmain.manyArg
と同様のレジスタとスタックが使用されていることが分かります。
slice型
Go 言語においてslice
型は以下の構造になっています。
データは array へ配列状に格納され、格納された配列のサイズは len で示されます。
cap は array に確保されているサイズを示します。
type slice struct { array unsafe.Pointer len int cap int }
では、アセンブリ上でslice
がどのように使用されているか確認してみましょう。
以下のコードでは連続したアドレスlocal_b0[0]
とlocal_b0[8]
に0x1440
と0x441
が書き込まれています。
このため、ここが sli のデータ部分であると分かります。
その後、rbx には sli のデータへのポインター、rcx と rdi に 2、rsi に 3 が指定され、runtime.growslice
が呼び出されています。
ここで Go 言語のソースコードから growslice 関数の定義を確認すると、以下の様になっているのが分かります。 growslice 関数では cap で指定したサイズ以上の要素が確保された old のコピーが作成されます。 例えば、cap に 3 が指定されると確保済みサイズが 3 や 4 の slice が作成されます。
func growslice(et *_type, old slice, cap int) slice {
https://github.com/golang/go/blob/de690c2ff8e323c7ce9e274f986dc6f824b35405/src/runtime/slice.go#L162
第 1 引数の_type
のポインターが rax に対応し、第 2 引数のslice
型が rbx、rcx、rdi に対応しています。
上図のアセンブリにて rbx、rcx、rdi に格納されている値からslice
が上述の構造通りになっていると分かります。
第 3 引数 cap に対応する rsi へ 3 を渡すことで slice
の確保済みサイズが 2 から 3 以上の値に拡張されます。
runtime.growslice
が呼び出されているアセンブリに話を戻します。
runtime.growslice
が呼び出された後、その戻り値であるslice
型に新たな 3 番目の要素0x7777
が格納されます。
interface型
次に標準出力に関する関数を見てみましょう。
ソースコードのfmt.Printf("testFunc()=%v\n", n1+n2+n3)
に対応するアセンブリではfmt.Fprintf
が使用されています。
この関数のソースコードを確認します。
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
https://github.com/golang/go/blob/de690c2ff8e323c7ce9e274f986dc6f824b35405/src/fmt/print.go#L202
fmt.Fprintf
では出力先、フォーマット文字列、可変長引数のinterface{}
が引数に指定されています。
Go 言語のinterface
型はinterface{}
を指定することで様々な型を使用できます。
以下はfmt.Fprintf
呼び出し箇所のアセンブリです。
上記のアセンブリの rcx はフォーマット文字列、rdi は文字列長であり、rsi 以降の引数が可変長のinterface{}
に対応しています。
rsi と r8、r9 を確認すると、ポインターと 1 が指定されています。
r8 と r9 にfmt.Printf
の可変長引数の数と同じ 1 が指定されていることから、可変長引数はslice
と同様の構造で管理されていると分かります。
次に rsi が示すデータを見てみます。 このデータは Ghidra で見るより実際に動作している際のデータを見た方が構造を理解しやすいので x64dbg を使用します。
rsi が示すポインターの先には 2 つのポインターがあります。
interface{}
として与えられたデータの実際の型を判別するために、interface{}
は型情報を示す_type
と実際のデータへのポインターを保持しています。
_type
はデータのサイズと型を識別する情報を保持しています。
上図でデータサイズとデータの型を確認すると 8 と int
になっています。
データ型の値 2 は int
を表しています。
データ部分 8 バイトを確認するとソースコードのfmt.Printf
で出力されるn1+n2+n3
の結果である 53 が格納されています。
Goルーチンとmain関数呼び出し
次に Go 言語の軽量なスレッドである Go ルーチンの呼び出し処理を確認します。
main.main
関数内を探しても goroutineFunc 関数は見つかりません。
引数に注目すると0x1
、0x10
、0x100
、0x1000
が存在することから、以下が goroutineFunc 関数を Go ルーチンとしてを呼び出す処理だと推測できます。
引数だと思われる0x1
、0x10
、0x100
、0x1000
は rax が示すポインターへ順番に格納されますが、先頭のアドレスにはlabel_unknown
が格納されています。
下図に示したlabel_unknown
の内容を確認するとmain.goroutineFunc
関数が呼び出されていると分かります。
上図のlabel_unknown
と引数が格納されたデータへのポインターは rbx に保持され、runtime.newproc
関数が呼び出されています。
実はこのruntime.newproc
関数が Go ルーチンを起動する関数であり、指定された引数を使用して関数を実行します。
ここまでmain.main
関数を中心にアセンブリを見てきましたが、実行ファイルによっては関数名が解決できない場合もあります。
main.main
は Go ルーチンとして実行されるため、関数呼び出しを単純に辿ると解析に時間がかかってしまいます。
このため、エントリーポイントから処理を辿る際は、名前解決された実行ファイルにてmain.main
実行までの流れを覚えておくと便利です。
エントリーポイントからmain.main
の処理の流れを以下で説明します。
エントリーポイントである_rt0_amd64_windows
からruntime.rt0_go.abi0
までは jmp 命令による単純な遷移です。
しかし、runtime.newproc.abi0
とruntime.newproc
は Go ルーチンの呼び出しに使用される関数であるため、それ以降の処理を辿るためには引数を確認する必要があります。
runtime.newproc.abi0
関数呼び出しの直前の命令を見ると push により 0 とruntime.mainPC
のアドレスがスタックに積まれています。
runtime.newproc.abi0
ではスタックにより引数が渡され、引数はそのままruntime.newproc
関数に渡されます。
runtime.mainPC
の先頭にはruntime.main
関数のアドレスがあり、この関数がruntime.newproc
関数により実行されます。
runtime.main
関数内でmain.main
が呼ばれることでソースコードに定義した main 関数がようやく呼ばれます。
ちなみに、Ghidra のデフォルト機能で関数名が解決できない Go 言語の実行ファイルは以下のコマンドでコンパイルできます。
go build -ldflags="-s -w" -o test.exe test.go
おわりに
このように、Go 言語製の実行ファイルの解析には Go 言語ならではの特徴を知ることが必要になります。 Go 言語製のマルウェアが出現している中、こうした知識は重要になります。
今回ご紹介した内容にはアーキテクチャーなどによって異なる点があります。
Go 言語ではコンパイラが動作する環境とは別のアーキテクチャーや OS 向けに実行ファイルを生成するクロスコンパイルが可能です。
例えば以下のコマンドで arm64 で動作する Linux 向けにコンパイルが可能です。
OS は環境変数 GOOS
、アーキテクチャーは環境変数 GOARCH
で指定しています。
> set GOOS=linux > set GOARCH=arm64 > go build test.go
また、使用可能なGOOS
とGOARCH
の組み合わせは以下のコマンドで確認できます。
> go tool dist list
複数環境に向けてビルドしやすいことはマルウェア作成者が Go 言語を好む理由の 1 つです。 マルウェア作成者は 1 つのコードを書くだけで、Windows だけでなく Linux や macOS に対しても攻撃できるようになります。 実際にマルウェアの解析をすると様々なアーキテクチャーのバイナリが登場します。
マルウェアなどの解析技術を磨くためにも、今回紹介した内容を参考に他環境で動作する Go 言語製実行ファイルのアセンブリがどのようになっているか調査するのも面白いと思います。
エンジニア募集
弊社ではリバースエンジニアリングの知見を活かし、マルウェアの解析などセキュリティの研究開発を行っています。 採用に関してはこちらを御覧ください。