情弱ログ

参考にならないので当てにしないでください

Linuxの共有ライブラリ呼び出し(呼び出し側編)

Linuxで共有ライブラリの関数を呼び出す際、PLTを経由して関数が呼び出される。PLTとはProcedure Linkage Tableの略で、PLT経由で呼び出す関数のアドレスは動的リンカが解決してくれる。この機構を追ってみた。


今回用意したソースコードはこんな感じ。

#include <stdio.h>

int main()
{
	printf("Hello, World!\n");
	printf("Goodbye, World!\n");

	return 0;
}

Gentoo (profile 17.0以降)だとPIEがデフォルトで有効なので、明示的に無効化してコンパイルする。

$ gcc -fno-pie -no-pie hello.c -o hello_c

アセンブルするとこんな感じ。(main()だけ)

00000000004004d6 <main>:
  4004d6:	55                   	push   %rbp
  4004d7:	48 89 e5             	mov    %rsp,%rbp
  4004da:	bf 74 05 40 00       	mov    $0x400574,%edi
  4004df:	e8 ec fe ff ff       	callq  4003d0 <puts@plt>
  4004e4:	bf 82 05 40 00       	mov    $0x400582,%edi
  4004e9:	e8 e2 fe ff ff       	callq  4003d0 <puts@plt>
  4004ee:	b8 00 00 00 00       	mov    $0x0,%eax
  4004f3:	5d                   	pop    %rbp
  4004f4:	c3                   	retq
  4004f5:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  4004fc:	00 00 00
  4004ff:	90                   	nop

コンパイラが最適化してprintfがputsになっている程度で、後は大体雰囲気でいける。0x400574および0x400582は文字列のアドレスとなっている。
main関数が呼び出しているputs@pltを見てみよう。

Disassembly of section .plt:

00000000004003c0 <.plt>:
  4003c0:	ff 35 42 0c 20 00    	pushq  0x200c42(%rip)        # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
  4003c6:	ff 25 44 0c 20 00    	jmpq   *0x200c44(%rip)        # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
  4003cc:	0f 1f 40 00          	nopl   0x0(%rax)

00000000004003d0 <puts@plt>:
  4003d0:	ff 25 42 0c 20 00    	jmpq   *0x200c42(%rip)        # 601018 <puts@GLIBC_2.2.5>
  4003d6:	68 00 00 00 00       	pushq  $0x0
  4003db:	e9 e0 ff ff ff       	jmpq   4003c0 <.plt>

puts@pltは3個の命令だけであり、しかもおおよそ通常の関数とは異なった形式を持っている。
0x200c42(%rip) (右のコメントにあるように、rip=0x4003d6なので0x4003d6+0x200c42 = 0x601018)の中身が見てみたいが、実行時のメモリからでないと分からないので、gdbから起動してみる。

(gdb) b main
Breakpoint 1 at 0x4004da
(gdb) r
Starting program: /home/vicco/Scripts/hello/hello_c

Breakpoint 1, 0x00000000004004da in main ()
(gdb) x 0x601018
0x601018:	0x004003d6

結果より、jmpq *0x200c42(%rip)は0x004003d6、すなわち次の行(puts@pltの二行目)へのjmp命令であることが分かる。先の処理を追ってみる。
まず、puts@pltは0x0をスタックにpushし、.pltにjmpする。.pltは更に0x601008をスタックにpushし、*0x601010へjmpする。callではなくjmpなので、push以外でスタックは変動しないことに注意したい。ちなみに.pltはgdbから逆アセンブルできなかった。関数らしき形式を取っていないからだろうか。

gdbのステップ実行で追いかけてみる。「disp/i $pc」はプログラムカウンタの指す命令列を表示するようgdbに指示する命令である。

(gdb) disp/i $pc
1: x/i $pc
=> 0x4004da <main+4>:	mov    $0x400574,%edi
(gdb) si
0x00000000004004df in main ()
1: x/i $pc
=> 0x4004df <main+9>:	callq  0x4003d0 <puts@plt>
(gdb) si
0x00000000004003d0 in puts@plt ()
1: x/i $pc
=> 0x4003d0 <puts@plt>:	jmpq   *0x200c42(%rip)        # 0x601018
(gdb) si
0x00000000004003d6 in puts@plt ()
1: x/i $pc
=> 0x4003d6 <puts@plt+6>:	pushq  $0x0
(gdb) si
0x00000000004003db in puts@plt ()
1: x/i $pc
=> 0x4003db <puts@plt+11>:	jmpq   0x4003c0
(gdb) si
0x00000000004003c0 in ?? ()
1: x/i $pc
=> 0x4003c0:	pushq  0x200c42(%rip)        # 0x601008
(gdb) si
0x00000000004003c6 in ?? ()
1: x/i $pc
=> 0x4003c6:	jmpq   *0x200c44(%rip)        # 0x601010
(gdb) si
0x00007ffff7def7b0 in ?? () from /lib64/ld-linux-x86-64.so.2
1: x/i $pc
=> 0x7ffff7def7b0:	vorpd  %ymm0,%ymm1,%ymm8

