本文主要讨论什么是延迟加载?什么是PLT与GOT表?PLT表与GOT表到底建立跳转关系的?延迟加载有好处与弊端?

GOT和PLT是什么

  • PLT:Procedure Link Table,程序链接表。

  • GOT:Global Offset Table,全局偏移表。

这两个表相互配合解决外部函数符号地址,解决运行时重定位的问题。这种方法能让函数在调用时才确定地址,进程的启动时间加快,只需一次绑定,也称为延迟绑定,接下来通过例子示意。

代码示意与分析

#include <stdio.h>

void testprintf()
{
printf("hello\n");
}

int main()
{
char acTemp[100] = {0};
printf("begin\n");
testprintf();
return 0;
}

上面是一段非常简单的C程序,就调一个printf函数,一个自定义的testprintf函数。其中我们知道printf是libc.so里面的函数,在默认情况下,glibc是在运行时动态链接的,当程序callprintf函数时,并不知printf的真正地址,只有libc.so装载时,才能知道具体地址。看起来编译时链接是完不成这件事了,编译时如果能留出来地址备用,当进程加载启动时告诉操作系统,我需要知道printf真正地址,能正确替换出来。

实际就是这么做的,但我们再具体一点,结合问题分析。

  1. 当进程启动,libc.so装载完毕,那么printf对应二进制代码所在.text的地址也确定了,就是说printf函数,在我的进程中也是明确了,那么我只要修改一下call printf对应汇编二进制代码,改为printf正确的地址也可以,但现在操作系统在不做特殊操作情况下,是不允许我们修改代码段的(实际还是可以修改的),这样直接行不通,我们可以间接加一层处理,call一个特定的内存地址,这个地址存放的printf的地址也可以啊。是的,如果这个printf地址放到变量中,也就是.data段,数据段,这个段系统规定可读可写,访问这个变量就可以了,如果这些函数很多,对应的变量也很多,就可以看成表了,这也是GOT表的概念,GOT表就是存这些函数的地址,不过这些编译器帮我们做了。GOT表但还有一些问题,后面再说。
  2. 就刚才的问题,就算可以直接修改代码,如果我们直接改了代码,就会打破操作系统文件共用的原则,做不了所有进程共用一个动态库的原则,所以我们要有更巧妙的方法来解决这个问题。如果我们通过PLT表,放在代码段,不再改变代码,这块代码能准确指引到正确的GOT,第一次时还能修改GOT表值,做到这样的效果,好像也能解决问题,实际就是这么做的。

知道上面两点,我们是通过运行时解决了地址问题,更专业的说法,叫运行重定位,延迟加载的动态绑定技术;相反编译的链接过程,就完成函数地址替换,称为链接重定位,下面我们接着讲如何完成运行重定位这一过程。

PLT的引入

我们先编译一下代码:

clang main.cpp -o out

然后用objdump静态反编译一下生成的可执行文件

objdump -d out

我们看一下代码段.text的main函数:

0000000000401160 <main>:
401160: 55 push %rbp
401161: 48 89 e5 mov %rsp,%rbp
401164: 48 81 ec 80 00 00 00 sub $0x80,%rsp
40116b: 31 f6 xor %esi,%esi
40116d: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
401174: 48 8d 45 90 lea -0x70(%rbp),%rax
401178: 48 89 c7 mov %rax,%rdi
40117b: ba 64 00 00 00 mov $0x64,%edx
401180: e8 bb fe ff ff callq 401040 <memset@plt>
401185: 48 bf 0b 20 40 00 00 movabs $0x40200b,%rdi
40118c: 00 00 00
40118f: b0 00 mov $0x0,%al
401191: e8 9a fe ff ff callq 401030 <printf@plt>
401196: 89 45 8c mov %eax,-0x74(%rbp)
401199: e8 a2 ff ff ff callq 401140 <_Z10testprintfv>
40119e: 31 c0 xor %eax,%eax
4011a0: 48 81 c4 80 00 00 00 add $0x80,%rsp
4011a7: 5d pop %rbp
4011a8: c3 retq
4011a9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)

仔细看偏移地址40116b,call printf,他用的是

401191:       e8 9a fe ff ff          callq  401030 <printf@plt>

分析:指令操作码e8,偏移跳转,而ff 15是绝对跳转。e8后面跟的是9a,fe,ff,ff,在x64小端模式下,就是0xfffffe9a,有符号数,是-358,根据intel的官方技术手册,间接跳转RIP = RIP + offset;这时RIP为401196h,减少358,0x401196h-358= 0x401030h,也就是callq 401030,objdump还帮我们分析出就是printf@plt的地址。

我们看一下.plt段

Disassembly of section .plt:

0000000000401020 <.plt>:
401020: ff 35 e2 2f 00 00 pushq 0x2fe2(%rip) # 404008 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 e4 2f 00 00 jmpq *0x2fe4(%rip) # 404010 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nopl 0x0(%rax)

0000000000401030 <printf@plt>:
401030: ff 25 e2 2f 00 00 jmpq *0x2fe2(%rip) # 404018 <printf@GLIBC_2.2.5>
401036: 68 00 00 00 00 pushq $0x0
40103b: e9 e0 ff ff ff jmpq 401020 <.plt>

0000000000401040 <memset@plt>:
401040: ff 25 da 2f 00 00 jmpq *0x2fda(%rip) # 404020 <memset@GLIBC_2.2.5>
401046: 68 01 00 00 00 pushq $0x1
40104b: e9 d0 ff ff ff jmpq 401020 <.plt>

