动态加载过程与 PLT、GOT 表

(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=0x4040181上记录的值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 表则被允许修改。从安全的角度上讲,这么设置显然是有助于减小攻击面,提高攻击门槛的。

参考资料


  1. 这里稍微解释一下:当程序正在执行 0x401030 上的指令时,rip 上的值一定为 0x401036,因此 0x401036+0x2fe2=0x404018。 ↩︎

  2. 再明确一下这里的意思:程序即将跳转到的目的地址,储存在地址为 0x404018 的内存中。程序会先从这里把目的地址拿到,然后再根据这个目的地址进行跳转,而不是直接跳转到 0x404018 上去执行。 ↩︎

  3. 此指令以 8 bytes 为单位("g"),以 16 进制数据格式(“x”)打印 0x404018 下的 1 个值。后文会涉及到以指令格式(“i”)打印。关于 GDB 中内存打印的更多细节可以参考GDB 手册 。 ↩︎

  4. 其实也不陌生。我之前的博客 有提到过,可以通过 pmap <PID>/proc/<PID>/maps 查看被调试进程的进程空间,来了解这个地址处于哪个动态库的内存映射范围内。 ↩︎


LSM 的 Security Blob 机制
Git Relevant Operations