Ghidra で Golang マルウェアを解析
概要
Go (Golang) は、クロスコンパイル機能、自己完結型バイナリを生成する静的リンク、そしてリバースエンジニアリングに複雑さをもたらす特性から、マルウェア作者に人気の言語になっています。Go バイナリはランタイム全体、標準ライブラリ、すべての依存関係を静的にリンクするため、結果として非常に大きなバイナリ(多くの場合 5~15MB)となり、数千もの関数が含まれます。Ghidra は、Go 固有の文字列形式(NULL 終端ではない)、ストリップされた関数名、goroutine の並行処理パターンに対応するのに苦労します。GoResolver(Volexity、2025)のような専門ツールは、制御フローグラフの類似性を利用して、ストリップまたは難読化された Go バイナリの関数名を自動的に復元・復号します。
使用すべき状況
- Ghidra で Golang マルウェアを解析する必要があるセキュリティインシデントを調査する場合
- この分野に関する検知ルールや脅威ハンティングクエリを作成する場合
- SOC アナリストがこの分析タイプの構造化された手順を必要とする場合
- 関連する攻撃手法に対するセキュリティ監視範囲を検証する場合
前提条件
- Ghidra 11.0+(JDK 17+)
- GoResolver プラグイン(関数名復元用)
- Go Reverse Engineering Tool Kit (go-re.tk)
- ヘルパースクリプト用 Python 3.9+
- Go ランタイム内部構造(goroutine、channel、interface)の理解
- Go バイナリ構造(pclntab、moduledata、itab)への精通
主要な概念
Go バイナリ構造
Go バイナリは、プログラムカウンタを関数名、ソースファイル、行番号にマッピングする pclntab(プログラムカウンタ行テーブル)構造体に豊富なメタデータを埋め込みます。ストリップされたバイナリでもこのメタデータは保持されます。moduledata 構造体には、型情報、itab(インターフェーステーブル)、pclntab 自体へのポインタが含まれます。Go 文字列は、NULL 終端の C 文字列ではなく、ポインタと長さのペアとして格納されます。
ストリップされたバイナリにおける関数復元
シンボルテーブルがストリップされていても、Go バイナリは pclntab 内に関数名を保持しています。ただし、garble のような難読化ツールは、関数名をランダムな文字列に変更します。GoResolver は、難読化された関数の制御フローグラフシグネチャを計算し、既知の Go 標準ライブラリおよびサードパーティパッケージ関数のデータベースと照合することで、この問題に対処します。
クレート/依存関係の抽出
Go の依存関係管理は、モジュールパスとバージョン文字列をバイナリに埋め込みます。これらを抽出することで、マルウェアのサードパーティ依存関係(HTTP ライブラリ、暗号化パッケージ、C2 フレームワーク)が明らかになり、完全なリバースエンジニアリングを必要とせずに機能に関する洞察が得られます。
ワークフロー
ステップ 1: 初期バイナリ分析
#!/usr/bin/env python3
"""マルウェア解析のための Go バイナリメタデータ解析。"""
import struct
import sys
import re
def find_go_build_info(data):
"""バイナリから Go ビルド情報を抽出する。"""
# Go buildinfo マジック: \xff Go buildinf:
magic = b'\xff Go buildinf:'
offset = data.find(magic)
if offset == -1:
return None
print(f"[+] Go ビルド情報がオフセット 0x{offset:x} にあります")
# 近くの Go バージョン文字列を抽出
go_version = re.search(rb'go\d+\.\d+(?:\.\d+)?', data[offset:offset+256])
if go_version:
print(f" Go バージョン: {go_version.group().decode()}")
return offset
def find_pclntab(data):
"""pclntab(プログラムカウンタ行テーブル)構造体を特定する。"""
# pclntab マジックバイトは Go バージョンによって異なる
magics = {
b'\xfb\xff\xff\xff\x00\x00': "Go 1.2-1.15",
b'\xfa\xff\xff\xff\x00\x00': "Go 1.16-1.17",
b'\xf1\xff\xff\xff\x00\x00': "Go 1.18-1.19",
b'\xf0\xff\xff\xff\x00\x00': "Go 1.20+",
}
for magic, version in magics.items():
offset = data.find(magic)
if offset != -1:
print(f"[+] pclntab が 0x{offset:x} にあります ({version})")
return offset, version
return None, None
def extract_function_names(data, pclntab_offset):
"""pclntab から関数名を抽出する。"""
if pclntab_offset is None:
return []
functions = []
# 関数名文字列は特定のパターンに従う
func_pattern = re.compile(
rb'(?:main|runtime|fmt|net|os|crypto|encoding|io|sync|'
rb'syscall|reflect|strings|bytes|path|time|math|sort|'
rb'github\.com|golang\.org)[/\.][\w/.]+',
)
for match in func_pattern.finditer(data):
name = match.group().decode('utf-8', errors='replace')
if len(name) > 4 and len(name) < 200:
functions.append(name)
return sorted(set(functions))
def extract_go_strings(data):
"""Go 形式の文字列(ポインタ+長さペア)を抽出する。"""
# Go 文字列は NULL 終端ではない。読み取り可能なシーケンスを抽出する
strings = []
ascii_pattern = re.compile(rb'[\x20-\x7e]{10,}')
for match in ascii_pattern.finditer(data):
s = match.group().decode('ascii')
# 興味深いマルウェア文字列にフィルタリング
interesting = [
'http', 'https', 'tcp', 'udp', 'dns',
'cmd', 'shell', 'exec', 'upload', 'download',
'encrypt', 'decrypt', 'key', 'token', 'password',
'c2', 'beacon', 'agent', 'implant', 'bot',
'mutex', 'persist', 'registry', 'scheduled',
]
if any(kw in s.lower() for kw in interesting):
strings.append(s)
return strings
def extract_dependencies(data):
"""バイナリから Go モジュールの依存関係を抽出する。"""
deps = []
# モジュールパスは github.com/user/repo のパターンに従う
dep_pattern = re.compile(
rb'((?:github\.com|gitlab\.com|golang\.org|gopkg\.in|'
rb'go\.etcd\.io|google\.golang\.org)/[^\x00\s]{5,80})'
)
for match in dep_pattern.finditer(data):
dep = match.group().decode('utf-8', errors='replace')
deps.append(dep)
unique_deps = sorted(set(deps))
return unique_deps
def analyze_go_binary(filepath):
"""Go マルウェアバイナリの完全解析。"""
with open(filepath, 'rb') as f:
data = f.read()
print(f"[+] Go バイナリを解析中: {filepath}")
print(f" ファイルサイズ: {len(data):,} バイト")
print("=" * 60)
# ビルド情報
find_go_build_info(data)
# pclntab
pclntab_offset, go_version = find_pclntab(data)
# 関数
functions = extract_function_names(data, pclntab_offset)
print(f"\n[+] {len(functions)} 個の関数名を復元しました")
# 関数をカテゴリ分け
categories = {
"network": [], "crypto": [], "os_exec": [],
"file_io": [], "main": [], "third_party": [],
}
for f in functions:
if 'net/' in f or 'http' in f.lower():
categories["network"].append(f)
elif 'crypto' in f:
categories["crypto"].append(f)
elif 'os/exec' in f or 'syscall' in f:
categories["os_exec"].append(f)
elif 'os.' in f or 'io/' in f:
categories["file_io"].append(f)
elif f.startswith('main.'):
categories["main"].append(f)
elif 'github.com' in f or 'golang.org' in f:
categories["third_party"].append(f)
for cat, funcs in categories.items():
if funcs:
print(f"\n [{cat}] ({len(funcs)} 関数):")
for fn in funcs[:10]:
print(f" {fn}")
# 依存関係
deps = extract_dependencies(data)
print(f"\n[+] 依存関係 ({len(deps)}):")
for dep in deps[:20]:
print(f" {dep}")
# 不審な文字列
sus_strings = extract_go_strings(data)
print(f"\n[+] 不審な文字列 ({len(sus_strings)}):")
for s in sus_strings[:20]:
print(f" {s}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"使い方: {sys.argv[0]} <go_binary>")
sys.exit(1)
analyze_go_binary(sys.argv[1])
ステップ 2: Ghidra 解析スクリプト
# Ghidra スクリプト(Ghidra のスクリプトマネージャ内で実行)
# AnalyzeGoBinary.py として Ghidra スクリプトディレクトリに保存
# @category MalwareAnalysis
# @description Go バイナリ構造を解析しメタデータを復元する
def analyze_go_binary_ghidra():
"""Go バイナリ解析用 Ghidra スクリプト。"""
from ghidra.program.model.mem import MemoryAccessException
program = getCurrentProgram()
memory = program.getMemory()
listing = program.getListing()
print("[+] Go バイナリ解析スクリプト")
print(f" プログラム: {program.getName()}")
# pclntab を検索
pclntab_magics = [
bytes([0xf0, 0xff, 0xff, 0xff]), # Go 1.20+
bytes([0xf1, 0xff, 0xff, 0xff]), # Go 1.18-1.19
bytes([0xfa, 0xff, 0xff, 0xff]), # Go 1.16-1.17
bytes([0xfb, 0xff, 0xff, 0xff]), # Go 1.2-1.15
]
for magic in pclntab_magics:
addr = memory.findBytes(
program.getMinAddress(), magic, None, True, None
)
if addr:
print(f"[+] pclntab が {addr} にあります")
# ラベルを作成
program.getSymbolTable().createLabel(
addr, "go_pclntab", None,
ghidra.program.model.symbol.SourceType.ANALYSIS
)
break
# Go 文字列定義の修正
# Go 文字列は ptr+len であり、NULL 終端ではない
print("[+] Go 文字列参照を修正中...")
# パッケージパスを含む関数名を検索
symbol_table = program.getSymbolTable()
func_count = 0
for symbol in symbol_table.getAllSymbols(True):
name = symbol.getName()
if ('.' in name and
any(pkg in name for pkg in
['main.', 'runtime.', 'net.', 'crypto.', 'os.'])):
func_count += 1
print(f"[+] {func_count} 個の Go 関数シンボルを検出しました")
# 実行
analyze_go_binary_ghidra()
検証基準
- Go バージョンとビルド情報がバイナリから抽出されていること
- pclntab が特定され、関数名復元のために解析されていること
- サードパーティの依存関係が特定され、マルウェアの機能が明らかになっていること
- main パッケージの関数が列挙され、対象を絞った分析が可能になっていること
- ネットワーク、暗号化、OS 実行関数がカテゴリ分けされていること
- Ghidra 解析が Go ランタイム構造を正しくラベル付けしていること