.pltのjmpq *0x200c44(%rip) により、/lib64/ld-linux-x86-64.so.2の命令列にjmpしたようだ。vorpd等見たこともない命令が出てきてしんどいので、ここは飛ばしてしまおう。

(gdb) fin
Run till exit from #0  0x00007ffff7def7b0 in ?? () from /lib64/ld-linux-x86-64.so.2
Hello, World!
0x00000000004004e4 in main ()
1: x/i $pc
=> 0x4004e4 <main+14>:	mov    $0x400582,%edi

Hello, World!と出力されたので、puts@pltの処理が終了してしまったことが分かる。気を取り直して2回目のputs@pltを実行しよう。

(gdb) si
0x00000000004004e9 in main ()
1: x/i $pc
=> 0x4004e9 <main+19>:	callq  0x4003d0 <puts@plt>
(gdb) si
0x00000000004003d0 in puts@plt ()
1: x/i $pc
=> 0x4003d0 <puts@plt>:	jmpq   *0x200c42(%rip)        # 0x601018
(gdb) si
0x00007ffff7a98de0 in puts () from /lib64/libc.so.6
1: x/i $pc
=> 0x7ffff7a98de0 <puts>:	push   %r13

先ほどと同じくputs@pltに入ったはいいものの、その後のjmp先が異なることにお気づきいただけただろうか。前回のputs@pltでは1行目のjmpでputs@pltの2行目に飛んでいた。しかし、今回は全く異なる、しかも本来呼び出したい関数である/lib64/libc.so.6のputs()に飛んでいることが分かる。明らかに怪しい0x601018の参照先の値を見てみる。

(gdb) x/g 0x601018
0x601018:	0x00007ffff7a98de0

対比として先程の値を再掲する。

(gdb) x 0x601018
0x601018:	0x004003d6

このように、先程とは異なる値が格納されている。(x/gとしているのは64bitの数値をダンプするためです。)

pltを経由して呼び出したライブラリ関数は1回目のみ/lib64/ld-linux-x86-64.so.2で定義された何かしらの処理が行われ、2回目以降は直接jmpできるようになる。この時、pltが最初に参照しているテーブル(今回なら0x601018)をGOT(Global Offset Table)と呼び、1回目に/lib64/ld-linux-x86-64.so.2で定義された動的リンカがGOTを書き換えることで2回目以降の呼び出しにかかるコストを低減できる。このように関数の呼び出しに応じて動的リンクを行う方式を遅延リンクや遅延バインドと呼ぶらしい。

動的リンカはスタックにpushされた2つの値を元にGOTを書き換える。最初の0x0は.rela.pltセクションのオフセット、二番目の0x601008はGOTのどっかのエントリ。多分。
.rela.pltセクションをダンプしてみよう。

$ readelf -x .rela.plt hello_c

