Ready, Set, Go — Golang の内部構造とシンボルの回復

Stripped Go binary vs. symbols recovered by GoReSym news

Golang (Go) は、2009 年に Google によって導入されたコンパイル済み言語です。それ以来、言語、ランタイム、およびツールは大幅に進化しています。近年、使いやすいクロス コンパイル、自己完結型の実行可能ファイル、優れたツールなどの Go 機能により、マルウェア作成者はクロスプラットフォーム マルウェアを設計するための強力な新しい言語を利用できるようになりました。リバース エンジニアにとって残念なことに、マルウェア作成者コードを Go ランタイム コードから分離するツールは遅れをとっています。

本日、Mandiant はGoReSymという名前のツールをリリースして、Go シンボル情報やその他の埋め込みメタデータを解析します。このブログ投稿では、関連する構造の内部構造と、各言語バージョンでの進化について説明します。また、圧縮、難読化、削除されたバイナリを分析する際に直面する課題についても取り上げます。

設計上の決定

Go は、完全に自己完結型のバイナリを生成するという点で、他の言語とは少し異なります。コンパイルされた Go バイナリを実行するシステムでは、ランタイムや追加の依存関係をインストールする必要はありません。これは、バイナリが正しく実行される前にユーザーがランタイムをインストールする必要がある Java や .NET などの言語とは対照的です。 Go のアプローチでは、コンパイラはさまざまな言語機能 (ガベージ コレクション、スタック トレース、型リフレクションなど) のランタイム コードをコンパイル済みの各プログラムに埋め込みます。これが、Go バイナリが C などの言語で記述された同等のプログラムよりも大きい主な理由です。コンパイラは、ランタイム コードに加えて、ソース コードとそのバイナリ レイアウトに関するメタデータも埋め込み、言語機能であるランタイムをサポートします。およびデバッグ ツール。

この埋め込み情報の一部、つまりpclntabmoduledata 、およびbuildinfo 構造体は完全に文書化されています。 Go の進化に伴い、これらの構造のそれぞれに大きな変化が見られます。この進化は、一般的な難読化ツールまたはパッキング トリックと組み合わされて、型の回復を予想以上に難しくする可能性があります。常に変化するランタイム構造を効果的に処理するために、 GoReSymは Go ランタイム ソース コードに基づいて、すべてのランタイム バージョンを透過的に処理します。これにより、新しい Go バージョンのサポートが簡単になります。 GoReSymはランタイムと同じパーサーを使用するため、エッジ ケースでもより自信を持って使用できます。

復元された記号と言語機能の照合

デバッグ シンボルのない Go バイナリ (ストリップされたバイナリとも呼ばれます) は、リバース エンジニアに特有の課題をもたらします。シンボルがなければ、バイナリの分析は非常に複雑で時間がかかる可能性があります。シンボルが復元されると、リバース エンジニアは逆アセンブルされたコードを元のソースにマップし直すことができます。図 1 は、2 つのサンプルの逆アセンブリ使用しシンボルを回復することの重要性を示しています。

Stripped Go binary vs. symbols recovered by GoReSym
図 1: 削除された Go バイナリと GoReSym によって復元されたシンボル

GoReSymこの情報を抽出する方法を調べる前に復元されたシンボルを使用して、チャネル、Go ルーチン、遅延ルーチン、関数の戻り値などのコアの Go の概念を説明します。以下のセクションの例は、 GoReSymによって復元された関数名を使用して注釈が付けられた Go バイナリを示しています。表 1 は、これらの概念が、各セクションで説明されているランタイム関数にどのように直接対応するかをまとめたものです。

表 1: Go キーワードとランタイム関数のマッピング

概念

キーワード

ランタイム関数名

ゴルーチン

行く

ランタイム.newproc

チャネル

[受信チャンネル]
[チャンネルを送信] <-

runtime.makechan runtime.chanrecv runtime.sendchan

据え置きルーチン

延期する

runtime.deferproc

runtime.deferprocStack

runtime.deferreturn

ルーティンとチャンネルに行く

Go ルーチンを実装するランタイム関数
図 2: Go ルーチンを実装するランタイム関数

