FFRIエンジニアブログ

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

Go 言語製実行ファイル解析の紹介 ~関数情報~

はじめに

お久しぶりです。セキュリティエンジニアの桑原です。 近年、Go 言語によって作成されたマルウェアが増加しています。 Go 言語の特徴として開発の容易さや任意の環境に向けてビルドを行うクロスコンパイルが可能であるといった点があります。 攻撃者も Go 言語を使用することで、手早く、複数の環境を攻撃できるマルウェアを開発できることが増加要因の 1 つとして挙げられます。 また、以前の記事でも紹介した通り、Go 言語製の実行ファイルは C/C++ 等で作成された実行ファイルとは大きく異なるバイナリ構造をしています。 このため、Go 言語を使用するだけでパターンマッチングによる検出を容易に回避できる可能性があることも増加要因として考えられます。 私たちセキュリティエンジニアは最新の攻撃者の動向に対応するため、Go 言語製マルウェアの解析も必要になってくることでしょう。 そこで、私は先日、JSAC2023 にて「進化する Go 言語製マルウェアとどう戦うか?: 解析能力向上に向けての実践的テクニック」というタイトルで発表いたしました。

jsac.jpcert.or.jp

私の 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に何が格納されるかを突き止めなければなりません。 まるで、LoadLibraryGetProcAddressを使用した難読化のようです。

このような理由から、関数名が解決できるだけで、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構造体とフィールドが非常に似ているため、_funcFuncが進化したものだと考えられます。

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
}

https://github.com/golang/go/blob/4a4127bccc826ebb6079af3252bc6bfeaec187c4/src/runtime/runtime2.go#L869-L885

さらに_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
}

https://github.com/golang/go/blob/4a4127bccc826ebb6079af3252bc6bfeaec187c4/src/runtime/symtab.go#L395-L408

type functab struct {
    entryoff uint32 // relative to runtime.text
    funcoff  uint32
}

https://github.com/golang/go/blob/4a4127bccc826ebb6079af3252bc6bfeaec187c4/src/runtime/symtab.go#L562-L565

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.pad1pcHeader.pad20,0と記載されています。 この 3 つのフィールドは構造体の先頭に位置するため、pcHeader0xf0ffffff0000で始まると推測できます。 以下の図は、Ghidra にてこのバイト列を検索して見つかったデータです。 データがpcHeader構造体であると仮定して、各フィールドがどの位置にあるかが分かりやすいようにしています。 図ではptrSizetextStartフィールドにあたる位置に80x401000が設定されています。 これらのフィールドはポインタのサイズと .text セクションのアドレスを示す情報です。 解析しているのは Windows 向けの 64 ビット実行ファイルであり、 Windows 実行ファイルでは .text セクションはよく 0x401000 に置かれます。 このため、ptrSizetextStartの値は妥当であり、図のデータはpcHeader構造体であると分かります。 これでpcHeaderがサンプルバイナリ内にあることが確認できました。

pcHeaderの検索結果

関数情報の取り出し

サンプルバイナリ内のpcHeaderの位置が分かったので、次は関数情報を取り出します。 まずは、比較的簡単に取り出せる関数アドレスと名前、引数・戻り値のバイト数を確認します。 以下の図は構造体から情報を取り出す流れを示したものです。 図の_funcfuntab配列の 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.minLCamd64では 1 です。 こうして得られた差分を引数に加算し、行番号とアドレスの組を取得します。 以下の図は、左に上図のpctabから取り出された組と各差分、右に行番号データ0xf8 0x01が行番号へ変換される流れを示しています。 右図ではまず、0xf8 0x01というバイト列の0xf8は最上位ビットが 1 なので、0x01までの 2 バイト分が読み込まれます。 その後、0xf8の下位 7 ビットである0x780x01の下位 7 ビットである0x01がリトルエンディアンとして扱われ、0xf8が値として取り出されます。 取り出された値は 1 ビット分右シフトされ0x7c (124)になり、元の値の最下位ビットが 0 であるため正の数として扱われます。 初期値である-10x7c (124)を足した123が行番号になります。

step関数の処理

次はpcfileからファイル名を取り出しましょう。 pcfileからファイル名を取り出す手順はpclnから行数を取り出す手順より多いですが、step 関数でアドレスに対応する値を取り出すところまでは同じです。 異なるのは_func.pclnの代わりに_func.pcfileを使うことぐらいです。 ファイル名文字列はpcHeader.filetabOffsetが示すアドレスに並んでおり、このアドレスから目的のファイル名へのオフセットを取得する必要があります。 下の図は step 関数で取り出した値からファイル名を取得する処理の流れです。 ファイル名へのオフセットは、cutab配列に格納されています。 cutabのアドレスはpcHeaderのアドレスとpcHeader.cuOffsetを足すと得られます。 アセンブラに対応するファイル名へのオフセットを取得するには、cutab配列のインデックスとして_func.cuOffsetに step 関数から取り出した値を足したものを使用します。 cutabuint32の配列であるため、目的のオフセットはcutabの先頭アドレスとインデックスに 4 を掛けた値を足すと得られるアドレスにあります。 得られたオフセットをファイル名列の先頭アドレスに足すと、ファイル名文字列のアドレスが得られます。

ファイル名の取り出し

以上のように Go 言語製の実行ファイルから関数情報を取り出せます。

紹介した手法の適用範囲

Go 言語にはクロスコンパイル機能があり、Windows だけでなく Mac や Linux のマルウェアも作成しやすくなっています。 今回、関数情報の取り出し方を Windows 向けの実行ファイルを使用して説明しましたが、紹介した方法は Mac や Linux 向けの実行ファイルでも活用できます。 しかし、Go 言語のバージョンの違いにより関数情報のデータ構造も少し異なる場合があります。 実際、「関数情報の格納場所調査」で紹介した Go 1.2 のドキュメントと今回紹介した Go 1.19.1 の構造は異なっていました。 このような違いは他のバージョンとの間にも存在します。 例えば、Go 1.19.1 のpcHeader.magic0xfffffff0でしたが、Go 1.17 では0xfffffffaです。 また、Go 1.20 の RC 版のソースコードを見ていると Go 1.20 からは0xfffffff1になっているようです。 このように、バージョンごとに多少の差はありますが、紹介したメタ情報の構造のコンセプトは変わっていません。 上述の構造の説明では Go 言語のソースコードへのリンクも載せていますので、本ブログとソースコードを他のバージョンの実行ファイルを扱う際の参考にできるでしょう。

さいごに

今回は Go 言語製の実行ファイルの解析方法の 1 つとして、解析に役立つ関数情報をメタデータから取り出す方法を紹介しました。 Go 言語製の実行ファイルは解析しづらい点が多くありますが、適切に情報を取り出してあげると格段に解析がしやすくなります。

冒頭でも説明しましたが、Go 言語を使用することでパターンマッチングによる検出を回避できる場合があります。 このようなマルウェアの対策としてマルウェアの動作を監視し検出する手法があり、FFRI yarai ではこの手法を採用し Go 言語製マルウェアの検出に成功しています。 しかし、攻撃者は検出を回避するため攻撃を日々進化させてきます。 進化し続けるサイバー攻撃からお客様を守るため、我々は日々攻撃動向を調査しています。 今回紹介した Go 言語製実行ファイルの解析方法もこうした調査によって得られた知見の 1 つです。

エンジニア募集

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