我们解读一下,当调用printf("begin");时,并没有直接调用printf函数,而是跳转到.plt段的401030的地址,先是跳转到*0x2fe2(%rip)的地方去执行代码,这里也不是printf函数的地址,怎么就做到的呢,我先不说,用GDB调试一下。

GDB调试PLT流程

我们依次输入下面指令:

gdb ./out

b *0x401191

r

会触发我们设置的断点,然后si单指令进入printf@plt的代码 反汇编一下

(gdb) x /5i $pc
=> 0x401030 <printf@plt>: jmpq *0x2fe2(%rip) # 0x404018 <printf@got.plt>
0x401036 <printf@plt+6>: pushq $0x0
0x40103b <printf@plt+11>: jmpq 0x401020
0x401040 <memset@plt>: jmpq *0x2fda(%rip) # 0x404020 <memset@got.plt>
0x401046 <memset@plt+6>: pushq $0x1

要绝对跳转404018地址对对应的内存进行访问,怎么算的呢? 看jump的机器码:ff 25 e2 2f 00 00,操作码 ff 25可知是 jump m/r模式,后面e2 2f 00 00,由于是小端模式,所以换算一下就是0x2fe2,那么就是(RIP + 0x2fe2)地址对应的内存值,可以看出现在RIP为0x401036,算一下也就0x404018,刚好是printf@got.plt对应GOT表中的内存地址。

注意: 我们现在是用的x64模式,能直接访问RIP的值,但x86的32位模式,无法直接访问eip,我们要通过其它手段来获得eip,这个很简单,比如mov eax,[esp];ret;就只可以得到,

我们继续看一下GOT表中内存值

(gdb) x /gx 0x404018
0x404018 <printf@got.plt>: 0x0000000000401036

可以看出,这里是第一次,并没有被填充到printf函数的地址,而是printf@plt的下一条指令地址,我们继续单步,先push压了一个0,然后跳转到401020地址。我们可以看一下这个jump的操作码:e9,偏移跳转,偏移e0 ff ff ff,小端模式,和前面计算方式一样,跳到401020地址,这个地址是什么,这个地址是.plt的开始地址。

反汇编一下:

(gdb) x /5i $pc
=> 0x401020: pushq 0x2fe2(%rip) # 0x404008
0x401026: jmpq *0x2fe4(%rip) # 0x404010
0x40102c: nopl 0x0(%rax)
0x401030 <printf@plt>: jmpq *0x2fe2(%rip) # 0x404018 <printf@got.plt>
0x401036 <printf@plt+6>: pushq $0x0

先看一下GOT表对应的内存

(gdb) x /4xg 0x404000
0x404000: 0x0000000000403e20 0x00007fffff7df190
0x404010: 0x00007fffff7c8bb0 0x0000000000401036

同上面分析RIP偏移法,将0x404008h地址所对应的内容放到栈中,然后跳转到0x404010来执行,我们看一下这一内存的值,然后跳转到了 ld-linux-x86-64.so.2 这个so我们调用完,就是将printf函数的地址,写入到Got表中,404018 ,GOT表以404000开始,我们是64位的,也就是偏移+3的地方,第4个数据。这里我们有些问题,为什么放到表中第4个数据呢?我们再观察一下前面的.plt段,就会发现,printf对应push 0,memset对应push 1,按理论应该printf放到GOT表中第一项,memset放到表中第2项,实际是因为GOT表中前三项已被占用了,有特别的用处。 GOT[0]:自身模块的dynamic段地址,这里是0x0000000000403e20, GOT[1]:本模块的link_map地址,这里是0x00007fffff7df190 GOT[2]:系统模块中的_dl_runtime_resolve函数地址,这里是0x00007fffff7c8bb0 所以printf函数地址放到GOT[3],从第4项开始,是正常的,这里第一次是0x0000000000401036。

从这里我们明白了,jmpq *0x2fe4(%rip)就是跳到了GOT[2]中_dl_runtime_resolve的函数里面了,这个函数在linux的库中,算也挺复杂的,这里调用完这个函数,会将printf的地址写到GOT[3]中,并跳转到printf函数执行。

//调用形式为:
_dl_runtime_resolve((link_map*)(got[1]),0);
// 第一个参数为.plt开头处刚刚0x401026: jmpq *0x2fe4(%rip) # 0x404010
// 地址为got[1]的值,也就是0x00007fffff7df190

// 第二个参数0,为<printf@plt>:中push 0;
// 0x401036 <printf@plt+6>: pushq $0x0
// 同理如果是memset,就是<memset@plt>:中push 1;
// 0x401046 <memset@plt+6>: pushq $0x1

我们看一下内存,注意GOT[3]已经被写成了0x00007fffff614e10,为printf的函数内存地址

(gdb) x /4xg 0x404000
0x404000: 0x0000000000403e20 0x00007fffff7df190
0x404010: 0x00007fffff7c8bb0 0x00007fffff614e10

为了这个,我们可以设置条件断点来监视一下,从此当我们下次调用printf函数,就不再走这一大圈的流程,直接就会调到GOT[3]中真正的printf地址。

GOT表数据第一次填充: 进程从二进制装载时,GOT表函数第一次是被ld-linux-x86-64.so.2填充为.plt表中地址,然后直到调用真正函数,才像上面分析的那样,填充为真正的函数地址。

总结:

plt和got配合的延迟绑定加载,可以降低程序的启动时间,只有外部函数被调用了才真正动态加载,只需要加载一次,后续再调用,无需重复,但略为增加了开销,最致命的是,GOT表是可写的,可以被外挂利用起来,达到替换函数攻击。