こんにちは!日々のデバッグやコード品質の向上、本当にお疲れ様です。
私たちが普段何気なく書いているCやC++、あるいはRustやGoなどのコンパイル言語。コードが書き終わり、「さあ実行だ」とビルドしたとき、OSの裏側で何が起きているか意識したことはありますか?
「画面に `Hello, World!` が出たからOK」
「テストが通ったからマージしよう」
もちろん、それもエンジニアとして大切な一歩です。しかし、ふとプロダクション環境で不可解なセグメンテーション違反(Segmentation Fault)に直面したり、セキュリティ脆弱性の解析やパフォーマンスチューニングを迫られたりしたとき、「OSとバイナリがどうやって関数を呼び出しているのか」という低レイヤの教養が、あなたを救う最大の武器になります。
今回は、開発現場で一目置かれるエンジニアになるための第一歩として、低レイヤデバッガの金字塔である GDB (GNU Debugger) を使って、共有ライブラリの裏側にある PLTとGOT を徹底的に追跡する方法を解説します。
これをマスターすれば、シンボルが削ぎ落とされたブラックボックスなバイナリであっても、内部の動きを透視できるようになりますよ。それでは、深淵なるバイナリの世界へ一緒に出発しましょう!
—
1. そもそも低レイヤデバッガ(GDB)の役割とは?
IDE(VS CodeやCLionなど)についている綺麗なビジュアルデバッガも素晴らしいですが、それらの多くは内部で `GDB` や `LLDB` といったCUIベースの低レイヤデバッガをエンジンとして動かしています。
GDBの真骨頂は、「CPUのレジスタ」「メモリの生データ」「スタックフレーム」「OSのシステムコール」といった、ソースコードの抽象化のベールを剥ぎ取った「剥き出しの現実」をそのまま覗き見できる点にあります。
特に、コンパイル済みのバイナリがメモリ上でどのように展開され、どのように他のライブラリと連携しているのかを追うには、GDB以外の選択肢はありません。
最小限にして最強の基礎セットアップ
まずは、手元の環境にGDBと、今回の実験に必要なコンパイラ、そしてバイナリ解析用のユーティリティをインストールしましょう。Ubuntu/Debian系を前提に話を進めます。
必要なビルドツール、GDB、およびバイナリ解析用ユーティリティ(binutils)の一括インストール
sudo apt-get update && sudo apt-get install -y \
build-essential \
gdb \
binutils \
patchelf
インストールされたGDBのバージョン確認
gdb –version
> 先輩からのアドバイス
> 実務では、GDBを生のままで使うのではなく、`.gdbinit` という設定ファイルをホームディレクトリに置くことで、見違えるほど使いやすくなります。例えば、`set disassembly-flavor intel` と書いておくだけで、読みにくいAT&T構文ではなく、直感的なインテル構文でアセンブリを表示してくれるようになります。今すぐ設定しておきましょう!
—
2. 共有ライブラリの裏側:PLTとGOTの仕組みを理解する
さて、ここからが本題です。私たちが書いたプログラムから `printf` などの標準ライブラリ関数を呼ぶとき、そのコードはどこにあるのでしょうか?
答えは「OSのメモリ上の別の場所(共有ライブラリ: `libc.so` など)」です。
しかし、プログラムがビルドされた時点では、`libc.so` がメモリ上のどこにロードされるのか、アドレスを完全に知ることはできません(ASLRというセキュリティ機構もあるため毎回アドレスが変わります)。
そこで登場するのが、PLT (Procedure Linkage Table) と GOT (Global Offset Table) です。
- GOT (Global Offset Table): 外部ライブラリの関数の「本当のメモリーアドレス」を格納するテーブル(データ領域)。
- PLT (Procedure Linkage Table): GOTを参照し、まだアドレスが解決されていなければ動的リンカを呼び出して解決し、解決済みならそのままジャンプするためのコード領域。
初めてその関数を呼ぶときは少し遠回りをし(遅延バインディング)、2回目以降はGOTから一発でジャンプする。この巧妙な仕組みのおかげで、プログラムの起動高速化とメモリ節約が両立されています。この動きをGDBで実際に暴いてみましょう。
—
3. 精度高い「HelloWorld」で動的リンクの瞬間を追跡する
まずは、非常にシンプルなC言語のプログラムを用意します。あえて最適化を切って、デバッグ情報を残した状態でコンパイルします。
サンプルの作成 (`main.c`)
include
int main() {
// 画面に文字列を出力するだけのシンプルな処理
printf(“Hello, PLT and GOT!\n”);
return 0;
}
コンパイルとビルド
-g オプションでデバッグ情報を付与、-no-pie はアドレス追跡をしやすくするための設定
gcc -g -no-pie main.c -o main
GDBでの追跡セッション
それでは、GDBを起動して `printf` がどのように呼び出されるのかを追いかけます。
gdb ./main
GDBが起動したら、以下のコマンドを順に入力してみましょう。
(gdb) # 1. main関数にブレークポイントを設定
(gdb) break main
Breakpoint 1 at 0x401122: file main.c, line 5.
(gdb) # 2. プログラムを実行開始
(gdb) run
Starting program: /path/to/main
Breakpoint 1, main () at main.c:5
5 printf(“Hello, PLT and GOT!\n”);
(gdb) # 3. 現在の周辺の逆アセンブラ(アセンブリコード)を表示
(gdb) disassemble
Dump of assembler code for function main:
0x0000000000401116 <+0>: push rbp
0x0000000000401117 <+1>: mov rbp,rsp
0x000000000040111a <+4>: lea rdi,[rip+0xee7] # 0x402008
0x0000000000401121 <+11>: call 0x401030
0x0000000000401126 <+16>: mov eax,0x0
0x000000000040112b <+21>: pop rbp
0x000000000040112c <+22>: ret
End of assembler code
おっ、見つかりましたね! `call 0x401030
では、この `0x401030` の中身(PLTのエントリ)を覗いてみましょう。
(gdb) # 4. PLTの中身を逆アセンブルする
(gdb) disassemble 0x401030
Dump of assembler code for function printf@plt:
0x0000000000401030 <+0>: bnd jmp qword ptr [rip + 0x2ff2] # 0x404028 (GOTのエントリ)
0x000000000040103b <+11>: push 0x0 # 初回のみ:関数インデックスのプッシュ
0x0000000000401040 <+16>: jmp 0x401020 # 初回のみ:動的リンカ(ld.so)へジャンプ
End of assembler code
ここがポイントです。最初のジャンプ先である `[rip + 0x2ff2]`(アドレス `0x404028`)が GOT です。
プログラムがまだ `printf` を一度も実行していなければ、このGOTの中には「動的リンカのヘルパーアドレス」が入っています。一度実行されると、動的リンカによって `libc.so` 内の本当の `printf` のアドレスに書き換えられます。
実際にステップ実行(`si` コマンド)して、GOTの中身が書き換わる瞬間を確認してみましょう!
—
4. 【実務の裏技】シンボルが剥ぎ取られたバイナリの解析手法
実務では、セキュリティ上の理由やサードパーティ製ライブラリの都合で、シンボル情報(関数名や変数名)がすべてストリップ(削除)されたバイナリに出くわすことが多々あります。「`main` さえ分からない」「関数名がすべて消えている」そんな絶望的な状況でどうやってコードを特定するのでしょうか?
ここで、先ほどのPLT/GOTの知識が強力に活きてきます。
シンボルなしバイナリの作成
実験として、先ほどのバイナリからすべてのシンボルを削ぎ落としてみましょう。
stripコマンドでデバッグ情報とシンボルを完全削除
strip –strip-all main -o main_stripped
確認:シンボルが消えているため、gdbで通常の関数名が引けない
gdb ./main_stripped
GDBで `break main` と打っても、「Function “main” not defined.」と怒られてしまいます。
関数テーブル(PLT)を逆算して処理を特定するテクニック
シンボルが消えていても、「外部ライブラリを呼び出すためのPLTエントリ」はバイナリ内に残存しています。 なぜなら、OSが動的リンクを行うためにどうしても必要な構造だからです。
GDB(または `objdump`)を使って、PLT領域をスキャンし、外部関数への参照を割り出します。
objdumpを使って、strippedバイナリのPLTセクションを丸裸にする
objdump -d -j .plt main_stripped
出力結果の中から、見慣れない `.plt` の塊を探します。例えば、次のようなパターンが見つかります。
0000000000401030 <.plt>:
401030: ff 35 f2 2f 00 00 pushq 0x2ff2(%rip) # 404028 <_GLOBAL_OFFSET_TABLE_+0x8>
401036: ff 25 f4 2f 00 00 jmpq 0x2ff4(%rip) # 404030 <_GLOBAL_OFFSET_TABLE_+0x10>
…
401050
さらに高度な解析では、GDB内で動的にメモリ上の文字列(クロスリファレンス)を検索します。
プログラム内部にハードコードされたエラーメッセージや文字列(今回の例なら `”Hello, PLT and GOT!\n”`)を頼りに、それを参照しているコードブロックを逆算します。
(gdb) # メモリ空間から特定の文字列を検索する (search-memoryコマンド)
(gdb) search-memory &main, 0x1000, “Hello, PLT”
[0x402008]
文字が格納されているアドレス(`0x402008`)が分かったので、次に「どの命令がこのアドレスを参照しているか(クロスリファレンス)」をGDBで逆アセンブルしながら特定し、上位の処理ブロックへと遡っていくのです。
このように、シンボルが消えていっても、「データ(文字列)への参照」と「外部連携(PLT/GOT)」の足跡を辿ることで、ブラックボックスの内部構造を完全に解体・復元できるようになります。これが、一流のデバッガーたちが使っているバイナリ解析の極意です。
—
5. まとめ
今回は、GDBを用いた低レイヤデバッグの世界として、以下の内容を駆け抜けました。
1. GDBの真価: ソースコードの枠を超え、CPUレジスタやメモリの現実を直視する。
2. PLT/GOTの仕組み: 共有ライブラリの関数を遅延バインディングで安全かつ効率的に呼び出すOSの仕組み。
3. 動的リンクの追跡: 実際のジャンプ先やGOTの書き換わりをGDBのステップ実行で目撃する方法。
4. シンボルなしバイナリの攻略: PLTやメモリ上の文字列を手がかりにブラックボックスを紐解く実務テクニック。
「コードが動かない」「原因不明のクラッシュが起きる」――そんな壁にぶFつかったとき、画面の向こうでOSやバイナリがどう動いているのかを頭の中でイメージできるようになると、デバッグは「苦痛な作業」から「知的な謎解き」へと劇的に変わります。
これをマスターすれば、毎日のコーディングで不具合に直面したときも、恐れることなく堂々とバイナリの懐に飛び込めるようになりますよ。ぜひ、手元の環境で今回のコマンドを動かして、その目で確かめてみてください。
あなたの開発ライフが、より深みのあるエキサイティングなものになりますように。それではまた!