PIC 即 position independent code(位置无关代码),对应编译选项 -fpic:生成适合放入共享库的位置无关代码。
共享库有一个重要特征——可以被多个可执行文件共享,以达到节省磁盘和内存空间的目标。这意味着:
- 磁盘上只有一份拷贝,加载到内存后也只有一份拷贝,因此代码部分在运行时不能被修改,否则就需要多份拷贝存在;
- 需要能够灵活地映射到不同的虚拟地址空间,以适应不同的程序、避免地址冲突。
这两点要求共享库的代码和数据都是位置无关的。接下来先看什么是”位置无关”。
什么是位置无关
同样以 hello.c 为例:
#include <stdio.h>
int main(void)
{
printf("hello\n");
return 0;
}
以普通方式编译并反汇编一个可执行文件看看:
$ gcc -m32 -o hello hello.c
$ objdump -d hello | grep -B1 "call.*puts@plt>"
8048416: 68 b0 84 04 08 push $0x80484b0
804841b: e8 c0 fe ff ff call 80482e0 <puts@plt>
可以看到,传递给 puts(printf)的字符串地址是”写死”的,在编译时就已经确定。这意味着它的 Load Address 也必须是固定的:
$ readelf -l hello | grep LOAD | head -1
LOAD 0x000000 0x08048000 0x08048000 0x005b0 0x005b0 R E 0x1000
Load Address 为 0x8048000。如果 Load Address 变了,数据地址就会指向别的内容,这就是”位置有关”。共享库必须摒弃这种”写死的”地址,做到”位置无关”(注:prelink 是特殊需求,暂且不表)。
位置无关的实现(上):运行时计算地址
位置无关,意味着运行时可以灵活调整 Load Address,即使 Load Address 改变了,代码仍能被执行到、数据仍能被正确访问。因此代码和数据都不能再用绝对地址,而要采用某个相对 Load Address 的地址。
动态链接器负责找到并装载共享库,所以它是知道 Load Address 的,函数符号也因此很容易确定。先看不带 -fpic 编译生成的共享库:
$ gcc -m32 -shared -o libhello.so hello.c
$ objdump -d libhello.so | grep -A2 "main>:"
000004a9 <main>:
4a9: 8d 4c 24 04 lea 0x4(%esp),%ecx
4ad: 83 e4 f0 and $0xfffffff0,%esp
查看装载地址——编译后初始化为 0:
$ readelf -l libhello.so | grep LOAD | head -1
LOAD 0x000000 0x00000000 0x00000000 0x0057c 0x0057c R E 0x1000
$ readelf --dyn-syms libhello.so | grep m
Symbol table '.dynsym' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
4: 00000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
9: 000004a9 46 FUNC GLOBAL DEFAULT 11 main
$ hexdump -C -s $((0x4a9)) -n 10 libhello.so
000004a9 8d 4c 24 04 83 e4 f0 ff 71 fc |.L$.....q.|
000004b3
可以看到,对 main 而言,无论把共享库装载到哪里,动态链接器总能根据 Load Address 以及 .dynsym 中的偏移算出 main 的运行时地址(见 glibc 的 _dl_fixup)。但此时(不用 -fpic 的话)数据地址仍然是”写死”的:
$ objdump -d libhello.so | grep -B1 "call.*main"
4bd: 68 ec 04 00 00 push $0x4ec
4c2: e8 fc ff ff ff call 4c3
作为对比,看看加上 -fpic 的效果:
$ gcc -m32 -shared -fpic -o libhello.so hello.c
$ objdump -dr libhello.so | grep -B6 "call.*puts@plt>"
4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax>
4cd: 05 33 1b 00 00 add $0x1b33,%eax
4d2: 83 ec 0c sub $0xc,%esp
4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx
4db: 52 push %edx
4dc: 89 c3 mov %eax,%ebx
4de: e8 bd fe ff ff call 3a0 <puts@plt>
可以看到,用上 -fpic 以后,传递给 puts 的数据地址(push %edx)已经是动态计算出来的了。那是怎么算的呢?上面有个内联进来的函数很关键:
$ objdump -dr libhello.so | grep -A3 "__x86.get_pc_thunk.ax>:"
000004f5 <__x86.get_pc_thunk.ax>:
4f5: 8b 04 24 mov (%esp),%eax
4f8: c3 ret
这个函数非常简单——从栈顶取了一个数据就跳回去了。取的是什么数据呢?这就要了解调用它的 call 指令:call 会把下一条指令的 EIP 压栈,然后跳转到目标地址:
call backward ==> push eip;
jmp backward
所以数据地址是运行时计算的,跟运行时的 EIP 关联上了。不难猜测:如果知道当前指令的位置,又提前保存了数据离当前位置的偏移,那么数据地址就可以直接算出。只是上面那段代码略显复杂,因为有一堆 “magic number”。
不管怎样,先模拟计算一下。假设装载到的地址就是 0x0,那么执行到 add 指令时存入 %eax 的 EIP,恰好是 call 返回后下一条指令的地址,即 0x4cd:
4c8: e8 28 00 00 00 call 4f5 <__x86.get_pc_thunk.ax>
4cd: 05 33 1b 00 00 add $0x1b33,%eax
4d5: 8d 90 10 e5 ff ff lea -0x1af0(%eax),%edx
根据上述指令,%edx 算出来就是 0x510:
$ echo "obase=16;$((0x4cd+0x1b33-0x1af0))" | bc
510
再去取这个地址的数据:
$ hexdump -C -s $((0x510)) -n 10 libhello.so
00000510 68 65 6c 6c 6f 00 00 00 01 1b |hello.....|
0000051a
果然是字符串的地址。所以相对偏移其实被拆分成了两部分——0x1b33 和 -0x1af0,两个 “magic number” 一加就出来了。
小结一下:”位置无关”是通过运行时动态获取 EIP,并加上一个编译时记录好的偏移计算出来的。这样无论加载到什么位置,都能访问到数据。
位置无关的实现(下):GOT 与 GOTOFF
这两个 “magic number” 既然是编译时确定的,那就看看汇编层面是怎么回事:
$ gcc -m32 -shared -fpic -S hello.c
$ cat hello.s | grep -v .cfi
...
.LC0:
.string "hello"
.text
.globl main
.type main,@function
main:
.LFB0:
leal 4(%esp),%ecx
andl $-16,%esp
pushl -4(%ecx)
pushl %ebp
movl %esp,%ebp
pushl %ebx
pushl %ecx
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_,%eax
subl $12,%esp
leal .LC0@GOTOFF(%eax),%edx
pushl %edx
movl %eax,%ebx
call puts@PLT
...
从 i386 的 archABI(P61~P62)不难找到这块的定义:name@GOTOFF(%eax) 直接表示 name 符号相对 %eax 保存的 GOT 的偏移地址。
编译时先要计算 $_GLOBAL_OFFSET_TABLE_ 和 .LC0@GOTOFF。$_GLOBAL_OFFSET_TABLE_ 是 GOT 相对 EIP 的偏移,可计算为:
$_GLOBAL_OFFSET_TABLE_ = .got.plt - eip
计算过程如下:
$ readelf -S libhello.so | grep .got.plt
[21] .got.plt PROGBITS 00002000 001000 000010 04 WA 0 0 4
$ echo "obase=16;$((0x2000-0x4cd))" | bc
1B33
接着计算 .LC0@GOTOFF:
.LC0 - eip = _GLOBAL_OFFSET_TABLE_ + .LC0@GOTOFF
.LC0@GOTOFF = .LC0 - eip - _GLOBAL_OFFSET_TABLE_
计算过程如下:
$ echo "obase=16;$((0x510-0x4cd-0x1B33))" | bc
-1AF0
反过来,运行时的计算公式为:
.LC0 = _GLOBAL_OFFSET_TABLE_ + .LC0@GOTOFF + eip
.LC0 = 0x1B33 + (-1AF0) + eip
.got.plt = _GLOBAL_OFFSET_TABLE_ + eip
.got.plt = 0x1B33 + eip
实际上,只有 .got.plt 的地址(即 %ebx)需要 $_GLOBAL_OFFSET_TABLE_ 来计算,这是用来做动态地址重定位的,暂且不表。而 .LC0 的地址完全可以换一种方式——直接用 .LC0 到 EIP 的偏移即可。改造后的汇编代码如下:
call __x86.get_pc_thunk.ax
.eip:
#计算 eip+(.LC0-.eip) 刚好指向内存中的数据 "hello" 所在位置
movl %eax,%ebx
leal (.LC0-.eip)(%eax),%edx
#计算 .got.plt 地址,_GLOBAL_OFFSET_TABLE_ 是相对 eip 的偏移,所以必须加上这个 offset:. - .eip
addl $_GLOBAL_OFFSET_TABLE_+(.-.eip),%ebx
subl $12,%esp
pushl %edx
call puts@PLT
验证结果:
$ gcc -m32 -g -shared -fpic -o libhello.so hello.s
$ gcc -m32 -g -o hello.noc -L./ -lhello
$ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./ ./hello.noc
hello
小结
本文介绍了 Linux 下 C 语言共享库”位置无关”(PIC)的核心实现原理:用 EIP 相对地址取代绝对地址。
“位置无关”代码带来了很大的内存使用灵活性,也带来一定的安全性——因为”位置无关”意味着加载地址可以随机化,给代码注入增加了难度。正因为有这些好处,各大平台的 gcc 都开始默认打开可执行文件的 -pie -fpie(因为 gcc 编译时开启了 --enable-default-pie)。这也可能导致一些”性能衰退”,有需要时可以用 -no-pie、-fno-pie 关闭。
当然,共享库的实现精髓不止于此,最核心的还是函数符号地址的动态解析过程,而它们与上面的 .got.plt 地址密切相关。受限于篇幅,这里暂不展开。