(TODO 补充关于编译时重定位、运行时重定位、PLT/GOT 表的背景知识)
本文将展示动态加载的过程,相关函数的地址如何在运行时被重定位,以及 PLT/GOT 表在其中的发挥的作用。以下面这段代码为例:
1#include <stdio.h>
2
3int main()
4{
5 printf("Hey man!\n");
6 return 0;
7}
编译
之前着实没想到,为了更好地展现 PLT/GOT 的过程,要在编译这块做这么多额外工作。
首先要添加 -fno-pie
和 -no-pie
两个选项,否则 GOT 里的函数地址会在 main()
执行前就被调整好,原本的过程就体现不出来了。这是从这个视频
里学来的。(TODO 待补充 PIE 相关的内容)
其次,在 Ubuntu 19.10 及以上的系统中,GCC 默认使能了 CFI 增强功能,如 Ubuntu 20.04 LTS 上的 man gcc
所述:
Currently the x86 GNU/Linux target provides an implementation based on Intel Control-flow Enforcement Technology (CET).
NOTE: In Ubuntu 19.10 and later versions,
-fcf-protection
is enabled by default for C, C++, ObjC, ObjC++, if none of-fno-cf-protection
nor-fcf-protection=*
are found.
该功能基于英特尔 Control-flow Enforcement Technology (CET)
实现,这其实从反汇编出来的 endbr
指令也能看出。该指令属于 CET 中的 Indirect Branch Tracking (IBT) 部分,用以实现*前向(forward-edge)*CFI。为了去除保障 CFI 的代码,让动态加载的执行过程更为清晰,需要在编译选项中加入 -fcf-protection=none
。我试过加 -fno-cf-protection
,但 GCC 没有识别。
综上所述,所需的编译命令为:
1# in Ubuntu 19.10 and above
2gcc -v -g -fno-pie -no-pie -fcf-protection=none ./main.c
3
4# in older Ubuntu
5gcc -v -g -fno-pie -no-pie ./main.c
过程分析
静态分析
将编译生成的可执行程序 a.out 用 objdump -d
反汇编,生成汇编代码如下:
10000000000401030 <puts@plt>:
2 401030: ff 25 e2 2f 00 00 jmpq *0x2fe2(%rip) # 404018 <puts@GLIBC_2.2.5>
3 401036: 68 00 00 00 00 pushq $0x0
4 40103b: e9 e0 ff ff ff jmpq 401020 <.plt>
5
6# . . .
7
80000000000401126 <main>:
9 401126: 55 push %rbp
10 401127: 48 89 e5 mov %rsp,%rbp
11 40112a: bf 04 20 40 00 mov $0x402004,%edi
12 40112f: e8 fc fe ff ff callq 401030 <puts@plt>
13 401134: b8 00 00 00 00 mov $0x0,%eax
14 401139: 5d pop %rbp
15 40113a: c3 retq
16 40113b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
g可以看到,不带格式打印参数的 printf()
被编译器替换成了 puts()
。printf()
/puts()
都定义在外部的动态链接库中,只有到了运行时才能确定 puts()
的地址,因此对 puts()
的调用不是直接跳转到 puts()
的地址上,而是先跳到 puts@plt
,通过 PLT 表来查询 puts()
的真实地址。用 readelf -SW
查询 a.out 的 ELF *节(section)*信息:
1Section Headers:
2 [Nr] Name Type Address Off Size ES Flg Lk Inf Al
3 . . .
4 [11] .init PROGBITS 0000000000401000 001000 00001b 00 AX 0 0 4
5 [12] .plt PROGBITS 0000000000401020 001020 000020 10 AX 0 0 16
6 [13] .text PROGBITS 0000000000401040 001040 000175 00 AX 0 0 16
7 . . .
8 [21] .got PROGBITS 0000000000403ff0 002ff0 000010 08 WA 0 0 8
9 [22] .got.plt PROGBITS 0000000000404000 003000 000020 08 WA 0 0 8
10 [23] .data PROGBITS 0000000000404020 003020 000010 00 WA 0 0 8
可以看到 puts@plt
的地址确实处于 .plt
节中。puts@plt
的第一条指令是跳转到 rip+0x2fe2=0x404018
1上记录的值2。根据上表,0x404018
落在 .got.plt
节中,存储的是一段数据,已经与代码无关了。想要了解这个地址上具体的值是多少,基于反汇编和 ELF 格式的静态分析已经不太够用了。下面我们上 GDB 来观察。
运行时分析(上 GDB!)
为方便讲解,我们再贴一下 puts@plt
的汇编代码:
10000000000401030 <puts@plt>:
2 401030: ff 25 e2 2f 00 00 jmpq *0x2fe2(%rip) # 404018 <puts@GLIBC_2.2.5>
3 401036: 68 00 00 00 00 pushq $0x0
4 40103b: e9 e0 ff ff ff jmpq 401020 <.plt>
在 GDB 中用 break *0x401030
设置断点后执行 run
让程序运行至 0x401030
,然后打印地址 0x404018
下的内存值3:
1(gdb) x/1xg 0x404018
20x404018: 0x0000000000401036
保存的是 0x401036
。程序当前在 0x401030
,下一步要跳到 0x401036
,那不就是下一条指令嘛,干嘛还要跳呢?其实是因为此时运行时重定位还没完成(严格来说才刚刚开始),等到重定位完成了,0x404018
下保存的就是 puts()
的地址啦!
接着往下运行,会跳到 0x401020
,即 .plt
段的开头:
10000000000401020 <.plt>:
2 401020: ff 35 e2 2f 00 00 pushq 0x2fe2(%rip) # 404008 <_GLOBAL_OFFSET_TABLE_+0x8>
3 401026: ff 25 e4 2f 00 00 jmpq *0x2fe4(%rip) # 404010 <_GLOBAL_OFFSET_TABLE_+0x10>
4 40102c: 0f 1f 40 00 nopl 0x0(%rax)
随后将 0x404008
压栈后又即将跳往 0x404010
上所保存的地址。结合上一节中的*节头表(Section Headers)*和此处的注释信息,可以确定 .got.plt
节保存的就是 GOT 表。打印地址 0x404010
下的内存内容:
1(gdb) x/1xg 0x404010
20x404010: 0x00007ffff7fe7bb0
是一段陌生的地址4。当程序跳转到该地址后,GDB 显示程序进入到了 /lib64/ld-linux-x86-64.so.2
,也就是动态加载器中。说明此时动态加载器正在发挥作用,寻找并加载 puts()
的真正地址。
关于动态加载器是如何定位到 puts()
的具体地址的,需要去看加载器的源码,这里我们跳过,直接来看最终的结果:我们在 0x401134
处设立断点,然后直接让程序执行到那里。此时我们再来回顾 puts@plt
开头处的跳转指令,查看一下此时 0x404018
上记录的地址:
1(gdb) x/1xg 0x404018
20x404018: 0x00007ffff7e535a0
Schau mal!这里的值已经从之前的 0x401036
变成了 0x7ffff7e535a0
,而后者正是 puts()
的真实地址:
1(gdb) x/6i 0x7ffff7e535a0
2 0x7ffff7e535a0 <__GI__IO_puts>: endbr64
3 0x7ffff7e535a4 <__GI__IO_puts+4>: push %r14
4 0x7ffff7e535a6 <__GI__IO_puts+6>: push %r13
5 0x7ffff7e535a8 <__GI__IO_puts+8>: push %r12
6 0x7ffff7e535aa <__GI__IO_puts+10>: mov %rdi,%r12
7 0x7ffff7e535ad <__GI__IO_puts+13>: push %rbp
到此为止,对 puts()
这个函数(或者说符号,“Symbol”)的重定位就完成了。显而易见,这个重定位过程只有在第一次跳转到这个符号的时候才需要执行;若是后面还有对 puts()
的调用,则可以跳转到正确的地址。
以上就是一个完整的运行时符号重定位的过程。
重点:为什么需要两个表
之前我一直很疑惑,为什么要搞得这么复杂,要搞 PLT 和 GOT 两个不同性质的表,还要跳来跳去的。直到偶然间读到这篇知乎文章 上的一段不太起眼的话:
而且现代操作系统不允许修改代码段,只能修改数据段,那么 GOT 表与 PLT 表就应运而生。
我才顿悟:原来是出于安全的原因!再次观察 a.out 的节头表:
1Section Headers:
2 [Nr] Name Type Address Off Size ES Flg Lk Inf Al
3 . . .
4 [12] .plt PROGBITS 0000000000401020 001020 000020 10 AX 0 0 16
5 . . .
6 [21] .got PROGBITS 0000000000403ff0 002ff0 000010 08 WA 0 0 8
7 [22] .got.plt PROGBITS 0000000000404000 003000 000020 08 WA 0 0 8
再观察它的段头表,以及节到段的映射:
1Program Headers:
2 Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
3 . . .
4 LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x0001c5 0x0001c5 R E 0x1000
5 LOAD 0x002000 0x0000000000402000 0x0000000000402000 0x000138 0x000138 R 0x1000
6 LOAD 0x002e10 0x0000000000403e10 0x0000000000403e10 0x000220 0x000228 RW 0x1000
7 . . .
8
9 Section to Segment mapping:
10 Segment Sections...
11 . . .
12 03 .init .plt .text .fini
13 04 .rodata .eh_frame_hdr .eh_frame
14 05 .init_array .fini_array .dynamic .got .got.plt .data .bss
15 . . .
可以发现,.plt
节及其相应的段是 r-x
的,而 .got.plt
节和段是 rw-
的。所以作为代码段一部分的 PLT 表,在运行时是不可以被修改的,而作为数据段的 GOT 表则被允许修改。从安全的角度上讲,这么设置显然是有助于减小攻击面,提高攻击门槛的。
参考资料
-
关于英特尔 CET/IBT
之前在调研 Linux Security Summit 2021 的议题时,我负责的其中一个是 Hardware-Assisted Fine-Grained Control-Flow Integrity: Adding Lasers to Intel’s CET/IBT ,刚好有讲到这部分。感兴趣的可以看一下他的胶片 ,或者去 Youtube 上搜峰会的视频。
-
关于
-fcf-protection
-
关于 GDB 操作
-
%rip
寄存器 (如何解读其在 GDB 中显示的值)The difference is, that the rip points at the time of evaluation to the beginning of next instruction.
-
关于 x86 汇编
-
这里稍微解释一下:当程序正在执行
0x401030
上的指令时,rip
上的值一定为0x401036
,因此0x401036+0x2fe2=0x404018
。 ↩︎ -
再明确一下这里的意思:程序即将跳转到的目的地址,储存在地址为
0x404018
的内存中。程序会先从这里把目的地址拿到,然后再根据这个目的地址进行跳转,而不是直接跳转到0x404018
上去执行。 ↩︎ -
此指令以 8 bytes 为单位("
g
"),以 16 进制数据格式(“x
”)打印0x404018
下的 1 个值。后文会涉及到以指令格式(“i
”)打印。关于 GDB 中内存打印的更多细节可以参考GDB 手册 。 ↩︎ -
其实也不陌生。我之前的博客 有提到过,可以通过
pmap <PID>
或/proc/<PID>/maps
查看被调试进程的进程空间,来了解这个地址处于哪个动态库的内存映射范围内。 ↩︎