goキーワードは、アプリケーションの残りの部分と連携して実行をインターレースする新しい実行スレッドを開始します。これはオペレーティング システムのスレッドではありません。代わりに、複数の Go ルーチンが 1 つのスレッドで順番に実行されます。 Go ルーチン間の通信は、メッセージ パッシング方式で「チャネル」を介して行われます。チャネルが割り当てられると、インターフェイス タイプがランタイム ルーチンruntime.makechanに渡され、チャネルを流れるデータのタイプが定義されます。 <-演算子を使用して、チャネルを介してデータを送受信できます。方向に基づいて、ルーチンruntime.chansendまたはruntime.chanrecvが逆アセンブリに存在します。チャネル ロジックは、多くの場合、関数ポインターをランタイム ルーチンruntime.newprocに渡すことによって実行を開始する Go ルーチン コードに隣接しています。

クリーンアップの延期

C++ デストラクタまたはC# のfinallyブロックに精通している場合、Go のdeferキーワードは似ています。これにより、Go プログラムは、関数の終了時に実行するルーチンをキューに入れることができます。これは通常、ハンドルを閉じたり、リソースを解放したりするために使用されます。ランタイムは、スコープの終了時に後入れ先出し (LIFO) の順序で実行する関数のスタックを維持し、このスタックへの各遅延プッシュを行います。ルーチンをスタックに追加するには、 runtime.deferprocまたはそのバリアントが呼び出されます。スタックでルーチンを実行するために、 Goコンパイラは関数が終了する前runtime.deferreturnを呼び出します。図 3 のソース コードと逆アセンブリは、この概念を示しています。

defer を実装するランタイム関数
図 3: defer を実装するランタイム関数

エラーのある戻り値

ほとんどの Go 関数は、値とオプションのエラーの両方を返します。 C とは異なり、ほとんどの Go 関数は 2 つの値を返します。 Go 1.17 より前では、これらの値はスタックで渡されていました。最近のバージョンでは、Go はレジスターベースの ABI を導入したため、多くの場合、両方の値がレジスターに格納されます。

Go のスタックとレジスタ ABI の戻り値
図 4: Go の戻り値におけるスタック ABI とレジスタ ABI

単一の値とエラーを返すことは一般的なイディオムですが、Go は戻り値の数に制限を設けていません。数十の値を返す関数を見ることができます。

シンボルの回復が重要な理由が明らかになったので、 GoReSymが解析してシンボル情報を抽出する重要な Go 構造をいくつか調べてみましょう。pclntab構造から始めます。

PCLNTAB

pclntab構造体は、「Program Counter Line Table」の略です。このテーブルの設計はこのページに記載されていますが、最近の Go バージョンでは進化しています。このテーブルは、関数名とファイル名を含むスタック トレースを生成するために、仮想メモリ アドレスを最も近いシンボル名にマップするために使用されます。元の仕様では、この情報はガベージ コレクションなどの言語機能に使用できると記載されていますが、最新のランタイム バージョンではそうではないようです。関数名、関数の開始アドレスと終了アドレス、ファイル名などを保存するため、シンボルの回復の目的でpclntabは重要です。

pclntabの検索方法は、ファイル形式によって異なります。 ELF および Mach-O ファイルの場合、 pclntabはバイナリ内の名前付きセクションにあります。 ELF ファイルには、 pclntabを格納する.gopclntabという名前のセクションが含まれていますが、Mach-O ファイルには__gopclntabという名前のセクションが使用されます。 PE ファイルでpclntabを見つけるのはより複雑で、Go ソース コードで.symtabとして参照されているシンボル テーブルを特定することから始めます。

PE ファイルの場合、 .symtabシンボル テーブルはFileHeader.PointerToSymbolTableフィールドによってポイントされます。このテーブル内のruntime.pclntabという名前のシンボルには、 pclntabのアドレスが含まれています。 ELF および Mach-O ファイルには、 runtime.pclntabシンボルを含む.symtabシンボル テーブルもありますが、 pclntabを見つけるためにそれに依存しません。 ELF ファイルで.symtabを見つけるには、タイプSH_SYMTAB.symtabという名前のセクションを探します。 Mach-O ファイルでは、 .symtabLC_SYMTABロード コマンドによって参照されます。以下は、 .symtabに存在する関連シンボルのリストです。

  • ランタイム.pclntab
  • ランタイム.epclntab
  • ランタイム.symtab
  • ランタイム.esymtab
  • pclntab
  • epclntab
  • symtab
  • esymtab

