こんにちは!日々の開発、本当にお疲れ様です。
突然ですが、皆さんはこんな絶望感を味わったことはないでしょうか?
「他チームから引き継いだソースコードのないレガシーバイナリ」「突然本番環境でクラッシュするサードパーティ製ライブラリ」、そして手元にあるのはデバッグ情報が綺麗さっぱり削ぎ落とされた、いわゆる「ストリップ済みバイナリ(Stripped Binary)」のみ……。
関数名も変数名もすべて消え去り、エディタを開いても `FUN_00401200` や `sub_401122` のような冷たいアドレスの羅列だけが並ぶ世界。多くのエンジニアがここで立ち尽くし、「無理だ、リバースエンジニアリングの専門家じゃないと解けない」と諦めてしまいます。
しかし、安心してください。世界最高峰のモダンデバッガ「LLDB」の仕組みとメモリの挙動さえ理解すれば、たとえシンボルが一切なくても、バイナリの心臓部を鮮やかに暴き出すことは十分に可能です。
今回は、シンボルなしバイナリをLLDBで攻略するための「現場の極意」を、優しく、そして徹底的に解説していきます。これをマスターすれば、ブラックボックスだったバイナリが手のうちで動くようになり、トラブルシューティングのスピードが劇的に変わりますよ。
—
1. そもそもなぜ「シンボルなしバイナリ」は難しく見えるのか?
普段私たちがCやC++、Rustなどでコードを書くとき、コンパイラは人間が読める関数名(例: `calculate_tax`)や変数名をバイナリの中に「シンボルテーブル」として残してくれます。デバッガ(LLDBやGDB)は、このテーブルを頼りに「今、どの関数にいるか」を私たちに教えてくれます。
しかし、プロダクション環境へのリリース時やセキュリティ上の理由(難読化やサイズ削減)で、`strip` コマンドが実行されると、このシンボルテーブルは容赦なく消去されます。
[通常のバイナリ]
main() -> calculate_tax() -> printf()
(人間が読める名前が残っている)
[ストリップ済みバイナリ]
0x00001200() -> 0x00001420() -> 0x00001030()
(アドレスしか分からない!)
「名前がないなら、どうやってデバッグすればいいのか?」
答えはシンプルです。「名前がないなら、メモリ上のジャンプ先とデータ構造のパターンから、その役割を推論すればいい」のです。
—
2. 準備:今回の実験台(Stripped Binary)の用意
まずは、理論を実践するための小さな実験台を作りましょう。
あえてデバッグ情報を削ぎ落としたバイナリを用意します。以下のC言語のコードを適当な名前(例: `target.c`)で保存してください。
include
// あえて外部から読まれそうな隠し関数
int calculate_secret(int a, int b) {
return (a ^ b) + 42;
}
int main() {
int result = calculate_secret(105, 87);
printf(“The secret result is: %d\n”, result);
return 0;
}
これを、シンボルを完全に削ぎ落としてコンパイルします。
-O0 で最適化を切り、-s (strip) オプションでシンボルを完全に消し去る
clang -O0 target.c -o target_stripped
strip target_stripped
これで、関数名 `calculate_secret` や `main` の名前が消え去った、純粋な「シンボルなしバイナリ (`target_stripped`)」が完成しました。
—
3. LLDBによる攻略ステップ:PLT / GOTから侵入せよ
シンボルがないバイナリを解析する際、最初に頼るべき羅針盤が PLT(Procedure Linkage Table) と GOT(Global Offset Table) です。
プログラムは、外部のライブラリ関数(今回の例では `printf` など)を直接呼び出すことができません。動的リンクの仕組み上、一度PLTを経由してGOTに格納されたアドレスへジャンプします。つまり、「このバイナリが外部のどの関数を呼んでいるか」は、PLT/GOTを見れば一目瞭然なのです。
ステップ 1: LLDBで起動し、エントリポイントを確認する
まずはLLDBを起動し、ターゲットを読み込ませます。
lldb ./target_stripped
LLDBが立ち上がったら、まずはmain関数(あるいはそれに相当する最初のコード)を探します。シンボルがないため、`run` や `b main` は使えません。代わりに、プログラムの「エントリポイント」にブレークポイントを仕掛けます。
(lldb) target create “./target_stripped”
Current executable set to ‘./target_stripped’ (x86_64).
シンボルがないので、’_start’(OSが最初に呼び出す真のエントリポイント)にブレークをかける
(lldb) b _start
Breakpoint 1: where = target_stripped`_start, address = 0x0000000000001050
(lldb) run
Process 12345 launched:
Process 12345 stopped
- thread #1, queue = ‘com.apple.main-thread’, stop reason = breakpoint 1.1
- frame #0: 0x0000000100003050 target_stripped`_start
これでプログラムの最上流で停止しました。
ステップ 2: 外部関数の呼び出し(PLT)を暴く
次に、このバイナリが何を使っているのかを知るために、逆アセンブル機能を使います。LLDBでは `disassemble` コマンド(略して `di`)を使います。
現在の命令周辺を逆アセンブルする
(lldb) disassemble -c 30
出力結果の中に、見慣れないシンボルが見つかるはずです。
例えば、`plt.printf` や `0x1000…` のようなジャンプ命令です。ストリップされていても、外部ライブラリをリンクするためのスタブ名(PLTエントリ)は、ダイナミックローダのために残されていることが多いのです。
もしPLTのシンボルすら完全に消えている場合は、メモリ上の文字列を検索します。
バイナリ内の文字列(String Literals)を検索する
(lldb) image lookup -v -s “The secret result is”
これにより、その文字列を参照しているコード領域(クロスリファレンス)の住所が特定できます。文字列を使っている場所こそが、まさに私たちが探している核心部分です。
—
4. 逆アセンブル結果と照らし合わせた関数の推論
文字列の参照元(例: `0x100003f10` あたり)が分かったら、その周辺の機械語をじっくり観察します。
(lldb) disassemble –start-address 0x100003ee0 –count 20
ここで、x86_64アーキテクチャの呼び出し規約(Calling Convention)の知識が強力な武器になります。
- レジスタ `rdi`, `rsi`, `rdx` には、関数に渡される引数が入る。
- `call` 命令の先に、名前のないサブルーチン(例: `0x100003ea0`)がある。
「あ、ここで2つの数字(105と87)をレジスタに詰めて、謎の関数を呼んでいるぞ」と気づくことができます。これが、シンボルなしバイナリから関数の役割を推論する瞬間です。
—
5. メモリマップとデータ構造の復元
関数が特定できたら、次はその中身(演算ロジック)を追います。
先ほど作成したコードには、`calculate_secret` 関数の中に `(a ^ b) + 42` という排他的論理和(XOR)と加算の処理が含まれていました。
逆アセンブル画面で、以下のような命令列を見つけるはずです。
- `xor` (排他的論理和)
- `add` (加算、定数 `0x2a` = 10進数で42)
0x100003ea0: xorl %esi, %edi # 引数同士のXORを取っている
0x100003ea2: leal 0x2a(%rdi), %eax # 結果に 42 (0x2a) を足している
0x100003ea5: retq # 返す
おお!関数名が消えていようとも、機械語のパターン(XORと42の加算)を見るだけで、この関数が何をしているのか(入力値を加工して特定のロジックを計算していること)が完全に手に取るように分かりましたね。
もしこれが複雑なデータ構造(構造体)を扱っている場合でも、LLDBのメモリ確認コマンド `memory read`(略して `x`)を使えば、構造体のオフセットを完璧に復元できます。
レジスタが指すアドレスから、64ビットのデータを10個分、16進数でダンプする
(lldb) memory read -f x -c 10 $rsp
このコマンドでメモリ上のバイト列を覗き見れば、「あ、このオフセット `+8` の位置にはポインタが入っているな」「ここはフラグ用のフラットな4バイトだな」といったデータ構造の設計図が、まるでパズルのように組み上がっていきます。
—
6. 先輩エンジニアからの実践アドバイス
シンボルがないバイナリをデバッグする作業は、最初は暗闇の中を手探りで歩くように感じるかもしれません。しかし、以下の3つを意識するだけで、調査スピードが劇的に向上します。
1. 「動くログ」を信じろ
ソースコードがなくても、LLDBの `register write` やブレークポイントでの条件分岐(`breakpoint set –condition`)を使い、動的に値を書き換えて挙動の変化を見ることで、ブラックボックスの仕様を逆算できます。
2. クロスリファレンス(参照関係)を徹底的に洗う
「この定数や文字列はどこから呼ばれているか?」を起点に逆アセンブルを辿ることで、迷子にならずに目的のロジックへたどり着けます。
3. 怖がらずに `disassemble` を叩く
機械語(アセンブリ)は、C言語やRustがコンパイルされた「結果の姿」です。怖がらずに1行ずつ読んでいくと、コンパイラがどれほど素直にコードを翻訳しているかに気づき、感動すら覚えるはずです。
—
まとめ
今回は、LLDBを用いて「シンボルなしバイナリ(Stripped Binary)」を攻略するための基礎アプローチを解説しました。
- シンボルが消えていても、PLT/GOT や 文字列参照 を手掛かりに侵入経路を見つける。
- 逆アセンブル結果とレジスタの動き(呼び出し規約)から、関数の役割を論理的に推論する。
- `memory read` コマンドでメモリ空間を直接覗き見し、データ構造を復元する。
この手法を身につければ、世の中に「解析できないバイナリ」は存在しなくなります。トラブルシューティングの引き出しが圧倒的に広がり、どんな難敵なバグやレガシーシステムが目の前に現れても、冷静に真相に迫ることができるはずです。
あなたのデバッグライフが、よりスリリングで、そして実りあるものになりますように。それではまた次の現場でお会いしましょう!