FFRIエンジニアブログ

株式会社FFRIセキュリティのエンジニアが執筆する技術者向けブログです

Go 言語製実行ファイル解析の紹介

はじめに

セキュリティエンジニアの桑原です。 プログラミング言語には様々なものがあります。 その中でも近年 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.testFuncmain.manyArgなどのソースコードに記載された関数と同名の関数が存在します。 これらの関数も気になりますがまずはmain.main関数を確認してみましょう。 以下はmain.main関数の Ghidra によるデコンパイル結果です。

main.main 関数のアセンブリ

main.testFuncなどの関数名が解決されている関数の位置関係から、main.main関数が main 関数と同等の処理を行っていると推測できます。 しかし、main.testFuncには引数がなく、main.manyArgには引数が 1 つしかないため引数の解決は単純にはできないようです。

上図のアセンブリには関数main.mainとラベルmain.mainが存在しており、非常に紛らわしいです。 このため、ラベルの方のmain.mainlabel_unknownに変更しておきます。

ラベル main.main の名前変更

文字列と関数の引数・戻り値

main.testFunc関数呼び出し直前のアセンブリコードを確認し testFunc 関数の引数であるstring型の文字列"hello"を探します。

main.testFunc 関数呼び出しアセンブリ

DAT_004a75de のデータ

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関数呼び出しを見てみましょう。

main.manyArg 関数呼び出しアセンブリ

呼び出し処理を見ると引数に指定した 1 から 15 までの値がレジスタやスタックに格納されていることが分かります。 Go 言語では引数はまず決められたレジスタに格納され、それでも足りない場合はスタックを先頭から順に使用します。 上図のアセンブリを見ると、格納される値からどのレジスタやスタックが第何引数に対応するかが判断できます。 引数の渡し方は Go 言語のバージョンやアーキテクチャーによって異なります。 ちなみに、これらの仕様についてはこちらに記載されています。 これらは戻り値についても同様です。 Go 言語では複数の戻り値を返すことが許容されており、これを実現するために引数と同じレジスタやスタックに戻り値が格納されます。 以下の図にてmain.manyRetの戻り値をアセンブリで確認するとmain.manyArgと同様のレジスタとスタックが使用されていることが分かります。

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]0x14400x441が書き込まれています。 このため、ここが sli のデータ部分であると分かります。 その後、rbx には sli のデータへのポインター、rcx と rdi に 2、rsi に 3 が指定され、runtime.growsliceが呼び出されています。

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呼び出し箇所のアセンブリです。

fmt.Fprintf 関数呼び出しアセンブリ

上記のアセンブリの rcx はフォーマット文字列、rdi は文字列長であり、rsi 以降の引数が可変長のinterface{}に対応しています。

rsi と r8、r9 を確認すると、ポインターと 1 が指定されています。 r8 と r9 にfmt.Printfの可変長引数の数と同じ 1 が指定されていることから、可変長引数はsliceと同様の構造で管理されていると分かります。

次に rsi が示すデータを見てみます。 このデータは Ghidra で見るより実際に動作している際のデータを見た方が構造を理解しやすいので x64dbg を使用します。

interface{} の構造

rsi が示すポインターの先には 2 つのポインターがあります。 interface{} として与えられたデータの実際の型を判別するために、interface{}は型情報を示す_typeと実際のデータへのポインターを保持しています。 _typeはデータのサイズと型を識別する情報を保持しています。 上図でデータサイズとデータの型を確認すると 8 と int になっています。 データ型の値 2 は int を表しています。 データ部分 8 バイトを確認するとソースコードのfmt.Printfで出力されるn1+n2+n3の結果である 53 が格納されています。

Goルーチンとmain関数呼び出し

次に Go 言語の軽量なスレッドである Go ルーチンの呼び出し処理を確認します。 main.main関数内を探しても goroutineFunc 関数は見つかりません。 引数に注目すると0x10x100x1000x1000が存在することから、以下が goroutineFunc 関数を Go ルーチンとしてを呼び出す処理だと推測できます。

runtime.newproc 関数呼び出しアセンブリ

引数だと思われる0x10x100x1000x1000は rax が示すポインターへ順番に格納されますが、先頭のアドレスにはlabel_unknownが格納されています。 下図に示したlabel_unknownの内容を確認するとmain.goroutineFunc関数が呼び出されていると分かります。 上図のlabel_unknownと引数が格納されたデータへのポインターは rbx に保持され、runtime.newproc関数が呼び出されています。 実はこのruntime.newproc関数が Go ルーチンを起動する関数であり、指定された引数を使用して関数を実行します。

label_unknown のアセンブリ

ここまでmain.main関数を中心にアセンブリを見てきましたが、実行ファイルによっては関数名が解決できない場合もあります。 main.mainは Go ルーチンとして実行されるため、関数呼び出しを単純に辿ると解析に時間がかかってしまいます。 このため、エントリーポイントから処理を辿る際は、名前解決された実行ファイルにてmain.main実行までの流れを覚えておくと便利です。 エントリーポイントからmain.mainの処理の流れを以下で説明します。

main 関数の呼び出しフロー

エントリーポイントである_rt0_amd64_windowsからruntime.rt0_go.abi0までは jmp 命令による単純な遷移です。 しかし、runtime.newproc.abi0runtime.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

また、使用可能なGOOSGOARCHの組み合わせは以下のコマンドで確認できます。

> go tool dist list

複数環境に向けてビルドしやすいことはマルウェア作成者が Go 言語を好む理由の 1 つです。 マルウェア作成者は 1 つのコードを書くだけで、Windows だけでなく Linux や macOS に対しても攻撃できるようになります。 実際にマルウェアの解析をすると様々なアーキテクチャーのバイナリが登場します。

マルウェアなどの解析技術を磨くためにも、今回紹介した内容を参考に他環境で動作する Go 言語製実行ファイルのアセンブリがどのようになっているか調査するのも面白いと思います。

エンジニア募集

弊社ではリバースエンジニアリングの知見を活かし、マルウェアの解析などセキュリティの研究開発を行っています。 採用に関してはこちらを御覧ください。