ランタイムプレフィックスのないシンボルは、ランタイムプレフィックスの代わりに使用されるレガシー シンボルです。これらは今後参照されませんが、ランタイムのプレフィックス付きシンボルが検索されて存在しない場合は、通常、フォールバックとして従来のシンボルが試行されることに注意してください。 e接頭辞が付いた記号 ( eclntab など) は、対応するテーブルの終わりを示します。

runtime.symtabシンボルは、Go 1.3 の時点でシンボルで埋められなくなった 2 番目の Go 固有のシンボル テーブルを指します。 ELF および Mach-O ファイルでは、 runtime.symtabシンボルは、このレガシー テーブルを格納する名前付きセクション (それぞれ.gosymtabおよび__gosymtab ) を指します。シンボルで満たされていないにもかかわらず、多くのツールは、シンボルと、このシンボルが指すセクションが存在することを期待しています。 Go ランタイムは、変更を加えないと、このレガシー シンボル テーブルのないバイナリの解析を拒否します。

図 5 のグラフィカルな概要は、 pclntab .symtab 、およびこのレガシー シンボル テーブルがどのように関連しているかを示しています。

Go バイナリのシンボル テーブルのレイアウト
図 5: Go バイナリのシンボル テーブルのレイアウト

Go バイナリが削除されると、 .symtabシンボル テーブルがゼロになるか、存在しなくなります。これにより、 runtime.pclntabなどのシンボルを検索する機能が削除されます。ただし、これらのシンボルが指すデータ ( pclntab自体など) は、バイナリにまだ存在します。 ELF および Mach-O ファイルの場合、名前付きセクション ( .gopclntab など) も引き続き存在し、 pclntabを見つけるために使用できます。したがって、ストリップされたバイナリとストリップされていないバイナリの両方のpclntabの手動の場所は、次の 3 つの方法のいずれかで実行できます。

  1. .symtabシンボルを見つけて、 runtime.pclntabを解決します。
  2. ELF および Mach-O ファイルの場合は、 .gopclntabまたは__gopclntabセクションを見つけます。
  3. バイナリをスキャンして、 pclntabを手動で見つけます。

最後のオプションは、テーブルを見つけるための究極のフォールバック メカニズムです。削除された PE ファイルには常にバイト スキャンが必要であり、場合によってはセクション名が変更された ELF および Mach-O バイナリにも必要です。バイト スキャン方式では、図 6 に示すpclntabヘッダー構造を検索します。

図 6: pclntab ヘッダー構造

type pcHeader struct { // header of pclntab

magic uint32

pad1, pad2 uint8 // 0,0

}

pclntabの先頭は、常にバージョン固有のマジック ナンバーです。この数は、ランタイムの残りの部分とは異なるバージョニング ケイデンスで変更される、各レイアウト形式のテーブル解析動作を決定します。有効なマジック ナンバーは、このGitHub ページにあります。その結果、これらのマジック ナンバーの線形バイト スキャンを実行してpclntabを特定できます。

UPX などの一部のパッカーは、セクション名とサイズを変更します。パックされたバイナリをスキャンするときは、スキャンを実行する前にすべてのセクション データをマージするように注意する必要があります。一部のパックされたバイナリでは、 pclntabが 2 つのセクションにまたがっています。各セクションを個別にスキャンすると、 pclntabの一部しか検出されない場合があります。

GoReSym には、 pclntabを見つけるために Go ランタイム コードに次の変更が含まれています。

  • runtime.symtabシンボルが欠落している場合に続行するファイル形式パーサーの変更。 Go 1.3 以降、このシンボルとそれが指すレガシー テーブルは使用されませんが、ランタイム コードはそれが常に存在することを検証しますが、これは不要です。
  • シンボルが見つかったら、それらがもっともらしい正しいデータを指していることを検証します
  • 標準のシンボルまたはセクション ベースの場所に失敗した場合は、バイト スキャンを実行してpclntabを見つけます。
  • 仮想アドレスなど、 pclntabに関する追加情報を返します。

重要な関数メタデータを保存するだけでなく、 pclntabを使用して、 moduledataという名前の 2 番目のメタデータ構造を見つけることもできます。

モジュールデータ

moduledata構造は、ファイルのレイアウトに関する情報と、ガベージ コレクションやリフレクションなどのコア機能をサポートするために使用されるランタイム情報を格納する内部ランタイム テーブルです pclntabとは異なり、この構造体はシンボルを介して見つけることができません。そのレイアウトも頻繁に変更されます。

