
リンカとローダの仕組み - ELF・静的リンクと動的リンク・PLT/GOT・LD_PRELOAD・RELROを手を動かして追う
リンカを自作して仕組みを体得する本。
リンカ・ローダの古典的教科書の邦訳。
ELFや動的リンクのハックが満載。
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
gcc hello.c と打つと a.out ができます。では、その中で printf の本体はどこにあるのでしょうか。a.out の中には入っていません。ls -l すると小さなファイルなのに、printf の実装を含む libc.so.6 はそれよりはるかに大きなファイルです。実行時に誰かが libc.so.6 を探して、プロセスのアドレス空間へ持ち込み、a.out の中の printf 呼び出しを本体へつなぎ直しているはずです。
別のマシンにバイナリを持っていって version 'GLIBC_2.34' not found と怒られた経験がある方も多いでしょう。LD_PRELOAD で malloc を差し替えるテクニックを見たことがあるかもしれません。checksec が言う "Full RELRO" が何を守っているのかを説明できるでしょうか。これらは全部、同じ一つの仕組み、つまりリンカ(linker)とローダ(loader、動的リンカ)の話です。
この記事では、コンパイル工程の末尾でリンカが何を解決しているのか、ELFファイルがどういう構造なのか、実行時に ld.so が何をしているのか、PLT/GOTによる遅延バインディングがどう動くのか、そしてそれらがセキュリティ強化(RELRO、PIE、BIND_NOW)とどう結びつくのかを順に追います。
前提として、実測環境について正直に書いておきます。手元はmacOS(Apple M3 Max / macOS 26.5)で、Dockerは動いていませんでした。そこでHomebrewのLLVM 21(clang と ld.lld)をx86-64 Linux向けのクロスツールチェーンとして使い、実際のELFファイルを生成して llvm-readelf / llvm-objdump / llvm-nm で観察しました。この記事に載せている readelf 系の出力は、そうして作った本物のELFから採ったものです。ただしLinuxカーネルと ld.so は手元にないため、実行時の挙動(ldd、LD_DEBUG、LD_PRELOADの効果など)の出力は「典型例」と明記し、実測値は主張しません。macOSのdyldに関する部分だけは手元で実行して確かめています。
コンパイル工程の全体像とリンカの仕事
gcc hello.c は1コマンドに見えますが、内部では4つのプログラムが順に走っています。
hello.c --(cpp: プリプロセッサ)--> hello.i (#include や #define を展開したC)
--(cc1: コンパイラ) --> hello.s (アセンブリ)
--(as: アセンブラ) --> hello.o (再配置可能オブジェクト = ELF ET_REL)
--(ld: リンカ) --> a.out (実行ファイル = ELF ET_EXEC / ET_DYN)アセンブラまでの段階では、各 .o は自分のことしか知りません。main が puts を呼んでいても、puts がどこにあるかは分からないので、呼び先のアドレスは空欄のまま出力されます。この空欄を埋めるのがリンカで、仕事は大きく2つです。
- シンボル解決(symbol resolution): 「
putsという名前の定義はどのファイルのどこか」を、全入力オブジェクトとライブラリを突き合わせて決める - 再配置(relocation): 決まったアドレスを、各オブジェクトが残しておいた「ここを埋めてくれ」という指示(再配置エントリ)に従って命令やデータに書き込む
これはIan Lance Taylor(GNU goldリンカの作者)が2007年から20回にわたって書いた "Linkers" 連載の第1回で "a linker converts object files into executables and shared libraries" と簡潔に述べている通りで、シンボル解決と再配置がリンカの本質です。
.oの中を見る - nm と readelf -r
言葉より現物です。次の小さなCプログラムを用意しました。ヘッダを使わず外部関数を自前で宣言しているのは、クロス環境でglibcのヘッダが無いためで、Linux上なら普通に stdio.h を使って構いません。
typedef int pid_t;
extern int puts(const char *s);
extern pid_t getpid(void);
extern void exit(int status);
int counter = 42; /* 初期化済み -> .data */
int uninit_buf[256]; /* 未初期化 -> .bss */
const char banner[] = "hello, linker"; /* 読み取り専用 -> .rodata */
static int helper(int x) { return x * 2; }
int main(void) {
puts(banner);
counter += helper((int)getpid());
return counter;
}
/* エントリポイント。crt1.o の代わりに自前で用意する */
void _start(void) {
exit(main());
}これを clang --target=x86_64-linux-gnu -fPIE -c hello.c -o hello.o でコンパイルし、nm でシンボル表を見ます。
$ llvm-nm hello.o
0000000000000030 T _start
0000000000000000 R banner
0000000000000000 D counter
U exit
U getpid
0000000000000000 T main
U puts
0000000000000000 B uninit_bufT はテキスト(コード)、D は初期化済みデータ、B はBSS(未初期化データ)、R は読み取り専用データに定義されたシンボルです。そして U が未定義(undefined)、つまり「このファイルには無いので、誰かが持ってきてほしい」シンボルです。static を付けた helper はインライン化されて消えていますが、残っていれば小文字の t (ローカルシンボル)として出ます。
次に、リンカへの「ここを埋めてくれ」という指示、再配置エントリを見ます。
$ llvm-readelf -r hello.o
Relocation section '.rela.text' at offset 0x1c0 contains 11 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000000004 0000000300000002 R_X86_64_PC32 0000000000000000 banner - 4
0000000000000009 0000000400000004 R_X86_64_PLT32 0000000000000000 puts - 4
000000000000000e 0000000500000004 R_X86_64_PLT32 0000000000000000 getpid - 4
0000000000000016 0000000600000002 R_X86_64_PC32 0000000000000000 counter - 4
...
0000000000000053 0000000800000004 R_X86_64_PLT32 0000000000000000 exit - 4.rela.text は「.text セクションに対する再配置」という意味です。各行は「.text のオフセット Offset にある値を、シンボル Symbol のアドレスを使って Type の計算式で書き換えよ」という指示です。R_X86_64_PC32 は「シンボルのアドレスからこの場所のアドレスを引いた相対値(32ビット)」、R_X86_64_PLT32 は「関数呼び出し用で、必要ならPLT(後述)経由に振り向けてよい」という型です。- 4 の加数(addend)は、x86-64の相対アドレスが「次の命令の先頭」基準で計算されるための補正です。
逆アセンブルに再配置を重ねて表示すると、空欄がどこにあるかがはっきりします。
$ llvm-objdump -d -r --no-show-raw-insn hello.o
0000000000000000 <main>:
0: pushq %rax
1: leaq (%rip), %rdi # 0x8 <main+0x8>
0000000000000004: R_X86_64_PC32 banner-0x4
8: callq 0xd <main+0xd>
0000000000000009: R_X86_64_PLT32 puts-0x4
d: callq 0x12 <main+0x12>
000000000000000e: R_X86_64_PLT32 getpid-0x4
12: addl %eax, %eax
14: addl (%rip), %eax # 0x1a <main+0x1a>
0000000000000016: R_X86_64_PC32 counter-0x4
1a: movl %eax, (%rip) # 0x20 <main+0x20>
000000000000001c: R_X86_64_PC32 counter-0x4
20: popq %rcx
21: retqcallq 0xd は「次の命令を呼ぶ」という意味のない値で、オペランドの4バイトが0で埋まっているだけです。leaq (%rip) も同様です。リンカはこの0を、シンボル解決の結果に基づいて正しい相対オフセットに書き換えます。これが再配置です。
ELFフォーマットの構造
Linuxのオブジェクトファイル、実行ファイル、共有ライブラリ、コアダンプは、すべてELF(Executable and Linkable Format)という一つのフォーマットです。仕様はTIS Committeeが1995年5月に公開した "Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification Version 1.2" と、それを引き継いだSystem V ABI(gABI)にあります。ELFの名前が示す通り、このフォーマットにはLinkable(リンク用の見方)とExecutable(実行用の見方)という2つの顔があります。
リンク時の見方 (Linking View) 実行時の見方 (Execution View)
+---------------------+ +---------------------+
| ELF ヘッダ | | ELF ヘッダ |
+---------------------+ +---------------------+
| プログラムヘッダ表 | (省略可) | プログラムヘッダ表 |
+---------------------+ +---------------------+
| セクション 1 | | セグメント 1 |
| セクション 2 | | (複数セクション |
| ... | | をまとめたもの) |
| セクション n | | セグメント 2 |
+---------------------+ +---------------------+
| セクションヘッダ表 | | セクションヘッダ表 | (省略可)
+---------------------+ +---------------------+- セクション(section): リンカが扱う単位。
.textや.dataなど、種類ごとに分かれた細かい区画。セクションヘッダ表で記述される - セグメント(segment): ローダ(カーネルと
ld.so)が扱う単位。「このファイルオフセットからこのバイト数を、このアドレスに、この権限でマップせよ」という粒度。プログラムヘッダ表で記述される
.o にはセクションヘッダ表だけがあり(プログラムヘッダ数は0)、実行ファイルには両方があります。strip で削れるのはセクションヘッダ側の情報で、実行には影響しません。
ELFヘッダ
ファイル先頭の64バイトがELFヘッダです。マジックナンバーから見てみます。
$ od -A x -t x1 -N 16 hello_lazy
0000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
$ od -A x -c -N 16 hello_lazy
0000000 177 E L F 002 001 001 \0 \0 \0 \0 \0 \0 \0 \0 \0先頭4バイトが 0x7f 'E' 'L' 'F'、5バイト目の 02 が ELFCLASS64(64ビット)、6バイト目の 01 が ELFDATA2LSB(リトルエンディアン)、7バイト目がELFのバージョン(1)、8バイト目がOS/ABI(0 = System V)です。仕様書のgABI第4章に定義されている通りの並びです。readelf -h はこれを人間向けに整形してくれます。
$ llvm-readelf -h hello_lazy
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x13B0
Start of program headers: 64 (bytes into file)
Start of section headers: 1944 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 20
Section header string table index: 18注目してほしいのは Type です。gABIの e_type には ET_REL(1、再配置可能)、ET_EXEC(2、実行ファイル)、ET_DYN(3、共有オブジェクト)、ET_CORE(4、コアダンプ)があります。hello.o は REL でしたが、PIE(後述)として作った実行ファイルは共有ライブラリと同じ DYN になります。「実行ファイルなのにShared object fileと表示される」のはこのためで、PIEはカーネルから見れば任意のアドレスにロードできる共有オブジェクトと同じ扱いです。比較のためPIEを無効にしてリンクした hello_nopie は EXEC (Executable file) と表示され、エントリポイントは 0x2013E0 のような固定アドレスになりました。
Size of this header: 64、Size of program headers: 56、Size of section headers: 64 はそれぞれ Elf64_Ehdr、Elf64_Phdr、Elf64_Shdr 1個のサイズで、gABIの型定義(Elf64_Addr と Elf64_Off が8バイト、Elf64_Word が4バイト、Elf64_Half が2バイト)から計算できる値と一致します。
セクションヘッダ表 - readelf -S
リンク済み実行ファイルのセクション一覧です(lldで生成)。
$ llvm-readelf -S hello_lazy
There are 20 section headers, starting at offset 0x798:
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 0000000000000238 000238 00001c 00 A 0 0 1
[ 2] .dynsym DYNSYM 0000000000000258 000258 000060 18 A 6 1 8
[ 3] .gnu.version VERSYM 00000000000002b8 0002b8 000008 02 A 2 0 2
[ 4] .gnu.version_r VERNEED 00000000000002c0 0002c0 000020 00 A 6 1 4
[ 5] .gnu.hash GNU_HASH 00000000000002e0 0002e0 00001c 00 A 2 0 8
[ 6] .dynstr STRTAB 00000000000002fc 0002fc 000028 00 A 0 0 1
[ 7] .rela.plt RELA 0000000000000328 000328 000048 18 AI 2 14 8
[ 8] .rodata PROGBITS 0000000000000370 000370 00000e 00 A 0 0 1
[ 9] .text PROGBITS 0000000000001380 000380 000057 00 AX 0 0 16
[10] .plt PROGBITS 00000000000013e0 0003e0 000040 00 AX 0 0 16
[11] .dynamic DYNAMIC 0000000000002420 000420 000100 10 WA 6 0 8
[12] .relro_padding NOBITS 0000000000002520 000520 000ae0 00 WA 0 0 1
[13] .data PROGBITS 0000000000003520 000520 000004 00 WA 0 0 4
[14] .got.plt PROGBITS 0000000000003528 000528 000030 00 WA 0 0 8
[15] .bss NOBITS 0000000000003560 000558 000400 00 WA 0 0 16
[16] .comment PROGBITS 0000000000000000 000558 00003b 01 MS 0 0 1
[17] .symtab SYMTAB 0000000000000000 000598 000108 18 19 3 8
[18] .shstrtab STRTAB 0000000000000000 0006a0 0000ab 00 0 0 1
[19] .strtab STRTAB 0000000000000000 00074b 000049 00 0 0 1主要なセクションの役割を表にまとめます。フラグの A はメモリに載る(alloc)、W は書き込み可、X は実行可です。
| セクション | 型 | 役割 |
|---|---|---|
.interp | PROGBITS | 動的リンカ(プログラムインタプリタ)のパス文字列。/lib64/ld-linux-x86-64.so.2 |
.text | PROGBITS | 機械語。読み取り+実行 |
.rodata | PROGBITS | 文字列リテラルなど読み取り専用データ |
.data | PROGBITS | 初期化済みの書き込み可能データ |
.bss | NOBITS | 未初期化データ。ファイル上に実体を持たず(サイズ0x400でも Off は次と同じ)、ロード時にゼロ埋めされる |
.symtab / .strtab | SYMTAB / STRTAB | リンク時・デバッグ用の全シンボル表と名前文字列。strip で消せる(フラグに A が無い) |
.dynsym / .dynstr | DYNSYM / STRTAB | 動的リンクに必要なシンボルだけの表。実行時に ld.so が使うので消せない |
.dynamic | DYNAMIC | ld.so への指示書。必要なライブラリ、各表のアドレスなど |
.rela.plt | RELA | PLT用の再配置(関数呼び出し先の穴埋め指示) |
.rela.dyn | RELA | それ以外の動的再配置(データのアドレスなど)。今回の例では不要だったので存在しない |
.plt | PROGBITS | Procedure Linkage Table。外部関数へのジャンプ台 |
.got / .got.plt | PROGBITS | Global Offset Table。外部シンボルの実アドレスを入れる表。.got.plt はPLT専用 |
.gnu.hash | GNU_HASH | シンボル名からの高速検索表 |
.gnu.version / .gnu.version_r | VERSYM / VERNEED | シンボルバージョン情報(後述) |
.relro_padding はlld特有のセクションで、後述するRELRO領域をページ境界まで延ばすためのものです。GNU ldで作ったバイナリには出てきません。また、GNU ldでは .init / .fini / .init_array / .fini_array(コンストラクタ・デストラクタ)、.note.*(ABIタグやビルドID)、.eh_frame(例外処理・スタック巻き戻し情報)などがさらに並びますが、今回のクロスビルドではCランタイムを一切リンクしていないため出ていません。
プログラムヘッダ表 - readelf -l
ローダが見る「セグメント」の一覧です。
$ llvm-readelf -l hello_lazy
Elf file type is DYN (Shared object file)
Entry point 0x13b0
There are 9 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0001f8 0x0001f8 R 0x8
INTERP 0x000238 0x0000000000000238 0x0000000000000238 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x00037e 0x00037e R 0x1000
LOAD 0x000380 0x0000000000001380 0x0000000000001380 0x0000a0 0x0000a0 R E 0x1000
LOAD 0x000420 0x0000000000002420 0x0000000000002420 0x000100 0x000be0 RW 0x1000
LOAD 0x000520 0x0000000000003520 0x0000000000003520 0x000038 0x000440 RW 0x1000
DYNAMIC 0x000420 0x0000000000002420 0x0000000000002420 0x000100 0x000100 RW 0x8
GNU_RELRO 0x000420 0x0000000000002420 0x0000000000002420 0x000100 0x000be0 R 0x1
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x0
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .dynsym .gnu.version .gnu.version_r .gnu.hash .dynstr .rela.plt .rodata
03 .text .plt
04 .dynamic .relro_padding
05 .data .got.plt .bss
06 .dynamic
07 .dynamic .relro_padding
08カーネルが実際に mmap するのは PT_LOAD セグメントだけです。ここでは4つあり、権限が「読み取りのみ(ヘッダ・動的リンク情報・.rodata)」「読み取り+実行(.text と .plt)」「読み書き(.dynamic)」「読み書き(.data .got.plt .bss)」に分かれています。コードとデータを別ページに置くのは、コードページを書き込み不可、データページを実行不可にするためで、GNU ldでは -z separate-code がこれに相当します。MemSiz が FileSiz より大きい最後のLOADは、差分が .bss の分でファイルからは読まずゼロ埋めされます。
セグメント単位でファイルをアドレス空間にマップする仕組みそのものは、仮想メモリとページングの仕組みで扱ったmmapとデマンドページングです。実行ファイルや共有ライブラリの .text は読み取り専用でファイルにバックされたマッピングなので、同じライブラリを使う全プロセスが物理ページを共有できます。これが「共有」ライブラリと呼ばれる理由で、ページキャッシュとの関係はファイルシステムの仕組みを参照してください。
PT_INTERP と PT_DYNAMIC と PT_GNU_RELRO と PT_GNU_STACK はロードの対象ではなく、それぞれ「動的リンカのパス」「.dynamic の位置」「再配置後に読み取り専用にする範囲」「スタックを実行可能にするか」をカーネルと ld.so に伝えるためのメタデータです。
.dynamic - ld.soへの指示書
$ llvm-readelf -d hello_lazy
Dynamic section at offset 0x420 contains 16 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000006ffffffb (FLAGS_1) PIE
0x0000000000000015 (DEBUG) 0x0
0x0000000000000017 (JMPREL) 0x328
0x0000000000000002 (PLTRELSZ) 72 (bytes)
0x0000000000000003 (PLTGOT) 0x3528
0x0000000000000014 (PLTREL) RELA
0x0000000000000006 (SYMTAB) 0x258
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000005 (STRTAB) 0x2fc
0x000000000000000a (STRSZ) 40 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x2e0
0x000000006ffffff0 (VERSYM) 0x2b8
0x000000006ffffffe (VERNEED) 0x2c0
0x000000006fffffff (VERNEEDNUM) 1
0x0000000000000000 (NULL) 0x0DT_NEEDED が「このライブラリが必要」という依存宣言で、gABIによれば複数あるときの順序に意味があります。DT_JMPREL はPLT用再配置表(.rela.plt)のアドレス、DT_PLTGOT は .got.plt のアドレス、DT_SYMTAB / DT_STRTAB は .dynsym / .dynstr のアドレスです。ld.so は .dynamic だけを見れば必要な表にすべて到達できるようになっています。他に DT_SONAME(共有ライブラリ自身の名前)、DT_RPATH / DT_RUNPATH(検索パス)、DT_INIT_ARRAY(コンストラクタ配列)、DT_FLAGS / DT_FLAGS_1(BIND_NOW などのフラグ)がよく見るタグです。
静的リンクと動的リンク
リンカはシンボル解決の結果をどう反映するかで、2つの流儀を選べます。
- 静的リンク: ライブラリのアーカイブ(
libc.a)から必要な.oを取り出し、実行ファイルの中にコピーして再配置まで済ませてしまう。実行時には外部に何も要らない - 動的リンク: 共有ライブラリ(
libc.so.6)の中のシンボルは「実行時にここから持ってくる」という印(DT_NEEDEDと動的再配置)だけを残し、実際の解決は実行時に動的リンカへ委ねる
| 観点 | 静的リンク | 動的リンク |
|---|---|---|
| ファイルサイズ | 使う関数の分だけライブラリを抱えるので大きい | 小さい。ライブラリ本体はシステム側 |
| メモリ | プロセスごとにライブラリのコピーを持つ | .text の物理ページを全プロセスで共有 |
| 起動 | 再配置済みなので速い | ld.so がライブラリを探し、マップし、再配置する時間がかかる |
| セキュリティ更新 | ライブラリの脆弱性修正には再ビルド・再配布が必要 | libc.so.6 を差し替えれば全プログラムに効く |
| 配布・可搬性 | glibcのバージョン差に悩まされない。コンテナの scratch イメージにも1ファイルで置ける | 依存ライブラリと同じsoname・シンボルバージョンが実行先に必要 |
| フック・差し替え | LD_PRELOAD は効かない | LD_PRELOAD や dlopen で挙動を差し替えられる |
GCCでは -static で静的リンク、既定で動的リンクです。GCCマニュアルの -static の説明は "On systems that support dynamic linking, this overrides -pie and prevents linking with the shared libraries." で、GNU ldの -Bstatic / -static は "Do not link against shared libraries" です。-static-pie を使うと "A static position independent executable is similar to a static executable, but can be loaded at any address without a dynamic linker."、つまり動的リンカなしにASLRの恩恵を受ける静的バイナリが作れます。
glibcを静的リンクするときの落とし穴 - NSS
glibcを -static でリンクすると、getaddrinfo や getpwnam を使っているプログラムで次のような警告が出ます。
warning: Using 'getaddrinfo' in statically linked applications requires at runtime
the shared libraries from the glibc version used for linkingこれはglibcのソース include/libc-symbols.h で static_link_warning マクロとして定義されている文言で、nss/nsswitch.h の nss_interface_function マクロを経由して getaddrinfo などのNSS(Name Service Switch)系関数に付けられています。NSSは /etc/nsswitch.conf の設定に従って libnss_files.so や libnss_dns.so を実行時に dlopen する仕組みなので、本体を静的にリンクしても名前解決のところだけは結局共有ライブラリが必要になり、しかもリンク時と同じglibcバージョンのものでなければならない、という警告です。「静的リンクしたのにglibcに依存する」という一見矛盾した状況はここから来ます。完全に自己完結したバイナリが欲しいなら、NSSを持たないmuslなどのlibcを使うのが定番の回避策です。
依存関係を調べる - ldd と objdump -p
動的リンクされたバイナリが何を必要としているかは ldd で見られます。次は ldd(1) のmanページに載っている例です(手元では実行できないため、manの例をそのまま引用しています)。
$ ldd /bin/ls
linux-vdso.so.1 (0x00007ffcc3563000)
libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f87e5459000)
libcap.so.2 => /lib64/libcap.so.2 (0x00007f87e5254000)
libc.so.6 => /lib64/libc.so.6 (0x00007f87e4e92000)
...
/lib64/ld-linux-x86-64.so.2 (0x00005574bf12e000)linux-vdso.so.1 はディスク上に存在しないファイルで、vdso(7) によればカーネルが全プロセスのアドレス空間へ自動的にマップする小さな共有ライブラリです。gettimeofday や clock_gettime をシステムコールなしで済ませるためのもので、x86-64では __vdso_clock_gettime、__vdso_getcpu、__vdso_gettimeofday、__vdso_time を提供します。
ldd には重要な注意があります。manページの "Security" 節に "you should never employ ldd on an untrusted executable, since this may result in the execution of arbitrary code." とある通り、ldd は動的リンカに LD_TRACE_LOADED_OBJECTS=1 を設定してプログラムを起動する実装であり、細工されたインタプリタを指定したバイナリではコードが実行されてしまいます。信頼できないバイナリには objdump -p /path/to/program | grep NEEDED を使うことがmanで推奨されています(ただし直接の依存しか見えません)。
動的リンカ ld.so の動作
いよいよ実行時です。fork と execve でプロセスが生まれる流れは並行と並列・プロセスとスレッドで触れましたが、execve されたELFをカーネルがどう扱うかは、LWNの "How programs get run: ELF binaries"(2015年2月4日)が load_elf_binary() を追って説明しています。流れは次の通りです。
- カーネルがELFヘッダとプログラムヘッダを読み、
PT_LOADをアドレス空間へマップする PT_INTERPがあれば、そこに書かれたパス(/lib64/ld-linux-x86-64.so.2)のELFも同様にマップする- スタックに引数・環境変数・補助ベクタ(auxiliary vector)を積む。
getauxval(3)によればAT_PHDR(実行ファイルのプログラムヘッダのアドレス)、AT_ENTRY(実行ファイルのエントリポイント)、AT_BASE(インタプリタのベースアドレス)、AT_SECURE(後述)、AT_SYSINFO_EHDR(vDSOのアドレス)、AT_RANDOM(16バイトの乱数)などが入る - 制御を実行ファイルの
e_entryではなくインタプリタのエントリポイントへ渡す
つまり、動的リンクされたプログラムで最初に走るのは main でも _start でもなく ld.so です。ld.so(8) の言葉を借りれば、動的リンカは "find and load the shared objects (shared libraries) needed by a program, prepare the program to run, and then run it" を行います。依存ライブラリを探してマップし、再配置を適用し、コンストラクタ(DT_INIT / DT_INIT_ARRAY)を依存順に呼んでから、AT_ENTRY の _start へジャンプします。gABIには "Before the initialization functions for any object A is called, the initialization functions for any other objects that object A depends on are called." とあり、初期化順は依存グラフで決まります。
ld.so は自分自身を直接起動することもでき、/lib64/ld-linux-x86-64.so.2 --list ./prog で依存関係を表示したり、--preload(glibc 2.30以降)でプリロードを指定したりできます。
ライブラリの検索順序
DT_NEEDED に書かれた名前(たとえば libc.so.6)にスラッシュが含まれない場合、ld.so(8) の記述によれば次の順で探します。ここはman ld.soの記述を正としています。
- バイナリの
DT_RPATHに書かれたディレクトリ。ただしDT_RUNPATHが存在しない場合のみ - 環境変数
LD_LIBRARY_PATH。ただしsecure-executionモードでは無視される - バイナリの
DT_RUNPATHに書かれたディレクトリ。DT_RUNPATHは直接の依存(DT_NEEDED)の検索にしか使われず、その依存のさらに依存には適用されない。DT_RPATHは依存ツリー全体に適用されるので、この点が違う /etc/ld.so.cache。ldconfig(8)が/etc/ld.so.confと信頼ディレクトリ(/lib、/usr/lib)から作る候補リストのキャッシュ- 既定パス
/lib、次いで/usr/lib(64ビットライブラリを使う構成では/lib64、/usr/lib64)
DT_RPATH はgABIで "Level 2" とされ DT_RUNPATH に取って代わられた古いタグですが、GNU ldは互換性のため既定では古い方を出力します。ld(1) の --enable-new-dtags の説明には "By default, the new dynamic tags are not created." とあり、-rpath に DT_RUNPATH を使いたければ -Wl,--enable-new-dtags を付ける必要があります(ディストリビューションによってはgccのspecsで既定にしている場合があります。手元で確認できないため未確認です)。DT_RPATH が LD_LIBRARY_PATH より優先されるのに対し DT_RUNPATH は劣後する、という違いは、上の順序を見れば読み取れます。
パスの中では $ORIGIN(実行ファイルまたは共有ライブラリのあるディレクトリ)、$LIB(lib または lib64)、$PLATFORM というトークンが使えます。-Wl,-rpath,'$ORIGIN/../lib' は「実行ファイルの隣の lib から探す」という意味で、配布物を自己完結させる常套手段です。シェルに展開されないようシングルクォートで囲む点に注意してください。
sonameと-lのルール、ldconfig
-lfoo と書いたとき、GNU ldは "will search a directory for a library called libnamespec.so before searching for one called libnamespec.a"、つまり libfoo.so を先に、なければ libfoo.a を探します。-Bstatic の後ろでは .a だけを探します。
一方、実行時に ld.so が探す名前は、-l に書いた名前ではなく、共有ライブラリの DT_SONAME です。ld(1) の -soname の説明にある通り、"when the executable is run the dynamic linker will attempt to load the shared object specified by the DT_SONAME field rather than using the file name given to the linker" です。ここから、Linuxの共有ライブラリの3層構造が生まれます。
libfoo.so -> libfoo.so.1 (開発用リンク名。リンク時に -lfoo が見る)
libfoo.so.1 -> libfoo.so.1.12 (soname。実行時に ld.so が DT_NEEDED から探す)
libfoo.so.1.12 (実体。実名。sonameはこの中に埋め込まれている)ldconfig(8) は "creates the necessary links and cache to the most recent shared libraries found in the directories specified on the command line, in the file /etc/ld.so.conf, and in the trusted directories, /lib and /usr/lib" で、sonameから中間のシンボリックリンクを作り、/etc/ld.so.cache を更新します。ライブラリを /usr/local/lib に入れたのに見つからないときは ldconfig を実行し忘れているか、/etc/ld.so.conf.d/ にディレクトリが登録されていないのが定番の原因です。ldconfig -p でキャッシュの中身を確認できます。
sonameの数字はABIのバージョンです。互換性を壊す変更をしたら libfoo.so.2 に上げ、古いプログラムには libfoo.so.1 を残す、という運用によって、同じ名前のライブラリの複数世代を共存させています。
シンボルの検索順序と挿入(interposition)
依存ライブラリがすべてマップされると、ld.so はそれらをリンクマップ(link map)という一本のリストにつなぎます。順序は「実行ファイル自身、LD_PRELOAD で指定されたもの、DT_NEEDED の幅優先順」です。未定義シンボルを解決するときは、このリストを先頭から順に走査し、最初に見つかった定義を採用します。
この「先頭から最初に見つかったもの」というルールが、ELFの動的リンクの重要な性質interposition(挿入・横取り)を生みます。実行ファイルや先にロードされたライブラリが malloc を定義していれば、libc.so.6 の中のコードが malloc を呼んだときでさえ、そちらが使われます(libc 自身が -Bsymbolic や隠し別名で内部参照を固定している場合を除く)。LD_PRELOAD は、このリストの実行ファイル直後という特等席にライブラリを差し込む仕組みにほかなりません。
解決の様子は LD_DEBUG で観察できます。ld.so(8) によれば値には libs(ライブラリ検索パス)、bindings(各シンボルがどの定義に束縛されたか)、symbols(シンボルごとの検索パス)、reloc(再配置処理)、files、scopes、statistics、versions、unused、all があり、LD_DEBUG=help で一覧が出ます。
# どのライブラリをどこから見つけたか(Linux)
LD_DEBUG=libs ./prog
# どのシンボルがどのオブジェクトの定義に束縛されたか
LD_DEBUG=bindings ./prog 2>&1 | grep malloc
# 標準エラーではなくファイルへ(PIDが付く)
LD_DEBUG=all LD_DEBUG_OUTPUT=/tmp/ld.log ./progPLT/GOTと遅延バインディング
ここが動的リンクの中で一番面白い部分です。問題設定はこうです。main は puts を呼びたいが、puts のアドレスは実行時に libc.so.6 がマップされるまで分からない。しかも .text は読み取り専用で全プロセス共有なので、call 命令の中に直接アドレスを書き込みたくない(書き込むとそのページはプロセス専用のコピーになり、共有できなくなる)。
Eli Benderskyの "Load-time relocation of shared libraries" は、まさにこの「コードに直接再配置を当てる方式(テキスト再配置)」の問題点として、起動時の遅さと .text が共有できなくなることの2つを挙げています。続編の "Position Independent Code (PIC) in shared libraries" が示す解決策が、再配置を書き込み可能なデータ領域(GOT)に集約することと、そのGOTを介して間接的に呼び出すPLTです。
実物のPLTを読む
先ほどのバイナリの .plt を逆アセンブルします。
$ llvm-objdump -d --no-show-raw-insn -j .plt hello_lazy
Disassembly of section .plt:
00000000000013e0 <.plt>:
13e0: pushq 0x214a(%rip) # 0x3530 (= GOT[1])
13e6: jmpq *0x214c(%rip) # 0x3538 (= GOT[2])
13ec: nopl (%rax)
00000000000013f0 <puts@plt>:
13f0: jmpq *0x214a(%rip) # 0x3540 (= GOT[3])
13f6: pushq $0x0
13fb: jmp 0x13e0 <.plt>
0000000000001400 <getpid@plt>:
1400: jmpq *0x2142(%rip) # 0x3548 (= GOT[4])
1406: pushq $0x1
140b: jmp 0x13e0 <.plt>
0000000000001410 <exit@plt>:
1410: jmpq *0x213a(%rip) # 0x3550 (= GOT[5])
1416: pushq $0x2
141b: jmp 0x13e0 <.plt>そして main 側は、リンク後には puts ではなく puts@plt を呼ぶよう書き換えられています。
$ llvm-objdump -d --no-show-raw-insn hello_lazy | sed -n '/<main>/,/retq/p'
0000000000001380 <main>:
1380: pushq %rax
1381: leaq -0x1018(%rip), %rdi # 0x370 <banner>
1388: callq 0x13f0 <puts@plt>
138d: callq 0x1400 <getpid@plt>
....o の段階で0だった callq のオペランドは、リンカによって puts@plt への相対オフセットに再配置されました。R_X86_64_PLT32 という再配置型は「PLTエントリを指してよい」という意味だったわけです。
次に .got.plt の中身です。
$ llvm-readelf -x .got.plt hello_lazy
Hex dump of section '.got.plt':
0x00003528 20240000 00000000 00000000 00000000 GOT[0]=0x2420 GOT[1]=0
0x00003538 00000000 00000000 f6130000 00000000 GOT[2]=0 GOT[3]=0x13f6
0x00003548 06140000 00000000 16140000 00000000 GOT[4]=0x1406 GOT[5]=0x1416x86-64 psABIで .got.plt の先頭3エントリは予約されています。MaskRayの "All about Procedure Linkage Table" の説明に沿えば、GOT[0] は _DYNAMIC(.dynamic セクション)のリンク時アドレス、GOT[1] はこのオブジェクトの link_map へのポインタ、GOT[2] は ld.so のリゾルバ(_dl_runtime_resolve)のアドレスです。実際 GOT[0] の 0x2420 は .dynamic のアドレスと一致しています。GOT[1] と GOT[2] はファイル上では0で、ld.so が起動時に埋めます。
そして GOT[3](puts 用)の初期値 0x13f6 に注目してください。これは puts@plt の2番目の命令(pushq $0x0)のアドレスです。ここが遅延バインディングの仕掛けです。
初回呼び出しの流れ
main: callq puts@plt
|
v
puts@plt: jmpq *GOT[3] ---- GOT[3] はまだ 0x13f6 (自分の次の命令) を指す
pushq $0 <--- ここへ戻ってくる。0 = .rela.plt の何番目か
jmp .plt (PLT0)
|
v
PLT0: pushq GOT[1] ---- link_map をスタックへ
jmpq *GOT[2] ---- _dl_runtime_resolve へ
|
v
_dl_runtime_resolve (ld.so, sysdeps/x86_64/dl-trampoline.S)
レジスタを退避して _dl_fixup(link_map, reloc_index) を呼ぶ
|
v
_dl_fixup (ld.so, elf/dl-runtime.c)
.rela.plt[0] を読む -> シンボル "puts" を _dl_lookup_symbol_x で検索
-> 見つけた実アドレスを GOT[3] に書き込む (elf_machine_fixup_plt)
-> そのアドレスを返す
|
v
_dl_runtime_resolve: レジスタを復元して、見つけたアドレスへジャンプ
|
v
libc.so.6 の puts 本体が実行され、main へ ret で戻る2回目以降の呼び出しでは GOT[3] に puts の実アドレスが入っているので、jmpq *GOT[3] が直接本体へ飛びます。間接ジャンプ1回分のコストで済み、リゾルバは二度と通りません。Eli Benderskyの記事の言葉では "now func is being actually called, without going through the resolver, at the cost of one additional jump" です。
glibcのソースで裏を取ると、sysdeps/x86_64/dl-trampoline.S は dl-trampoline.h を複数回インクルードして、CPUの機能に応じて _dl_runtime_resolve_fxsave、_dl_runtime_resolve_xsave、_dl_runtime_resolve_xsavec という3つの変種を生成しています(呼び出し規約上保存すべきベクタレジスタの退避方法が違う)。そこから呼ばれる elf/dl-runtime.c の _dl_fixup が _dl_lookup_symbol_x でシンボルを検索し、最後に elf_machine_fixup_plt でGOTを書き換えて値を返す、という構造です。
このように、関数の解決を「初めて呼ばれたとき」まで先送りするのが遅延バインディング(lazy binding)です。プログラムが持つ何百もの外部関数のうち実際に呼ばれるのは一部なので、起動時に全部解決するより速く始められる、というのが動機です。GNU ldでは -z lazy が既定で("Lazy binding is the default.")、-z now を指定するか環境変数 LD_BIND_NOW を設定すると、ld.so は起動時に全シンボルを解決します。.rela.plt の各エントリの型が R_X86_64_JUMP_SLOT なのは、「これはPLTのジャンプ先スロットなので、遅延解決してもよい」ことを示す印です。
$ llvm-readelf -r hello_lazy
Relocation section '.rela.plt' at offset 0x328 contains 3 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000003540 0000000100000007 R_X86_64_JUMP_SLOT 0000000000000000 puts@GLIBC_2.2.5 + 0
0000000000003548 0000000200000007 R_X86_64_JUMP_SLOT 0000000000000000 getpid@GLIBC_2.2.5 + 0
0000000000003550 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 exit@GLIBC_2.2.5 + 0Offset の 0x3540 0x3548 0x3550 は、まさに GOT[3] GOT[4] GOT[5] のアドレスです。
データ用のGOTとPIC
関数だけでなく、共有ライブラリの外にあるグローバル変数(たとえば errno や stdout)へのアクセスもGOTを通ります。Ian Lance Taylorの連載第4回の説明では "the compiler will generate a load from the GOT to get the address of the variable, followed by a second load to get the actual value of the variable"、つまり「GOTからアドレスを読む、そのアドレスから値を読む」の2段階です。こちらは関数と違って遅延できないので、R_X86_64_GLOB_DAT 型の再配置として起動時に ld.so が .got に書き込みます。
自分自身のデータ(今回の counter や banner)は、x86-64では %rip 相対アドレッシングで直接触れます。逆アセンブルの leaq -0x1018(%rip), %rdi がそれで、コードとデータの相対距離はリンク時に確定しているため、どこにロードされても再配置なしで動きます。これが位置独立コード(PIC、Position Independent Code)で、-fPIC(共有ライブラリ用)や -fPIE(実行ファイル用)が生成するコードの正体です。
なお、GCCの -fno-plt を使うとPLTを経由せず call *GOT[n](%rip) を直接生成し、間接ジャンプを1つ減らせますが、遅延バインディングはできなくなります(BIND_NOW前提)。またIntel CETの間接分岐追跡(IBT)が有効な環境では、GNU ldはPLTを .plt と .plt.sec の2つに分けます。endbr64 命令が4バイトあって16バイトのPLTエントリに収まらないため、という理由がMaskRayの記事に書かれています。Ubuntuなどで objdump -d すると puts@plt が .plt.sec にあるのはこのためです。
LD_PRELOADでフックする
シンボルの検索順序とinterpositionを理解すると、LD_PRELOAD は自明な仕組みに見えてきます。ld.so(8) の定義は "A list of additional, user-specified, ELF shared objects to be loaded before all others. This feature can be used to selectively override functions in other shared objects." です。リンクマップの先頭近くに自分のライブラリを差し込み、そこに getpid や malloc を定義しておけば、他のライブラリからの呼び出しもそちらへ束縛されます。
最小の例です。getpid を差し替えて常に12345を返させます。
#include <unistd.h>
pid_t getpid(void) {
return 12345;
}# Linuxでの手順(手元では実行環境が無いため、出力は典型例)
gcc -shared -fPIC -o libfakepid.so fakepid.c
./hello # pid=4242 のような本物のPID
LD_PRELOAD=./libfakepid.so ./hello # pid=12345本物の関数を呼びつつ前後に処理を挟みたい場合は、dlsym(RTLD_NEXT, ...) を使います。dlsym(3) の説明では、RTLD_NEXT は "Find the next occurrence of the desired symbol in the search order after the current object." で、"the definition of a function in a preloaded shared object (see LD_PRELOAD in ld.so(8)) can find and invoke the 'real' function provided in another shared object" とプリロード用途が明記されています。RTLD_NEXT を使うには _GNU_SOURCE の定義が必要です。
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stddef.h>
#include <unistd.h>
static void *(*real_malloc)(size_t) = NULL;
void *malloc(size_t size) {
if (real_malloc == NULL) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
void *p = real_malloc(size);
/* printf は内部で malloc を呼ぶので再帰する。write を使う */
static const char msg[] = "malloc called\n";
write(2, msg, sizeof msg - 1);
return p;
}gcc -shared -fPIC -o libmallocspy.so mallocspy.c
LD_PRELOAD=./libmallocspy.so lsprintf を使わず write にしているのは、printf 自身が malloc を呼んで無限再帰になるためで、malloc フックの定番の落とし穴です。dlsym も内部でメモリを確保することがあるので、本格的なフックでは初期化中の再帰対策が要ります。実務では、この仕組みでjemallocやtcmallocに丸ごと差し替えたり、ASanのような検出器を挟んだりします。アロケータの差し替えについてはメモリアロケータ入門で扱っています。
LD_PRELOAD 以外に、ld.so --preload オプション(glibc 2.30以降、そのプロセスだけに効き子プロセスに継承されない)と、システム全体に効く /etc/ld.so.preload ファイルがあります。manによれば、両方指定されたときは LD_PRELOAD の方が先にプリロードされます。
setuidバイナリでは無視される - secure-executionモード
LD_PRELOAD で任意のコードを差し込めるなら、sudo や passwd のようなsetuidバイナリに対して使えば特権昇格できてしまいます。当然これは塞がれています。ld.so(8) はsecure-executionモードを次のように定義しています。
A binary is executed in secure-execution mode if the AT_SECURE entry in the auxiliary vector has a nonzero value.
AT_SECURE はカーネルが補助ベクタで渡す値で、getauxval(3) によれば "Most commonly, a nonzero value indicates that the process is executing a set-user-ID or set-group-ID binary (so that its real and effective UIDs or GIDs differ from one another), or that it gained capabilities by executing a binary file that has capabilities." です。Linux Security Moduleが立てる場合もあります。
このモードでは LD_LIBRARY_PATH、LD_AUDIT、LD_DEBUG(/etc/suid-debug が存在する場合を除く)、LD_PROFILE などが無視され、LD_PRELOAD は完全には無視されないものの、実質的に無力化されます。
In secure-execution mode, preload pathnames containing slashes are ignored. Furthermore, shared objects are preloaded only from the standard search directories and only if they have set-user-ID mode bit enabled (which is not typical).
つまり「パスを含む指定は捨てられる」「標準ディレクトリにあり、かつsetuidビットの付いた共有ライブラリしかプリロードされない」ので、ユーザが自作したライブラリを差し込む余地はありません。ちなみに、判定をユーザ空間の getuid() != geteuid() ではなくカーネルからの AT_SECURE に頼るのは、ケーパビリティやLSMなどUID比較では捕まえられない条件を統一的に扱うためです。
セキュリティ強化 - RELRO・BIND_NOW・PIE・noexecstack
ここまでの仕組みには、攻撃者から見ると魅力的な標的があります。GOTです。GOTは「関数ポインタの配列」であり、遅延バインディングのために書き込み可能で、しかも実行ファイルの中の位置がリンク時に決まっています。Red Hatの記事 "Hardening ELF binaries using Relocation Read-Only (RELRO)"(Huzaifa Sidhpurwala、2019年1月28日)が指摘するように、任意アドレス書き込みの脆弱性が一つあれば、GOT[puts] を system のアドレスに書き換えるだけで、次に puts が呼ばれた瞬間に攻撃者のコードへ制御が移ります。これがGOT上書き攻撃です。
RELRO(partial / full)
対策は素朴で、「再配置が終わったらGOTを読み取り専用にしてしまう」ことです。GNU ldの -z relro の説明は "Create an ELF PT_GNU_RELRO segment header in the object. This specifies a memory segment that should be made read-only after relocation" で、ld.so は再配置を適用した後、PT_GNU_RELRO の範囲に mprotect(PROT_READ) をかけます。
ただし遅延バインディングを使う限り、.got.plt は実行中に書き換わり続けるので読み取り専用にできません。ここで2段階が生まれます。
- Partial RELRO(
-z relroのみ):.dynamicやデータ用の.gotなどを読み取り専用にするが、.got.pltは書き込み可能なまま残る。Red Hatの記事の表現では "the non-PLT part of the GOT section (.got from readelf output) is read only but .got.plt is still writeable" - Full RELRO(
-z relro -z now):BIND_NOWで起動時に全関数を解決してしまい、.got.pltもRELRO領域に含めて読み取り専用にする。"the entire GOT (.got and .got.plt both) is marked as read-only"
GOT上書き攻撃を防げるのはFull RELROだけです。代償は起動時間で、main に入る前に全JUMP_SLOTを解決するぶん遅くなります。
手元で作った2つのバイナリで実際に違いを見てみます。まずlazy(-z relro -z lazy)のほうです。
$ llvm-readelf -l hello_lazy | grep -E 'LOAD|GNU_RELRO'
LOAD 0x000000 0x0000000000000000 ... R 0x1000
LOAD 0x000380 0x0000000000001380 ... R E 0x1000
LOAD 0x000420 0x0000000000002420 ... RW 0x1000
LOAD 0x000520 0x0000000000003520 ... RW 0x1000
GNU_RELRO 0x000420 0x0000000000002420 0x0000000000002420 0x000100 0x000be0 R 0x1
$ llvm-readelf -S hello_lazy | grep -E 'dynamic|got'
[11] .dynamic DYNAMIC 0000000000002420 ...
[14] .got.plt PROGBITS 0000000000003528 ...GNU_RELRO は 0x2420 から 0xbe0 バイト、つまり 0x3000 まで(ページ境界)を覆っていますが、.got.plt は 0x3528 にあってその外側です。次に -z relro -z now のほうです。
$ llvm-readelf -d hello_now | grep -E 'FLAGS'
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) NOW PIE
$ llvm-readelf -l hello_now | grep GNU_RELRO
GNU_RELRO 0x000420 0x0000000000002420 0x0000000000002420 0x000140 0x000be0 R 0x1
$ llvm-readelf -S hello_now | grep -E 'dynamic|got'
[11] .dynamic DYNAMIC 0000000000002420 ...
[12] .got.plt PROGBITS 0000000000002530 ....dynamic に DT_FLAGS の BIND_NOW と DT_FLAGS_1 の NOW が立ち、.got.plt がリンカによって 0x2530 へ、つまりRELRO範囲の内側へ移されています。ld.so はこれを見て、起動時に全部解決してからGOTごと読み取り専用にします。-z norelro で作った hello_norelro には GNU_RELRO セグメントがそもそもありません。
checksec というツールは、このあたりを一覧にしてくれます。READMEにある出力例は次の通りです(現行版はbashからGoに書き直され、3.0.1がリリースされています)。
$ checksec file /bin/ls
RELRO Stack Canary NX PIE RPATH RUNPATH
Partial RELRO Canary Found NX enabled PIE Enabled No RPATH No RUNPATHchecksec が無くても、readelf -l で GNU_RELRO の有無、readelf -d で BIND_NOW の有無を見れば判定できます。両方あればFull、GNU_RELRO だけならPartialです。
PIEとASLR
ET_EXEC の実行ファイルは固定アドレス(手元のlldで作った hello_nopie では 0x200000)にロードされます。攻撃者にとっては「GOTはどこにあるか」「system へのガジェットはどこか」が既知になるということです。PIE(Position Independent Executable)は実行ファイル自体を ET_DYN として作り、共有ライブラリと同様に毎回ランダムな位置へロードできるようにします。GNU ldの -pie は "They are marked ET_DYN in the ELF file header, but differ from shared libraries in a number of ways." と説明しています。
ランダム化そのものはカーネルのASLR(Address Space Layout Randomization)の仕事で、/proc/sys/kernel/randomize_va_space で制御します。カーネル文書によれば 1 で "the addresses of mmap base, stack and VDSO page randomized. This, among other things, implies that shared libraries will be loaded to random addresses. Also for PIE-linked binaries, the location of code start is randomized."、2 でさらにヒープ(brk)もランダム化され、CONFIG_COMPAT_BRK が無効なカーネルでは2が既定です。PIEでないバイナリは、ASLRが有効でも本体の位置だけは固定のままです。
実行不可スタックとその他
PT_GNU_STACK セグメントはスタックの権限を表し、readelf -l の出力で GNU_STACK ... RW なら実行不可、RWE なら実行可能です。GNU ldの -z noexecstack は "Marks the object as not requiring executable stack" で、逆に入力オブジェクトのどれかが .note.GNU-stack を持たない(古いアセンブラで作ったなど)と、リンカは安全側に倒して実行可能スタックを要求することがあります。checksec の "NX" 列はこれを見ています。
Ubuntuは早くからこれらを既定にしており、Ubuntu Wikiの "ToolChain/CompilerFlags" によれば、-Wl,-z,relro は8.10から、-fstack-protector-strong は14.10から、-Wl,-z,now と PIE(-fPIE / -Wl,-pie)は16.10から、-fcf-protection は19.10から(x86)、-D_FORTIFY_SOURCE=3 は24.04から既定です。つまり、現在のUbuntuで普通に gcc でビルドしたバイナリはFull RELROかつPIEになっています。他のディストリビューションについては未確認です。
まとめると、フラグと効果は次の通りです。
| フラグ | 効果 | 確認方法 |
|---|---|---|
-Wl,-z,relro | PT_GNU_RELRO を作り、再配置後に .got 等を読み取り専用化(Partial) | readelf -l に GNU_RELRO |
-Wl,-z,now | DT_FLAGS に BIND_NOW。遅延バインディングを無効化。relroと併用でFull RELRO | readelf -d に BIND_NOW |
-fPIE -pie | ET_DYN の実行ファイル。ASLRで本体もランダム配置 | readelf -h の Type が DYN |
-Wl,-z,noexecstack | スタックを実行不可に | readelf -l の GNU_STACK に E が無い |
-fstack-protector-strong | スタックカナリア(リンカではなくコンパイラの機能) | __stack_chk_fail がシンボルにある |
シンボルバージョニング - GLIBC_2.34 not foundの正体
新しいマシンでビルドしたバイナリを古いマシンで動かすと、こういうエラーが出ます。
./prog: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./prog)この文言はglibcの elf/dl-version.c に "version %s' not found (required by %s)"として実装されています。何が起きているのかを、先ほどのバイナリのreadelf -V` で見てみます。
$ llvm-readelf -V hello_lazy
Version symbols section '.gnu.version' contains 4 entries:
000: 0 (*local*) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5) 2 (GLIBC_2.2.5)
Version needs section '.gnu.version_r' contains 1 entries:
0x0000: Version: 1 File: libc.so.6 Cnt: 1
0x0010: Name: GLIBC_2.2.5 Flags: none Version: 2
$ llvm-readelf --dyn-syms hello_lazy
Num: Value Size Type Bind Vis Ndx Name
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND getpid@GLIBC_2.2.5
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND exit@GLIBC_2.2.5ELFのシンボルには、GNU拡張として名前に加えてバージョンを付けられます。MaskRayの "All about symbol versioning" の整理に従うと、3つのセクションが関わります。
.gnu.version(DT_VERSYM):.dynsymと並行な表で、各シンボルのバージョンIDを持つ.gnu.version_r(DT_VERNEED): 未定義シンボルが「どのファイルのどのバージョン」を必要とするか.gnu.version_d(DT_VERDEF): 定義側が提供するバージョンの一覧
実行ファイルは「libc.so.6 の GLIBC_2.2.5 にある puts が欲しい」と記録しており、ld.so はロード時に libc.so.6 の .gnu.version_d にそのバージョンが定義されているかを照合します。無ければ上のエラーです。今回の例ではダミーの libc.so.6 をバージョンスクリプトで作りました。
GLIBC_2.2.5 { global: puts; exit; getpid; local: *; };
GLIBC_2.34 { global: pthread_create; };$ llvm-readelf -V libc.so.6
Version definition section '.gnu.version_d' contains 3 entries:
0x0000: Rev: 1 Flags: BASE Index: 1 Cnt: 1 Name: libc.so.6
0x001c: Rev: 1 Flags: none Index: 2 Cnt: 1 Name: GLIBC_2.2.5
0x0038: Rev: 1 Flags: none Index: 3 Cnt: 1 Name: GLIBC_2.34
$ llvm-readelf --dyn-syms libc.so.6
1: 0000000000001340 3 FUNC GLOBAL DEFAULT 7 puts@@GLIBC_2.2.5
2: 0000000000001350 6 FUNC GLOBAL DEFAULT 7 getpid@@GLIBC_2.2.5
3: 0000000000001360 2 FUNC GLOBAL DEFAULT 7 exit@@GLIBC_2.2.5
4: 0000000000001370 3 FUNC GLOBAL DEFAULT 7 pthread_create@@GLIBC_2.34@@ は「既定バージョン」の印で、新しくリンクするプログラムはこのバージョンに束縛されます。@ 1つは既定でない古いバージョンで、既存バイナリの互換性のためだけに残されます。この仕組みによってglibcは、同じ realpath という名前で挙動の違う2つの実装を同居させ、古いプログラムには古い方を、新しいプログラムには新しい方を提供できます。
では、なぜ GLIBC_2.34 がこれほど頻繁に問題になるのでしょうか。2021年8月2日にリリースされたglibc 2.34のNEWSにこうあります。
In order to support smoother in-place-upgrades and to simplify the implementation of the runtime all functionality formerly implemented in the libraries libpthread, libdl, libutil, libanl has been integrated into libc. New applications do not need to link with -lpthread, -ldl, -lutil, -lanl anymore.
pthread_create や dlopen が libc.so.6 の中へ移り、その際にバージョン GLIBC_2.34 が付けられました。2.34以降の環境でリンクすると、スレッドや dlopen を使うほとんどのプログラムが GLIBC_2.34 を要求するようになり、それ以前のglibc(たとえばUbuntu 20.04のglibcは2.31)では動かなくなった、というわけです。対策は「配布先で想定する最古のglibcを持つ環境でビルドする」(manylinuxコンテナはこの発想です)か、静的リンクやmuslでglibcへの依存を断つことです。
macOSとWindowsとの対比
ELF以外の世界も、同じ問題を別の形で解いています。
macOS - Mach-O と dyld
macOSの実行形式はMach-Oで、動的リンカは /usr/lib/dyld です。手元のMacで先ほどの getpid を表示するだけのプログラムをビルドして観察しました。
$ file hello
hello: Mach-O 64-bit executable arm64
$ otool -hv hello
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 17 1056 NOUNDEFS DYLDLINK TWOLEVEL PIE
$ otool -l hello | grep -A2 LC_LOAD_DYLINKER
cmd LC_LOAD_DYLINKER
cmdsize 32
name /usr/lib/dyld (offset 12)
$ otool -L hello
hello:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
$ nm -m hello | grep undefined
(undefined) external _getpid (from libSystem)
(undefined) external _printf (from libSystem)ELFのセクション/セグメント/プログラムヘッダに相当するのがMach-Oのロードコマンドで、LC_LOAD_DYLINKER が PT_INTERP、LC_LOAD_DYLIB が DT_NEEDED に対応します。フラグの TWOLEVEL が重要で、macOSの既定は2レベル名前空間、つまり「_getpid は libSystem から」というように、未定義シンボルごとにどのライブラリから取るかまで記録します。ELFのように「リンクマップを先頭から探して最初に見つかったもの」ではないので、ELF流のinterpositionは既定では起きません。
その帰結を実験で確かめました。getpid を差し替えるdylibを作り、LD_PRELOAD に相当する DYLD_INSERT_LIBRARIES で挿入します。
$ cc -O2 -dynamiclib -o libfakepid.dylib fakepid.c
$ ./hello
pid=46283
$ DYLD_INSERT_LIBRARIES=./libfakepid.dylib ./hello
pid=46287 # 効かない(2レベル名前空間で libSystem に束縛済み)
$ DYLD_FORCE_FLAT_NAMESPACE=1 DYLD_INSERT_LIBRARIES=./libfakepid.dylib ./hello
pid=12345 # フラット名前空間を強制すると差し替わるmacOSの ld のmanページによれば、-flat_namespace は "With -two_levelnamespace (the default), the linker only searches dylibs on the command line for symbols, and records in which dylib they were found. With -flat_namespace, the linker searches all dylibs ... At runtime, dyld will search each dynamic library in load order when resolving symbols. This is slower, but more like how other operating systems resolve symbols." と説明されており、まさにELF流の検索に切り替えるスイッチです。
dyld のmanページでは、DYLD_INSERT_LIBRARIES は "a colon separated list of additional dynamic libraries to load before the ones specified in the program" と定義されています。そしてsetuidに相当する制限として、AppleのSystem Integrity Protectionガイドに "Any dynamic linker (dyld) environment variables, such as DYLD_LIBRARY_PATH, are purged when launching protected processes." とあり、システムバイナリや特定のエンタイトルメントを持つプロセスでは DYLD_* が丸ごと消されます。
Windows - PE と DLL のインポートテーブル
Windowsの実行形式PE(Portable Executable)は、先頭にMS-DOSスタブを持ち、オフセット 0x3c にPEシグネチャ PE\0\0 へのオフセットが入っています。Microsoftの "PE Format" 仕様によれば、DLLからのインポート情報は .idata セクションにあり、"All image files that import symbols, including virtually all executable (EXE) files, have an .idata section." です。中身はImport Directory Table、Import Lookup Table、Hint/Name Table、そしてImport Address Table(IAT)で、IATがELFのGOTに相当します。
The structure and content of the import address table are identical to those of the import lookup table, until the file is bound. During binding, the entries in the import address table are overwritten with the 32-bit (for PE32) or 64-bit (for PE32+) addresses of the symbols that are being imported.
Windowsのローダは起動時にIATを埋めるのが基本で、ELFのような遅延バインディングは「遅延ロードDLL」(Delay-Load Import Table、リンカの /DELAYLOAD)としてオプション的に存在します。もう一つの違いは再配置の扱いで、PEは「優先ベースアドレスにロードできなかったときだけ .reloc セクションの情報で全体をずらす」という設計です(ASLRが有効ならベースアドレスはランダム化されるので、その場合は .reloc による再配置が適用されます)。また、DLLはエクスポートするシンボルを .edata のExport Directory Tableで明示的に宣言する必要があり、ELFのように「グローバルなら既定で全部見える」わけではありません。この点はGCCの -fvisibility=hidden で明示的に絞る流儀と対照的です。
手を動かして確かめる手順
Dockerが使えるLinux環境なら、この記事の内容は10分で追試できます。以下はその手順で、出力は典型例です。
docker run --rm -it ubuntu:24.04
apt-get update && apt-get install -y gcc binutils
cat > hello.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void) { printf("pid=%d\n", (int)getpid()); return 0; }
EOF
# 1. 動的リンクと静的リンクのサイズ差
gcc -o hello_dyn hello.c
gcc -static -o hello_static hello.c
ls -l hello_dyn hello_static # 静的版が桁違いに大きいはず
file hello_dyn hello_static # dynamically linked / statically linked
# 2. ELFの構造
readelf -h hello_dyn # Type: DYN (PIE) か EXEC か
readelf -l hello_dyn | head -40 # INTERP, LOAD, GNU_RELRO, GNU_STACK
readelf -d hello_dyn # NEEDED, BIND_NOW の有無
readelf -r hello_dyn # JUMP_SLOT / GLOB_DAT / RELATIVE
objdump -d hello_dyn | grep -A3 '<printf@plt>'
# 3. 依存とシンボルバージョン
ldd hello_dyn
objdump -p hello_dyn | grep NEEDED
readelf -V hello_dyn # GLIBC_2.x の要求
# 4. 遅延バインディングの観察
LD_DEBUG=bindings ./hello_dyn 2>&1 | grep -E 'printf|getpid'
LD_BIND_NOW=1 LD_DEBUG=statistics ./hello_dyn 2>&1 | tail
# 5. LD_PRELOAD で getpid を差し替える
cat > fakepid.c <<'EOF'
#include <unistd.h>
pid_t getpid(void) { return 12345; }
EOF
gcc -shared -fPIC -o libfakepid.so fakepid.c
./hello_dyn
LD_PRELOAD=./libfakepid.so ./hello_dyn # pid=12345
LD_PRELOAD=./libfakepid.so ./hello_static # 静的版には効かない
# 6. RELRO の比較
gcc -Wl,-z,relro -Wl,-z,lazy -o partial hello.c
gcc -Wl,-z,relro -Wl,-z,now -o full hello.c
gcc -Wl,-z,norelro -o none hello.c
for f in partial full none; do
echo "== $f"; readelf -l $f | grep GNU_RELRO; readelf -d $f | grep -E 'BIND_NOW|FLAGS'
doneLD_PRELOAD が静的リンク版に効かないこと、Full RELROのバイナリで .got.plt がRELRO範囲に入っていること、readelf -V に GLIBC_2.34 が要求として現れること(Ubuntu 24.04のglibcは2.39なので、スレッドや dlopen を使うプログラムなら)を確認できれば、この記事の要点は押さえたことになります。
まとめ
- コンパイルはプリプロセッサ・コンパイラ・アセンブラ・リンカの4段階。リンカの仕事はシンボル解決と再配置で、
.oの中の空欄(R_X86_64_PC32やR_X86_64_PLT32の再配置エントリ)を埋める - ELFは「リンク用のセクション」と「実行用のセグメント」という2つの見方を持つ。ELFヘッダは64バイト、
e_typeがREL/EXEC/DYNで種類が分かり、PIEはDYNになる - 動的リンクされたプログラムで最初に走るのは
PT_INTERPに書かれたld.so。カーネルは補助ベクタ(AT_ENTRY、AT_BASE、AT_SECUREなど)で情報を渡す - ライブラリの検索順序は
DT_RPATH(DT_RUNPATHが無いとき)、LD_LIBRARY_PATH、DT_RUNPATH、/etc/ld.so.cache、/libと/usr/lib。実行時に探す名前は-lの名前ではなくDT_SONAME - シンボル検索は「リンクマップを先頭から、最初に見つかった定義を採用」。この性質(interposition)が
LD_PRELOADを可能にし、setuidバイナリではsecure-executionモードで無力化される - PLTはGOTを介した間接ジャンプの台。
.got.pltの初期値がPLT内の次の命令を指しているため、初回呼び出しは_dl_runtime_resolve経由で解決され、2回目からは直接飛ぶ。これが遅延バインディング - 書き込み可能なGOTは攻撃の標的になる。
-z relroで再配置後に読み取り専用化(Partial)、-z nowを足して.got.pltまで含める(Full)。PIEとASLRで位置も隠す。現在のUbuntuではこれらが既定 GLIBC_2.34 not foundはシンボルバージョニングの照合失敗。glibc 2.34(2021年8月)でlibpthreadとlibdlがlibcに統合されたため、この版が要求されやすい- macOSのdyldは既定で2レベル名前空間なので
DYLD_INSERT_LIBRARIESだけでは差し替わらず、DYLD_FORCE_FLAT_NAMESPACE=1が要る(手元で確認)。WindowsのPEはIATがGOTに相当する
リンカとローダは、ふだんは意識しなくても動くように作られていますが、「動かない」ときの原因はたいていこの層にあります。readelf -d と readelf -l、LD_DEBUG=libs、この3つを最初に打つ癖をつけるだけで、依存関係のトラブルの大半は10分で切り分けられるようになります。
参考リンク
- ld.so(8) - Linux manual page: https://man7.org/linux/man-pages/man8/ld.so.8.html
- ld(1) - Linux manual page: https://man7.org/linux/man-pages/man1/ld.1.html
- elf(5) - Linux manual page: https://man7.org/linux/man-pages/man5/elf.5.html
- readelf(1) / objdump(1) / nm(1) / ldd(1) / ldconfig(8) - Linux manual pages: https://man7.org/linux/man-pages/man1/readelf.1.html , https://man7.org/linux/man-pages/man1/objdump.1.html , https://man7.org/linux/man-pages/man1/nm.1.html , https://man7.org/linux/man-pages/man1/ldd.1.html , https://man7.org/linux/man-pages/man8/ldconfig.8.html
- dlsym(3)(RTLD_NEXT)/ getauxval(3) / vdso(7) - Linux manual pages: https://man7.org/linux/man-pages/man3/dlsym.3.html , https://man7.org/linux/man-pages/man3/getauxval.3.html , https://man7.org/linux/man-pages/man7/vdso.7.html
- Tool Interface Standard (TIS) ELF Specification Version 1.2(1995年5月): https://refspecs.linuxfoundation.org/elf/elf.pdf
- System V ABI (gABI) 第4章 ELFヘッダ / 第5章 動的リンク: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html , https://refspecs.linuxfoundation.org/elf/gabi4+/ch5.dynamic.html
- System V ABI x86-64 psABI: https://gitlab.com/x86-psABIs/x86-64-ABI
- GNU ld マニュアル Command-line Options: https://sourceware.org/binutils/docs/ld/Options.html
- GCC マニュアル Link Options: https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html
- glibc ソース elf/dl-runtime.c(
_dl_fixup)/ sysdeps/x86_64/dl-trampoline.S(_dl_runtime_resolve)/ elf/dl-version.c / include/libc-symbols.h: https://sourceware.org/git/?p=glibc.git - The GNU C Library version 2.34 is now available(2021年8月2日): https://sourceware.org/pipermail/libc-alpha/2021-August/129718.html
- Ian Lance Taylor, "Linkers" 連載(全20回、2007年): https://www.airs.com/blog/archives/38 (目次: https://lwn.net/Articles/276782/ )
- Eli Bendersky, "Load-time relocation of shared libraries": https://eli.thegreenplace.net/2011/08/25/load-time-relocation-of-shared-libraries
- Eli Bendersky, "Position Independent Code (PIC) in shared libraries": https://eli.thegreenplace.net/2011/11/03/position-independent-code-pic-in-shared-libraries
- Eli Bendersky, "Position Independent Code (PIC) in shared libraries on x64": https://eli.thegreenplace.net/2011/11/11/position-independent-code-pic-in-shared-libraries-on-x64
- MaskRay, "All about Procedure Linkage Table": https://maskray.me/blog/2021-09-19-all-about-procedure-linkage-table
- MaskRay, "All about Global Offset Table": https://maskray.me/blog/2021-08-28-all-about-global-offset-table
- MaskRay, "All about symbol versioning": https://maskray.me/blog/2020-11-26-all-about-symbol-versioning
- Ulrich Drepper, "How To Write Shared Libraries": https://www.akkadia.org/drepper/dsohowto.pdf
- LWN, "How programs get run: ELF binaries"(2015年2月4日): https://lwn.net/Articles/631631/
- Red Hat, "Hardening ELF binaries using Relocation Read-Only (RELRO)"(2019年1月28日): https://www.redhat.com/en/blog/hardening-elf-binaries-using-relocation-read-only-relro
- Ubuntu Wiki, ToolChain/CompilerFlags: https://wiki.ubuntu.com/ToolChain/CompilerFlags
- Linux kernel sysctl(randomize_va_space): https://www.kernel.org/doc/html/latest/admin-guide/sysctl/kernel.html
- checksec(slimm609/checksec): https://github.com/slimm609/checksec
- Microsoft, PE Format: https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
- Apple, System Integrity Protection Guide - Runtime Protections: https://developer.apple.com/library/archive/documentation/Security/Conceptual/System_Integrity_Protection_Guide/RuntimeProtections/RuntimeProtections.html
- 坂井弘亮『リンカ・ローダ実践開発テクニック』(CQ出版、2010年): https://shop.cqpub.co.jp/hanbai/books/38/38071.htm
- John R. Levine『Linkers & Loaders』(邦訳: オーム社、2001年): https://www.ohmsha.co.jp/book/9784274064371/
- 『Binary Hacks Rebooted』(オライリー・ジャパン): https://www.oreilly.co.jp/books/9784814400850/