Hex dump of section '.rela.plt':
  0x00400390 18106000 00000000 07000000 01000000 ..`.............
  0x004003a0 00000000 00000000                   ........

少し見づらいのでodでダンプする。

$ od -tx8 -Ax -j 0x390 -N 0x18 hello_c
000390 0000000000601018 0000000100000007
0003a0 0000000000000000
0003a8

.rela.pltの構造は以下の構造体で定義されている。

typedef struct
{
  Elf64_Addr    r_offset;               /* Address */
  Elf64_Xword   r_info;                 /* Relocation type and symbol index */
  Elf64_Sxword  r_addend;               /* Addend */
} Elf64_Rela;
typedef uint64_t Elf64_Addr;
typedef uint64_t Elf64_Xword;
typedef int64_t  Elf64_Sxword;

r_offsetを見ると0x601018が格納されており、これはputs@pltが最初に見るGOTのアドレスに一致する。
r_infoは一部のビットフィールドが情報を持つため、そのままではなく以下のマクロを用いて情報を取り出す。

/* How to extract and insert information held in the r_info field.  */

#define ELF64_R_SYM(i)                  ((i) >> 32)
#define ELF64_R_TYPE(i)                 ((i) & 0xffffffff)
#define ELF64_R_INFO(sym,type)          ((((Elf64_Xword) (sym)) << 32) + (type))

ELF64_R_SYM(r_info)とすれば1が得られ、ELF64_R_TYPE(r_info)とすれば7が得られる。以下、前者をsym、後者をtypeと呼ぶ。先にtypeを見ていこう。これは以下のマクロで定義された値となる。

/* AMD x86-64 relocations.  */
#define R_X86_64_JUMP_SLOT	7	/* Create PLT entry */

次にsymを見ていく。symは.dynsymセクションのインデックスとなっており、.dynsymセクションの構造は以下の構造体で定義される。

typedef struct
{
  Elf64_Word    st_name;                /* Symbol name (string tbl index) */
  unsigned char st_info;                /* Symbol type and binding */
  unsigned char st_other;               /* Symbol visibility */
  Elf64_Section st_shndx;               /* Section index */
  Elf64_Addr    st_value;               /* Symbol value */
  Elf64_Xword   st_size;                /* Symbol size */
} Elf64_Sym;
typedef uint32_t Elf64_Word;
typedef uint16_t Elf64_Section;
typedef uint64_t Elf64_Addr;
typedef uint64_t Elf64_Xword;

一つのエントリーにつき24byteとなっているようだ。

$ readelf -x .dynsym hello_c

Hex dump of section '.dynsym':
  0x00400298 00000000 00000000 00000000 00000000 ................
  0x004002a8 00000000 00000000 0b000000 12000000 ................
  0x004002b8 00000000 00000000 00000000 00000000 ................
  0x004002c8 10000000 12000000 00000000 00000000 ................
  0x004002d8 00000000 00000000 2e000000 20000000 ............ ...
  0x004002e8 00000000 00000000 00000000 00000000 ................

インデックスが1の部分だけを抜き出すと以下のような形となる。

  0x004002a8                   0b000000 12000000 ................
  0x004002b8 00000000 00000000 00000000 00000000 ................

これより、st_nameは0x0000000bであることが分かる。これは.dynstrセクションのインデックスとなっている。

$ readelf -p .dynstr hello_c

String dump of section '.dynstr':
  [     1]  libc.so.6
  [     b]  puts
  [    10]  __libc_start_main
  [    22]  GLIBC_2.2.5
  [    2e]  __gmon_start__

0xbはputsとなっており、期待したものと一致する。
先の.dynsymのダンプを見てみると、st_name以外にst_infoが定義されており、その値は0x12となっている。この値もビットフィールドにより意味が分かれており、以下のマクロで取り出すことができる。

/* How to extract and insert information held in the st_info field.  */

#define ELF32_ST_BIND(val)              (((unsigned char) (val)) >> 4)
#define ELF32_ST_TYPE(val)              ((val) & 0xf)
#define ELF32_ST_INFO(bind, type)       (((bind) << 4) + ((type) & 0xf))

/* Both Elf32_Sym and Elf64_Sym use the same one-byte st_info field.  */
#define ELF64_ST_BIND(val)              ELF32_ST_BIND (val)
#define ELF64_ST_TYPE(val)              ELF32_ST_TYPE (val)
#define ELF64_ST_INFO(bind, type)       ELF32_ST_INFO ((bind), (type))

st_infoは0x12なので、ELF64_ST_BIND(st_info)は1、ELF64_ST_TYPE(st_info)は2となる。これらはそれぞれ以下のような意味を表す。

#define STB_GLOBAL      1               /* Global symbol */
#define STT_FUNC        2               /* Symbol is a code object */

ちなみにreadelfコマンドに-rオプションをつけるとこれらの情報をひとまとめにして表示できる。

$ readelf -r hello_c

Relocation section '.rela.dyn' at offset 0x360 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000600ff0  000200000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
000000600ff8  000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

Relocation section '.rela.plt' at offset 0x390 contains 1 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000601018  000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0

putsの後のGLIBC_2.2.5が気になるが、今見れる範囲では正直分からんので宿題にする。

動的リンカが実行時にこれらの情報が取れるよう、プログラムヘッダのDYNAMICセクションにこれらのアドレスがまとまっている。(はずなんだけど実行時のプログラムヘッダってどうやって見るんだろう。) readelfに-dオプションをつけるとこれらを確認できる。

$ readelf -d hello_c

Dynamic section at offset 0xe20 contains 24 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x4003a8
 0x000000000000000d (FINI)               0x400564
 0x0000000000000019 (INIT_ARRAY)         0x600e08
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x600e10
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x400278
 0x0000000000000005 (STRTAB)             0x4002f8
 0x0000000000000006 (SYMTAB)             0x400298
 0x000000000000000a (STRSZ)              61 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x601000
 0x0000000000000002 (PLTRELSZ)           24 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x400390
 0x0000000000000007 (RELA)               0x400360
 0x0000000000000008 (RELASZ)             48 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000006ffffffe (VERNEED)            0x400340
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x400336
 0x0000000000000000 (NULL)               0x0

他にも色々気になることはあるが、呼び出し側からするとこれ以上は厳しいので、次回は動的リンカのソースコードを追っていこう。

参考文献
再配置 (リンカーとライブラリ)
https://docs.oracle.com/cd/E19620-01/805-5821/chapter6-42444/index.html
ELFの動的リンク(1) - 七誌の開発日記
【ELF形式】.dynsym セクション - ゆずさん研究所
ELFの再配置シンボルの解決 | ψ(プサイ)の興味関心空間
http://ukai.jp/debuan/2002w/elf.txt
How is glibc loaded at runtime? | Dustin Schultz — Pluralsight Author & Senior Software Engineer