構造を見つけるには、まずそれがどのように定義されているかを調べる必要があります。 Go ランタイム バージョンに依存する 2 つの定義があります。最新バージョンでは、 pclntab、図 7 に示すpcHeader構造のオフセットを介して検出されます。

図 7: moduledata の構造の 2 つのバリアント

type moduledata struct {

pcHeader *pcHeader

}

type moduledata struct {

pclntable []byte

}

Go の古いバージョンでは、 pclntabはオフセットによって参照されるのではなく、バイト配列または「スライス」として直接埋め込まれていました。ただし、Go スライスの形式を調べると、両方のメモリ内レイアウトは似ています (図 8)。

図 8: Go スライス構造

type GoSlice struct {

data *byte;

len int;

capacity int;

}

スライスはそのデータ ポインターを最初のメンバーとして保持するため、 moduledataの両方のバージョンは、図 9 のように定義されているかのように開始されます。

図 9: 共通モジュールのデータ メモリ レイアウト

type moduledata struct {

pclntable *byte

...starts to differ...

}

したがって、 moduledataテーブルを見つけるには、 pclntabの仮想アドレスに対して線形バイト スキャンを実行できます(つまり、このアドレスへのポインターを最初のメンバーとして保持する構造体を検索します)。あるいは、関数runtime.moduledataverifyを逆アセンブルしてmoduledataを見つけることもできます。この関数は、 moduledataへのポインターを保持し、それをウォークします。

pclntabとは異なり、ランタイムには、すべての Go バージョンで機能する汎用モジュール データパーサーが含まれていません。これを克服するために、 GoReSymには、サポートされている各ランタイム バージョンの解析ロジックが含まれています。モジュールのデータレイアウトは頻繁に変更されますが、文字列などのフィールド エンコーディングはより安定しています。その結果、一般的なエンコーディングを処理するユーティリティ ルーチンを簡単に作成できます。これにより、いくつかのユーティリティ ルーチンと構造レイアウトを変更するだけで済むため、新しいランタイム バージョンへの移行が容易になります。

moduledata配列内には、 typelinksという名前のリストがあります。 Go プログラムで定義された型の型情報を格納し、リフレクションとガベージ コレクションに使用されます。このリストに格納されている構造体はrtypeという名前で、ランタイム バージョンによって異なります。ただし、各形式で型の名前を抽出できます。これは、型が渡されるチャネルなどの状況で役立ちます。型名がないと、チャネルを介して渡されるデータの型を知ることができません。

以下は、 runtime_makechan関数がインターフェイスへのポインターを取るタイプ リカバリのの例です。

goresym0

タイプの回復後、インターフェイスに名前を付けることができます。

goresym00

これは、チャネルにブール値が渡されたことを示します。この呼び出しの Go ソース コードを調べると、次のタイプが確認されます。

goresym000

rtypeは、それが表す型に応じて追加のフィールドを持つことができます。チャネルの場合、拡張構造は次のようになります。ここでは、基本rtypeが最初のメンバーとして埋め込まれています (図 10)。

図 10: 拡張チャネル rtype 構造

type chantype struct {

typ _type

elem *_type

dir uintptr

}

この拡張型構造内のdirフィールドにより、チャネルの方向 (つまり、送信、受信、またはその両方) を認識することができます。 GoReSymは、すべての拡張型構造を処理し、追加の関連情報を抽出します。 interfacestructなどの型の場合、この拡張情報には、メソッド、フィールド、およびそれらのオフセットがリストされ、内部構造とユーザー定義構造の両方の再構築が容易になります。

ビルド情報

Go 1.18 以降、追加のメタデータがbuildinfoという名前のテーブルで提供されます。このテーブルはデフォルトで生成されますが、コンパイラ フラグを使用して簡単に省略できます。存在する場合、テーブルは次を提供できます: コンパイラとリンカーのフラグ、環境変数GOOSGOARCHの値、 git情報、およびメイン パッケージと依存パッケージの両方のパッケージ名に関する情報。以前の Go バージョンでは、ランタイム バージョンなどのデータを見つけるには、「 go1.<version> 」のような文字列フラグメントを線形スキャンする必要がありましたこのスキャンが必要だったのは、グローバル値runtime.buildVersionがどのシンボルからも参照されておらず、その場所を特定することが難しいためです。

