はじめに
お久しぶりです。セキュリティエンジニアの桑原です。 近年、Go 言語によって作成されたマルウェアが増加しています。 Go 言語の特徴として開発の容易さや任意の環境に向けてビルドを行うクロスコンパイルが可能であるといった点があります。 攻撃者も Go 言語を使用することで、手早く、複数の環境を攻撃できるマルウェアを開発できることが増加要因の 1 つとして挙げられます。 また、以前の記事でも紹介した通り、Go 言語製の実行ファイルは C/C++ 等で作成された実行ファイルとは大きく異なるバイナリ構造をしています。 このため、Go 言語を使用するだけでパターンマッチングによる検出を容易に回避できる可能性があることも増加要因として考えられます。 私たちセキュリティエンジニアは最新の攻撃者の動向に対応するため、Go 言語製マルウェアの解析も必要になってくることでしょう。 そこで、私は先日、JSAC2023 にて「進化する Go 言語製マルウェアとどう戦うか?: 解析能力向上に向けての実践的テクニック」というタイトルで発表いたしました。
私の Go 言語製マルウェアとの出会いは、2 年ほど前に業務で解析したのが初めてでした。 当時から Go 言語製実行ファイルを解析するためのツールはいくつか公開されていたのですが、欲しい情報を取得できないケースが多々あり、自分用に Ghidra プラグインなどの開発を続けていました。 最近では Go 言語製マルウェアの解析に関する既存発表も多くありますが、JSAC2023 ではプラグイン開発をしていた経験を活かし、既存ツールでの解決が困難なケースに着目して発表させていただきました。 本ブログでは、JSAC の発表では時間と構成の都合上触れられなかったアセンブラに対応するファイル名・行番号を交えた関数関連の情報の取り出しについて紹介します。 JSAC2023 の発表内容と被る部分もありますが、本ブログでは解説用の無害なサンプルコードを用いて説明しているため、より多くの方が手元で再現しやすくなっています。
解析における関数情報
Go 言語製の実行ファイルを解析するにはいくつかの壁があります。
その 1 つは関数の量が膨大であることです。
これは、Go 言語製の実行ファイルがシングルバイナリであり、使用するライブラリの関数が 1 つのファイルに含まれるのが原因です。
実行ファイルでは関数名が分からないため、解析がとても大変です。
例えばランサムウェアを解析する際に、ファイルを作成するos.Create
を呼び出す処理を偶然見つけたとします。
私たちは関数名が分からないため、Windows が提供するファイル作成 API であるCreateFile
の呼び出しを関数内部で確認することで、それがos.Create
であると推測できます。
しかし、os.Create
呼び出しからCreateFile
が呼び出されるまでには、追加で 8 回以上の関数呼び出しがあります。
もちろん、関数が並列に並んでいるのではなく、呼び出された関数内で別の関数が呼び出され、さらにその関数内で別の関数が呼び出されるという流れが繰り返されます。
この処理を辿るだけで、多くの時間を消費してしまいます。
さらに、実は単純に関数呼び出しを辿るだけでは終わりません。
Go 言語ではCreateFile
のアドレスがメモリ上に格納されており、そのアドレスがcall rax
等で呼び出されます。
よって、ただ関数呼び出しを辿るだけではcall rax
が見つかるだけです。
call rax
で呼び出される関数を知るためには、データフローを辿りrax
に何が格納されるかを突き止めなければなりません。
まるで、LoadLibrary
とGetProcAddress
を使用した難読化のようです。
このような理由から、関数名が解決できるだけで、Go 言語製の実行ファイルの解析がかなり楽になります。 そこで本ブログでは、Go 言語製実行ファイルの解析に役立つ関数情報の取り出しについて紹介します。
以下は「Go 言語製実行ファイル解析の紹介」にて使用したソースコードをビルドし、Ghidra で表示した結果です。 左図は通常のアセンブラ、右図は今回紹介する方法で取り出した関数情報を追加したアセンブラになります。
右上図には以下の情報が追加されています。
- 関数名
- ソースコードのファイル名
- ソースコードの行数
関数名やファイル名が分かると、アセンブラを詳しく解析することなくその機能を推測できます。 また、アセンブラを解析する場合も、呼び出されている関数名とその周辺のファイル名・行番号を参考にすることで解析しやすくなります。 Go 言語製のマルウェアでもこれらの関数情報が完全に隠ぺいされている例はあまりありません。 このため、関数名やファイル名からマルウェアの機能を予想できることが多いです。
次に、これらの情報の取り出し方を見てみましょう。
実行ファイル内の関数情報解析
本ブログでは「Go 言語製実行ファイル解析の紹介」にて紹介したソースコードを使用します。
ビルドコマンドはgo build -trimpath -ldflags="-w -s" -o sample.exe sample.go
です。
このコマンドで利用しているオプションはリリースビルドで使われることが多く、ローカルパスとシンボル情報の一部が削除できます。
上記コマンドを指定すると、Ghidra のデフォルトの機能を利用してもシンボル情報が見えません。
以後、ビルドされた実行ファイルをサンプルバイナリと呼びます。
使用するツールとそのバージョンは以下です。
- Ghidra 10.1.5
- Go 1.19.1 windows/amd64
関数情報の格納場所調査
「解析における関数情報」で実行ファイルから取り出せる関数情報にはどのようなものがあるか紹介しました。
では、これらの情報はバイナリ内のどこにどのような形式で存在するのでしょうか。
Go 言語のドキュメントを探すと「Go 1.2 Runtime Symbol Information」が見つかります。
このドキュメントは Go 言語に導入されているシンボル情報の説明をしており、読み進めると以下のコードが見つかります。
このコードは関数ごとに作成される構造体であり、name
というフィールド名があることから関数名が存在すると思われます。
struct Func { uintptr entry; // start pc int32 name; // name (offset to C string) int32 args; // size of arguments passed to function int32 frame; // size of function frame, including saved caller PC int32 pcsp; // pcsp table (offset to pcvalue table) int32 pcfile; // pcfile table (offset to pcvalue table) int32 pcln; // pcln table (offset to pcvalue table) int32 nfuncdata; // number of entries in funcdata list int32 npcdata; // number of entries in pcdata list };
https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub
上記の構造体が Go 言語のバージョン 1.19.1 でも存在するのかという疑問を解消するには、Go 言語のソースコードを調べるのが効果的です。
ソースコードを調べると以下の構造体が見つかります。
以下の_func
構造体は上記のFunc
構造体とフィールドが非常に似ているため、_func
はFunc
が進化したものだと考えられます。
type _func struct { entryoff uint32 // start pc, as offset from moduledata.text/pcHeader.textStart nameoff int32 // function name args int32 // in/out args size deferreturn uint32 // offset of start of a deferreturn call instruction from entry, if any. pcsp uint32 pcfile uint32 pcln uint32 npcdata uint32 cuOffset uint32 // runtime.cutab offset of this function's CU funcID funcID // set for certain special runtime functions flag funcFlag _ [1]byte // pad nfuncdata uint8 // must be last, must end on a uint32-aligned boundary }
さらに_func
に関連する構造体を調べると以下の 2 つの構造体が見つかります。
type pcHeader struct { magic uint32 // 0xFFFFFFF0 pad1, pad2 uint8 // 0,0 minLC uint8 // min instruction size ptrSize uint8 // size of a ptr in bytes nfunc int // number of functions in the module nfiles uint // number of entries in the file tab textStart uintptr // base for function entry PC offsets in this module, equal to moduledata.text funcnameOffset uintptr // offset to the funcnametab variable from pcHeader cuOffset uintptr // offset to the cutab variable from pcHeader filetabOffset uintptr // offset to the filetab variable from pcHeader pctabOffset uintptr // offset to the pctab variable from pcHeader pclnOffset uintptr // offset to the pclntab variable from pcHeader }
type functab struct { entryoff uint32 // relative to runtime.text funcoff uint32 }
pcHeader
構造体は関数情報全体を管理しています。
functab
構造体はpcHeader
と_func
を繋げる役割を持っています。
各構造体の関係は以下の図の様になっています。
オフセットはサンプルバイナリを使用した際の値です。
不要なフィールドは表示していません。
pcHeader.pclnOffset
は_func
へのオフセットを保持するfunctab
構造体の配列を指し示します。
pcHeader.pclnOffset
のベースはpcHeader
のアドレスです。
ちなみに、これはpcHeader
内の他のOffset
と名の付くフィールドでも同様です。
functab
配列の長さはpcHeader.nfunc
で指定されます。
_func
のアドレスはfunctab
配列の先頭アドレスとfunctab.funcoff
を足すと得られます。
次に、pcHeader
構造体がサンプルバイナリ内にも存在するか調べてみましょう。
pcHeader.magic
のコメントには0xfffffff0
とあり、pcHeader.pad1
とpcHeader.pad2
は0,0
と記載されています。
この 3 つのフィールドは構造体の先頭に位置するため、pcHeader
は0xf0ffffff0000
で始まると推測できます。
以下の図は、Ghidra にてこのバイト列を検索して見つかったデータです。
データがpcHeader
構造体であると仮定して、各フィールドがどの位置にあるかが分かりやすいようにしています。
図ではptrSize
とtextStart
フィールドにあたる位置に8
と0x401000
が設定されています。
これらのフィールドはポインタのサイズと .text セクションのアドレスを示す情報です。
解析しているのは Windows 向けの 64 ビット実行ファイルであり、 Windows 実行ファイルでは .text セクションはよく 0x401000 に置かれます。
このため、ptrSize
とtextStart
の値は妥当であり、図のデータはpcHeader
構造体であると分かります。
これでpcHeader
がサンプルバイナリ内にあることが確認できました。
関数情報の取り出し
サンプルバイナリ内のpcHeader
の位置が分かったので、次は関数情報を取り出します。
まずは、比較的簡単に取り出せる関数アドレスと名前、引数・戻り値のバイト数を確認します。
以下の図は構造体から情報を取り出す流れを示したものです。
図の_func
はfuntab
配列の 2 番目の要素から取得したデータです。
ここで 2 番目の要素を使用しているのは、今回 1 番目の要素は説明には適さないためです。
テーブル名には構造体名とサンプルバイナリにおけるアドレス、各行にはオフセットとフィールド名、フィールドのデータを載せています。
ただし、使用しないフィールドは記載していません。
関数アドレスはpcHeader.textStart
と_func.entryoff
を加算した値です。
関数名はpcHeader
のアドレスとpcHeader.funcnameOffset
、_func.nameoff
を加算して得られるアドレスにあります。
このアドレスにある関数名文字列はヌル終端文字列になっています。
引数・戻り値のバイト数は_func.args
に格納されています。
ソースコードファイル名や行番号情報は_func.pcfile
や_func.pcln
で指定されています。
ただし、これらの情報を取り出すにはいくつかの複雑な手順が必要です。
以下は行番号の取り出し処理を図で示したものです。
図の右側に取り出されたアドレスと対応する行番号を載せています。
pcHeader
のアドレスとpcHeader.pctabOffset
、_func.pcln
を足して得られるアドレスには行番号とアセンブラアドレスの元になるバイト列があります。
このバイト列から Go 言語のソースコードで実装されているstep 関数を利用して行番号とアドレスが 1 組ずつ取り出せます。
step 関数は行番号以外の情報の取得にも使用されますが、以下の説明では行番号を例に説明します。
step 関数では引数に行番号とアセンブラの組を渡すことで、次の行番号とアセンブラの組が得られます。
初期値は行番号が -1、アドレスが関数の先頭アドレスとなっています。
ある行番号に対応するアセンブラの先頭アドレスはそれより 1 つ前の組のアドレスになります。
step 関数は引数として渡された組から次の組を得るために、以下の処理を 2 回行います。
1 回目は行番号の差分の取り出しであり、2 回目はアドレスになります。
まず、バイト列を最上位ビットが 0 であるバイトまで読み込み、読み込んだバイト列の下位 7 ビットをそれぞれ取り出します。
取り出された 7 ビットごとのデータをリトルエンディアンとして解釈し、値に変換します。
1 回目であれば、取り出された値は 1 ビット分右シフトされ、元の値の最下位ビットが 0 であれば正の数、1 であれば負の数として扱われます。
2 回目であれば、値にpcHeader.minLC
を掛けます。
pcHeader.minLC
はamd64
では 1 です。
こうして得られた差分を引数に加算し、行番号とアドレスの組を取得します。
以下の図は、左に上図のpctab
から取り出された組と各差分、右に行番号データ0xf8 0x01
が行番号へ変換される流れを示しています。
右図ではまず、0xf8 0x01
というバイト列の0xf8
は最上位ビットが 1 なので、0x01
までの 2 バイト分が読み込まれます。
その後、0xf8
の下位 7 ビットである0x78
と0x01
の下位 7 ビットである0x01
がリトルエンディアンとして扱われ、0xf8
が値として取り出されます。
取り出された値は 1 ビット分右シフトされ0x7c (124)
になり、元の値の最下位ビットが 0 であるため正の数として扱われます。
初期値である-1
に0x7c (124)
を足した123
が行番号になります。
次はpcfile
からファイル名を取り出しましょう。
pcfile
からファイル名を取り出す手順はpcln
から行数を取り出す手順より多いですが、step 関数でアドレスに対応する値を取り出すところまでは同じです。
異なるのは_func.pcln
の代わりに_func.pcfile
を使うことぐらいです。
ファイル名文字列はpcHeader.filetabOffset
が示すアドレスに並んでおり、このアドレスから目的のファイル名へのオフセットを取得する必要があります。
下の図は step 関数で取り出した値からファイル名を取得する処理の流れです。
ファイル名へのオフセットは、cutab
配列に格納されています。
cutab
のアドレスはpcHeader
のアドレスとpcHeader.cuOffset
を足すと得られます。
アセンブラに対応するファイル名へのオフセットを取得するには、cutab
配列のインデックスとして_func.cuOffset
に step 関数から取り出した値を足したものを使用します。
cutab
はuint32
の配列であるため、目的のオフセットはcutab
の先頭アドレスとインデックスに 4 を掛けた値を足すと得られるアドレスにあります。
得られたオフセットをファイル名列の先頭アドレスに足すと、ファイル名文字列のアドレスが得られます。
以上のように Go 言語製の実行ファイルから関数情報を取り出せます。
紹介した手法の適用範囲
Go 言語にはクロスコンパイル機能があり、Windows だけでなく Mac や Linux のマルウェアも作成しやすくなっています。
今回、関数情報の取り出し方を Windows 向けの実行ファイルを使用して説明しましたが、紹介した方法は Mac や Linux 向けの実行ファイルでも活用できます。
しかし、Go 言語のバージョンの違いにより関数情報のデータ構造も少し異なる場合があります。
実際、「関数情報の格納場所調査」で紹介した Go 1.2 のドキュメントと今回紹介した Go 1.19.1 の構造は異なっていました。
このような違いは他のバージョンとの間にも存在します。
例えば、Go 1.19.1 のpcHeader.magic
は0xfffffff0
でしたが、Go 1.17 では0xfffffffa
です。
また、Go 1.20 の RC 版のソースコードを見ていると Go 1.20 からは0xfffffff1
になっているようです。
このように、バージョンごとに多少の差はありますが、紹介したメタ情報の構造のコンセプトは変わっていません。
上述の構造の説明では Go 言語のソースコードへのリンクも載せていますので、本ブログとソースコードを他のバージョンの実行ファイルを扱う際の参考にできるでしょう。
さいごに
今回は Go 言語製の実行ファイルの解析方法の 1 つとして、解析に役立つ関数情報をメタデータから取り出す方法を紹介しました。 Go 言語製の実行ファイルは解析しづらい点が多くありますが、適切に情報を取り出してあげると格段に解析がしやすくなります。
冒頭でも説明しましたが、Go 言語を使用することでパターンマッチングによる検出を回避できる場合があります。 このようなマルウェアの対策としてマルウェアの動作を監視し検出する手法があり、FFRI yarai ではこの手法を採用し Go 言語製マルウェアの検出に成功しています。 しかし、攻撃者は検出を回避するため攻撃を日々進化させてきます。 進化し続けるサイバー攻撃からお客様を守るため、我々は日々攻撃動向を調査しています。 今回紹介した Go 言語製実行ファイルの解析方法もこうした調査によって得られた知見の 1 つです。
エンジニア募集
FFRIセキュリティではリバースエンジニアリングの知見を活かし、マルウェアの解析などセキュリティの研究開発を行っています。 採用に関してはこちらをご覧ください。