動的リンクとシンボル解決(PLT/GOT・遅延束縛)
共有ライブラリ起動の謎が解けると、起動の遅さや謎のクラッシュの原因を切り分けられる。PIC・PLT/GOT・遅延束縛・シンボル干渉を原理から押さえる。
- 1.共有ライブラリは複数プロセスでコードページを共有するため位置独立コード(PIC)で書かれ、絶対アドレスを直接埋め込まずGOT(Global Offset Table)経由の間接参照でデータと関数を参照する。
- 2.外部関数呼び出しはPLT(Procedure Linkage Table)を踏み台にし、初回呼び出し時にだけ動的リンカが解決してGOTへ実アドレスを書き戻す遅延束縛を行う。2回目以降はGOT経由の直接ジャンプで済む。
- 3.ELFのシンボル解決はグローバルな名前空間を先頭一致で走査するためシンボル干渉(interposition)が起き、これを制御するのがシンボルバージョニングと可視性(visibility)属性である。
なぜ動的リンクに間接参照が必要なのか
共有ライブラリ(.so/.dll/.dylib)の狙いは、同じコードを複数のプロセスで物理メモリ上から共有することにあります。libc のコードページを各プロセスが個別に複製していては、メモリも起動コストも無駄になります。共有を成り立たせる条件は、コードページがどのプロセスでも書き換え不要なまま使えることです。ところがプログラムがロードされるアドレスは実行のたびに変わりうる(ASLRもあります)ため、コードの中に絶対アドレスを直接書き込んでしまうと、ロード先ごとにコードを書き換える羽目になり共有が壊れます。
この矛盾を解くのが 位置独立コード(PIC: Position-Independent Code)です。PICはアドレスを命令の中に焼き込まず、すべての外部参照を1か所のテーブルへの 間接参照 に置き換えます。テーブル自体はプロセスごとに書き換えてよい(プライベートな)データ領域に置き、コード本体は不変に保つ。ここで登場するのが GOT と PLT です。実行ファイル生成全体の流れはABI・呼び出し規約とリンカの仕組み、ロード先がばらつく前提となるアドレス空間の話は仮想メモリとページングのプログラミング的含意で補えます。
PICは絶対アドレスを持たない代わりに、自分の位置を基準とした相対参照を多用します。x86-64では命令ポインタ相対(RIP-relative)アドレッシングがあり、lea rax, [rip + offset] のように「現在の命令位置 + 定数」でGOTやデータを指せます。32ビットx86にはRIP相対がなく、callで戻り番地を読んで現在地を求めるテクニックが使われていました。だから64ビット化でPICのコストは大きく下がりました。
GOT——データと関数の実アドレスを置く表
GOT(Global Offset Table)は、外部シンボルの最終アドレスを格納する書き換え可能なテーブルです。コードは「GOTのこのスロットを読んで、そこに書いてあるアドレスへ行け」という間接参照だけを持ちます。スロットの中身(実アドレス)はロード時または初回呼び出し時に確定して書き込まれるので、コード本体は一切書き換わりません。
GOTは用途で性質が分かれます。外部 データ オブジェクト(グローバル変数など)への参照は、ロード直後に動的リンカが一括で埋めます(即時束縛)。一方、外部 関数 への参照は、後述する遅延束縛のために最初は仮の値を指しておき、初回呼び出しで差し替えます。
コード側(不変):
mov rax, [rip + global_x@GOTPCREL] ; GOTスロットを読む
mov eax, [rax] ; その先の実体を読む
GOT側(プロセスごとに書き換え可):
[global_x のスロット] = 0x7f...1234 ; ロード時にリンカが書き込む
PLT と遅延束縛——初回だけ解決する仕掛け
すべての外部関数アドレスを起動時に解決すると、実際には呼ばれない関数まで解決して起動が遅くなります。これを避けるのが 遅延束縛(lazy binding)で、その入り口が PLT(Procedure Linkage Table)です。各外部関数には小さなPLTエントリ(数命令の踏み台コード)が割り当てられ、関数呼び出しは直接ではなく対応するPLTエントリへ飛びます。
PLTエントリは「対応するGOTスロットを読んでそこへジャンプ」します。仕掛けの核心は、GOTスロットの初期値がPLTエントリ自身の続き(解決ルーチンへ向かう部分)を指している点です。流れはこうなります。
初回呼び出し:
call func@plt
-> PLT[func]: jmp *GOT[func]
GOT[func] は未解決なので「PLT[0]+解決ルーチン」へ戻る
-> 動的リンカ(_dl_runtime_resolve) が func の実アドレスを探索
-> GOT[func] に実アドレスを書き戻す
-> func 本体へジャンプ
2回目以降:
call func@plt
-> PLT[func]: jmp *GOT[func]
GOT[func] は実アドレスなので即ジャンプ(解決ルーチンを通らない)
つまり1回目だけ解決の遠回りをし、結果をGOTへキャッシュして以後は直接ジャンプに化けます。これは仮想関数のvtable間接呼び出しと構造が似ていますが、PLT/GOTは リンク時の解決、vtableは 実行時の型による分岐という違いがあります(動的ディスパッチとvtableの内部を参照)。
遅延束縛のためGOTは実行中に書き換え可能でなければなりません。これは攻撃者がGOTスロットを書き換えて制御を奪う「GOT overwrite」の標的になります。対策が RELRO(RELocation Read-Only)です。-Wl,-z,relro,-z,now で Full RELRO を有効にすると、起動時に全シンボルを即時解決(遅延束縛を放棄)してGOTを読み取り専用に再マップします。起動はわずかに遅くなりますが、GOTの改ざんを封じられます。セキュリティ重視のビルドでは遅延束縛をあえて捨てるわけです。
シンボル解決の順序と干渉(interposition)
ELFの動的シンボル解決は、プロセス内のオブジェクト(実行ファイルと各共有ライブラリ)を一定順序で並べた グローバルなシンボル名前空間 を、先頭から走査して最初に見つかった定義を採用します。この「先頭一致」の性質から、同名シンボルが複数あると、探索順で先に来た方が後続を覆い隠す 現象が起きます。これが シンボル干渉(symbol interposition)です。
干渉は機能でもあり罠でもあります。LD_PRELOAD で自作 malloc を先頭に差し込めば、ライブラリ側の malloc 呼び出しまで横取りできます(プロファイラやメモリデバッガの原理)。一方、意図せず同名シンボルが衝突すると、想定外の定義が呼ばれてデバッグ困難なバグになります。
| 仕組み | 効果 | 主な用途・注意 |
|---|---|---|
| LD_PRELOAD | 指定ライブラリを探索順の先頭へ | 関数の差し替え・計測。誤用で全体が壊れる |
| 可視性 hidden | シンボルを動的テーブルから除外 | ライブラリ内部関数の隠蔽・干渉防止 |
| -Bsymbolic | 自ライブラリ内の参照を内部優先で束縛 | 外部干渉を抑え呼び出しも高速化 |
| バージョンスクリプト | 公開シンボルを明示的に選別 | ABIの公開面を最小化 |
実務では、ライブラリの公開API以外を __attribute__((visibility("hidden")))(または -fvisibility=hidden)で隠すのが定石です。動的シンボルテーブルが小さくなるため解決が速くなり、内部関数が外部から干渉される事故も防げます。
シンボルバージョニングとABI互換
同じ共有ライブラリを更新しつつ、既存バイナリを再リンクなしで動かし続けたい——これを支えるのが シンボルバージョニング です。各シンボルにバージョンタグ(例 malloc@GLIBC_2.2.5)を付け、古いバイナリは古いバージョンの定義へ、新しいバイナリは新しい定義へ解決させます。1つのライブラリに同名・別バージョンの実体を共存させ、後方互換を保ったままABIを進化できます。
glibc 内に両方が存在:
realpath@GLIBC_2.2.5 ← 旧挙動(古いバイナリ向け)
realpath@GLIBC_2.3 ← 新挙動(@@ で既定版を指定)
古いプログラムは古い版へ、新しいプログラムは新しい版へ解決される
これは soname(libfoo.so.2 のメジャー番号)によるファイル単位の互換管理より細かい、シンボル単位 の互換制御です。sonameのメジャーが変わればライブラリ全体が非互換ですが、シンボルバージョニングならファイルは1つのまま個々の関数の互換性を管理できます。コンパイルからロードまでの全体像はコンパイルとインタプリタと合わせて押さえると、どの段で何が確定するかが整理できます。
「実行はできるのに起動時に symbol not found / version GLIBC_2.34' not found」は、ビルド環境より古いランタイムで起きる典型例です。新しいglibcでビルドすると新しいシンボルバージョンを要求するため、古いglibcのシステムでは解決に失敗します。原因は動的リンカのバージョン照合であって、コード自体の誤りではありません。ldd/readelf --dyn-syms/objdump -T` で要求バージョンを確認するのが定石です。
まとめ
動的リンクは「コードページの共有」という利得を、間接参照 という一手で実現します。PICが絶対アドレスをコードから追い出し、GOTが実アドレスを置く書き換え可能な表、PLTがそのGOTを踏む踏み台となって、初回呼び出し時だけ解決する遅延束縛を成立させます。一方でグローバルな名前空間の先頭一致探索はシンボル干渉を生み、可視性属性・バージョンスクリプト・シンボルバージョニングがそれを制御してABIの公開面と互換性を管理します。遅延束縛は速い起動と引き換えにGOT改ざんのリスクを抱え、RELROがそのトレードオフを調整します。ldd や readelf が吐く情報は、この一連の約束ごとのどこを見ているかを知れば、起動失敗や性能・セキュリティの判断材料へと変わります。
プログラミング Article
動的リンクとシンボル解決(PLT/GOT・遅延束縛)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
動的リンク
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
外部関数呼び出しはPLT(Procedure Linkage Table)を踏み台にし、初回呼び出し時にだけ動的リンカが解決してGOTへ実アドレスを書き戻す遅延束縛を行う。2回目以降はGOT経由の直接ジャンプで済む。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「動的リンク / PLT」に近いか確認する。
- 強みである「共有ライブラリは複数プロセスでコードページを共有するため位置独立コード(PIC)で書かれ、絶対アドレスを直接埋め込まずGOT(Global Offset Table)経由の間接参照でデータと関数を参照する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。