buildinfo構造体には、ELF および Mach-O ファイルに名前付きセクションがありますが、ランタイムはロケーション プロセス中にこの情報を無視します。この情報を識別するために、ランタイムはファイルの最初の 64KB を読み取り、マジック ストリング「 xff Go buildinf: 」の線形スキャンを実行し、直後にデータを解析します。この情報が存在する場合、 GoReSymはそれを使用し、 GOOS値とGOVERSION値が欠落している場合にのみバイト スキャン手法に依存します。

GoReSym の使用法

GoReSymは、コマンド ラインで実行したり、より大きなバッチ処理スクリプトに組み込んだりできるスタンドアロン アプリケーションです。 Go のシンボル以外では、追加のバイナリ解析は実行されません。その結果、データ抽出は通常、最も複雑なバイナリでも 1 ~ 5 秒で完了します。

デフォルトでは、 GoReSymは抽出されたすべての情報ではなく、簡潔な出力を出力します。 GoReSymの動作を変更するために、さまざまなフラグを使用できます -tフラグは、型とインターフェイスのリストを発行するように GoReSym に指示ます。これは、型を受け入れるチャネルやその他のルーチンを逆にするときに役立ちます。場合によっては、逆アセンブリに存在するすべての型がtypelinks配列にリストされているわけではありません。チャネル オブジェクトは、この良い例です。図 11 は、 runtime_newobject呼び出しの前に参照されていない型がraxレジスタに読み込まれる様子を示しています。

参照されていない型 0x48D2A0
図 11: 参照されていない型 0x48D2A0

これらのインスタンスでは、図 12 に示すように -mフラグと型の仮想アドレスをGoReSymに渡すことができます。- humanフラグは、JSON ではなくフラットな出力を生成するために使用されていることに注意してください。

図 12: 手動の rtype 構造抽出

./GoReSym -m 0x48D2A0 -human ../gotests/example

 

-TYPE STRUCTURES-

VA: 0x48d2a0

type struct {

.F uintptr

.autotmp_11 int

.autotmp_13 chan int Direction: (Both)

}

提供されたアドレスの型はGoReSymによって解析され、出力されるため、チャネルの割り当てに関連していることがわかります。その他の便利なフラグには、バイナリに存在するファイル パスを出力する-pフラグや、デフォルト パッケージの一部として分類された情報を出力する-dフラグがあります。

最後に、一部の難読化ツールは Go ランタイムのバージョン情報を削除します。型はバージョン固有であるため、これによりGoReSymが型を解析できなくなります。これを克服するために、ランタイム バージョンを提案する-vフラグを指定できます。 GoReSymは、提案されたバージョンの構造を使用して型を解析しようとします。試行錯誤を使用して、正しいバージョンを推測できます。また、 pclntabのバージョンは、どこから始めればよいかのヒントとして使用できます。ただし、 pclntabのバージョンは実行時のバージョンとは異なることを思い出してください。

既存のツール

GoReSymで扱われている型解析ロジックの一部、またはすべてをサポートする公開ツールと商用ツールがあります。 RedressIDAGolangHelperなどの以前の作品は優れており、称賛されるべきです。 GoReSymと既存のツールの主な違いは、 GoReSymが Go ランタイム ソース コードに基づいていることです。これは、Go 内部構造の進化の急速なペースに対抗するのに役立ちます。さらに、ランタイムは既にクロス アーキテクチャであるため、 GoReSymもクロス アーキテクチャです。すべての新しい解析ロジックは、32 ビットと 64 ビットのビッグ エンディアン アーキテクチャとリトル エンディアン アーキテクチャを正しくサポートするように配慮されています。可能な場合は、他のツールが苦労する可能性がある、解凍されたサンプルまたは部分的に破損したサンプルを処理するように注意が払われました。全体として、 GoReSymは他のツールが失敗した場合に機能するように設計されており、Go の進化に合わせてツールのメンテナンスを容易にする方法を提供します。

参考文献とクレジット

コードではなく元のパッケージ分類のアイデアがGoReSymで使用され、JEB ブログ投稿の再帰型解析のアイデアがtypelink列挙の実装に役立ちました。作品を公開してくれたこれらの著者に感謝します。

GoReSymをチェックしてください!

参照: https://www.mandiant.com/resources/blog/golang-internals-symbol-recovery

Comments

Copied title and URL