未知のバイナリを攻略せよ:LLDBを用いた『シンボルなし実行ファイル』のリバースエンジニアリング入門
テックリードの君なら、一度は直面したことがあるはずだ。
サードパーティ製のプロプライエタリなライブラリ、ドキュメントが完全に失われたレガシーシステム、あるいはセキュリティ監査の最中に放り込まれた、一切のデバッグ情報が剥ぎ取られた(Stripped)バイナリ。
`nm`や`objdump`を叩いても、そこにあるのは無機質なメモリアドレスと、見慣れない機械語の羅列だけ。`main`シンボルすらない。
「ここにブレークポイントを張れ」と言われても、手がかりはゼロだ。
しかし、恐れることはない。コンパイラが吐き出す機械語の構造、そしてOSのダイナミックリンクの仕組み(PLT/GOT)を理解していれば、シンボルがないという事実は「読めない」を意味しない。単に「読み方を知らない」だけなのだ。
今回は、現代のmacOS/Linux開発におけるデバッグのデファクトスタンダードである LLDB を用いて、シンボルなしバイナリを丸裸にし、その内部構造を完全に復元する実践的かつ極限まで効率化されたテクニックを伝授する。
—
1. LLDBの真価を引き出す設定とカスタムコマンド
デフォルトのLLDBは、正直言って使い勝手がいいとは言えない。シンボルがないバイナリを相手にする場合、毎回のタイポや冗長なコマンド入力は認知負荷を高め、解析スピードを著しく落とす。
プロのアーキテクトは、環境を極限までチューニングしている。まずは、あなたの手元にある `.lldbinit` をアップデートし、シンボルレス解析を加速させる神設定を導入しよう。
実践的 `.lldbinit` ベストプラクティス
ホームディレクトリの `~/.lldbinit` に以下の設定を記述する。これにより、逆アセンブルの視認性が劇的に向上し、レジスタやメモリの追跡が容易になる。
=====================================================================
LLDB Initialization Config for Reverse Engineering
=====================================================================
1. 逆アセンブルのデフォルトフレーバーをIntel形式に設定 (AT&T形式の呪縛から解放される)
settings set target.x86-assembly-flavor intel
2. ステップ実行時に自動的に逆アセンブルを表示 (今どこを実行しているかを視覚的に常時把握)
settings set stop-disassembly-count 10
3. カラー出力を有効化し、構文ハイライトで可読性を最大化
settings set use-color true
=====================================================================
カスタムエイリアス(よく使う長大なコマンドを1文字〜数文字に凝縮)
=====================================================================
現在のレジスタ状態を綺麗にダンプする (r = register read のエイリアス拡張)
command alias rr register read –all
現在のインストラクション周辺を逆アセンブル (u = disas)
command alias u disassemble –pc
現在のスタックフレームの引数とローカル変数を表示 (フレームがなくてもレジスタから推論)
command alias sf frame variable
PLT/GOTのエントリを迅速に確認するためのカスタムスクリプト定義
(後述の動的リンク解析で使用)
—
2. 現場で震えるほど役立つLLDBショートカット&イディオム
デバッグセッションにおいて、キーボードから手を離す時間、あるいは無駄なコマンドを打つ時間は悪である。以下のショートカットとイディオムを体に叩き込め。
| 目的 | 標準コマンド | 最速ショートカット / イディオム | 現場での活用シーン |
| :— | :— | :— | :— |
| ステップオーバー | `thread step-over` | `n` (または `next`) | 関数内部に入らずに次へ進む |
| ステップイン | `thread step-in` | `s` (または `step`) | 未知の関数内部へ飛び込む |
| 関数を抜ける | `thread step-out` | `finish` | 解析ミスった関数から即座に脱出 |
| 条件付きブレーク | `breakpoint set …` | `b 0x401234 -c “rax == 0″` | 特定のレジスタ値の時だけ止める |
| メモリの直接確認 | `memory read` | `x/4gx $rsp` | スタック上の変数を8バイト単位で4つ読む |
プロが使う「条件付きブレーク」の真髄
シンボルがない場合、アドレスベースでブレークを仕掛けることになるが、「ループの500回目でバグる」といった状況では通常のブレークでは太刀打ちできない。
LLDBでは、ヒットカウント(Hit Count)を利用したブレークが可能だ。
(lldb) breakpoint set –address 0x0000000100003fa0 –condition ‘$rcx == 0x0’
このように条件を仕掛けることで、ノイズとなる無数のヒットを無視し、真に調査すべき瞬間ピンポイントで実行を停止させることができる。
—
3. 攻略ステップ1:PLT / GOT から「外部依存」を特定する
シンボルが剥ぎ取られていても、プログラムが外部のOS機能(ファイルI/O、ネットワーク、メモリ確保など)を使うためには、動的リンカ(dyld / ld-linux)の助けを借りる必要がある。ここに突破口がある。
ELFバイナリであれば PLT (Procedure Linkage Table) と GOT (Global Offset Table)、Mach-Oであれば Stubs と Lazy Pointer を解析することで、「このバイナリが何をしているのか」の強力なヒントが得られる。
手順:外部関数コールのあぶり出し
LLDBでバイナリをロードし、イメージ情報を確認する。
$ lldb ./stripped_target
(lldb) target modules dump symfile ./stripped_target
もし完全になくても、ダイナミックシンボル(Dynamic Symbols)は残されていることが多い。これらはストリップ後も動的リンクのために保持される必要があるからだ。
(lldb) image dump symtab ./stripped_target
この出力結果から、`_printf` や `_malloc`、あるいは独自の共有ライブラリの関数名が見つかったなら、それがこのバイナリの「足掛かり」となる。
例えば、`_fopen` を呼んでいるアドレスを特定できれば、その直前のレジスタ(RDIやRSIなど、x86_64の呼び出し規約)に格納されている文字列こそが、「プログラムがオープンしようとしている設定ファイルやログのパス」に他ならない。
—
4. 攻略ステップ2:逆アセンブル結果からの「関数プロファイル」の推論
メインのエントリポイント(`_main` や `entry`)を見つけたら、いよいよ逆アセンブルの解析だ。シンボルがない環境では、関数の境界(どこからどこまでが1つの関数か)を自分で見極める必要がある。
すべてのコンパイラ(GCC / Clang / MSVC)は、関数生成時に一定の決まったイディオム(定型コード)を出力する。これをプロローグ(Prologue)およびエピローグ(Epilogue)と呼ぶ。
関数プロローグのフィンガープリント(x86_64の場合)
バイナリを `disassemble` した際、以下のバイト列やインストラクションの組み合わせが現れたら、それは「新しい関数の始まり」である。
push rbp
mov rbp, rsp
または、スタックフレームを最適化で使わない場合(Leaf Functionなど)でも、スタックを確保する命令が最初に来る。
sub rsp, 0x30
シンボルなしバイナリを解析する際は、まずバイナリ全体から `55 48 89 e5`(`push rbp; mov rbp, rsp`)のパターンを探すことで、すべての関数のエントリポイントを機械的にリストアップすることができる。
—
5. 攻略ステップ3:メモリマップとデータ構造の動的復元
静的解析だけでは、動的に生成されるデータ構造(ヒープ上の構造体など)のレイアウトまでは分からない。ここでLLDBのメモリ操作コマンドの真価が発揮される。
ある関数が実行された後、メモリ上に展開されたデータ構造を復元する手順を見ていこう。
実践:メモリダンプと型キャストの模倣
例えば、ポインタが格納されているレジスタ `$rax` が指すメモリ領域の構造を知りたいとする。シンボルがないため、構造体の定義(フィールド名など)はLLDBには分からない。
そこで、メモリを直接16進数とASCIIでダンプする。
(lldb) memory read –format x –size 8 –count 16 $rax
- `–format x` : 16進数表示
- `–size 8` : 8バイト(64ビット)単位
- `–count 16` : 16ブロック分(計128バイト)
出力されたメモリ上から、以下の特徴を探す:
1. ポインタらしい値 : アダプティブなメモリ空間(例: `0x00007fff…`)を指している領域は、別の構造体へのネストしたポインタ、あるいは文字列へのポインタである可能性が高い。
2. ASCII文字列 : ダンプの右側に表示されるASCII文字領域に、人間が読める文字列(例: `”config_v2″` やエラーメッセージ)が見つかれば、そのオフセット位置に特定のデータが格納されていることが確定する。
この作業を繰り返すことで、脳内で「オフセット 0x00 はフラグ、0x08 は名前へのポインタ、0x10 はカウンター」という風に、バイナリ専用の構造体定義をリバースエンジニアリングしていくのだ。
—
6. チーム開発における知識の共有化とスケーリング
個人のスキルに依存しがちなリバースエンジニアリングだが、チームとして開発・解析スピードを最大化するためには、発見した知見をコードとして共有し、属人性を排除しなければならない。
共有ルール:カスタムLLDB Pythonスクリプトの導入
LLDBはPythonによるスクリプト拡張(LLDB Python API)を強力にサポートしている。
例えば、先ほど苦労して解析した未知の構造体を、LLDB上で人間が読める形に整形して表示するコマンドをPythonで記述し、プロジェクトの共有リポジトリ(例: `.gdbinit` ならぬ `.lldbinit` や `.lldb/commands.py`)に含めるべきだ。
プロジェクト共有用Pythonスクリプト例 (`.lldb/parser.py`)
import lldb
def dump_unknown_struct(debugger, command, result, internal_dict):
“””
未知のバイナリの特定アドレスにある構造体を解析して綺麗に表示するカスタムコマンド
“””
target = debugger.GetSelectedTarget()
process = target.GetProcess()
thread = process.GetSelectedThread()
frame = thread.GetSelectedFrame()
# コマンド引数からアドレスを取得
addr_str = command.strip()
addr = int(addr_str, 16) if addr_str.startswith(“0x”) else int(addr_str)
error = lldb.SBError()
# メモリから32バイト読み込む
content = process.ReadMemory(addr, 32, error)
if not error.Success():
result.SetError(f”Failed to read memory at {hex(addr)}: {error.GetCString()}”)
return
# アンパックの例 (最初の8バイトをint64、次の8バイトをポインタとして解釈)
import struct
val1, val2 = struct.unpack(“
このスクリプトをプロジェクトルートの `.lldb/parser.py` に配置し、`.lldbinit` から以下のようにロードするようにチーム全体でルール化する。
プロジェクトローカルのLLDB設定(リポジトリ管理する)
command script import .lldb/parser.py
これで、チームメンバー全員が `dump_struct 0x7ffee3b410f0` と叩くだけで、解析済みの構造体ビューを瞬時に共有できるようになる。個人の暗黙知が、チームの資産(コード)へと昇華される瞬間だ。
—
結び:未知を既知に変えるエンジニアであれ
シンボルがないバイナリを目の前にした時、凡人は諦め、プロはLLDBを起動する。
コンパイラが機械語を生成するルール、OSがメモリを管理する仕組み、そしてLLDBという強力なレンズ。これらを組み合わせれば、世界にブラックボックスなど存在しない。
今日紹介した設定、ショートカット、そしてPythonスクリプトによる拡張の思想をあなたの開発チームに導入し、いかなる難解なバイナリをも圧倒的なスピードで攻略してほしい。