Linux动态链接中的GOT和PLT
本文主要讨论什么是延迟加载?什么是PLT与GOT表?PLT表与GOT表到底建立跳转关系的?延迟加载有好处与弊端?
GOT和PLT是什么
PLT:Procedure Link Table,程序链接表。
GOT:Global Offset Table,全局偏移表。
这两个表相互配合解决外部函数符号地址,解决运行时重定位的问题。这种方法能让函数在调用时才确定地址,进程的启动时间加快,只需一次绑定,也称为延迟绑定,接下来通过例子示意。
代码示意与分析
|
上面是一段非常简单的C程序,就调一个printf
函数,一个自定义的testprintf
函数。其中我们知道printf
是libc.so里面的函数,在默认情况下,glibc是在运行时动态链接的,当程序callprintf
函数时,并不知printf
的真正地址,只有libc.so装载时,才能知道具体地址。看起来编译时链接是完不成这件事了,编译时如果能留出来地址备用,当进程加载启动时告诉操作系统,我需要知道printf真正地址,能正确替换出来。
实际就是这么做的,但我们再具体一点,结合问题分析。
- 当进程启动,libc.so装载完毕,那么printf对应二进制代码所在.text的地址也确定了,就是说printf函数,在我的进程中也是明确了,那么我只要修改一下call printf对应汇编二进制代码,改为printf正确的地址也可以,但现在操作系统在不做特殊操作情况下,是不允许我们修改代码段的(实际还是可以修改的),这样直接行不通,我们可以间接加一层处理,call一个特定的内存地址,这个地址存放的printf的地址也可以啊。是的,如果这个printf地址放到变量中,也就是.data段,数据段,这个段系统规定可读可写,访问这个变量就可以了,如果这些函数很多,对应的变量也很多,就可以看成表了,这也是GOT表的概念,GOT表就是存这些函数的地址,不过这些编译器帮我们做了。GOT表但还有一些问题,后面再说。
- 就刚才的问题,就算可以直接修改代码,如果我们直接改了代码,就会打破操作系统文件共用的原则,做不了所有进程共用一个动态库的原则,所以我们要有更巧妙的方法来解决这个问题。如果我们通过PLT表,放在代码段,不再改变代码,这块代码能准确指引到正确的GOT,第一次时还能修改GOT表值,做到这样的效果,好像也能解决问题,实际就是这么做的。
知道上面两点,我们是通过运行时解决了地址问题,更专业的说法,叫运行重定位,延迟加载的动态绑定技术;相反编译的链接过程,就完成函数地址替换,称为链接重定位,下面我们接着讲如何完成运行重定位这一过程。
PLT的引入
我们先编译一下代码:
clang main.cpp -o out
然后用objdump静态反编译一下生成的可执行文件
objdump -d out
我们看一下代码段.text的main函数:
0000000000401160 <main>: |
仔细看偏移地址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: |
我们解读一下,当调用printf("begin");时,并没有直接调用printf函数,而是跳转到.plt段的401030的地址,先是跳转到*0x2fe2(%rip)的地方去执行代码,这里也不是printf函数的地址,怎么就做到的呢,我先不说,用GDB调试一下。
GDB调试PLT流程
我们依次输入下面指令:
gdb ./out
b *0x401191
r
会触发我们设置的断点,然后si单指令进入printf@plt的代码 反汇编一下
(gdb) x /5i $pc |
要绝对跳转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 |
可以看出,这里是第一次,并没有被填充到printf函数的地址,而是printf@plt的下一条指令地址,我们继续单步,先push压了一个0,然后跳转到401020地址。我们可以看一下这个jump的操作码:e9,偏移跳转,偏移e0 ff ff ff,小端模式,和前面计算方式一样,跳到401020地址,这个地址是什么,这个地址是.plt的开始地址。
反汇编一下:
(gdb) x /5i $pc |
先看一下GOT表对应的内存
(gdb) x /4xg 0x404000 |
同上面分析RIP偏移法,将0x404008h地址所对应的内容放到栈中,然后跳转到0x404010来执行,我们看一下这一内存的值,然后跳转到了 ld-linux-x86-64.so.2 这个so我们调用完,就是将printf函数的地址,写入到Got表中,404018 printf@GLIBC_2.2.5,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函数执行。
//调用形式为: |
我们看一下内存,注意GOT[3]已经被写成了0x00007fffff614e10,为printf的函数内存地址
(gdb) x /4xg 0x404000 |
为了这个,我们可以设置条件断点来监视一下,从此当我们下次调用printf函数,就不再走这一大圈的流程,直接就会调到GOT[3]中真正的printf地址。
GOT表数据第一次填充: 进程从二进制装载时,GOT表函数第一次是被ld-linux-x86-64.so.2填充为.plt表中地址,然后直到调用真正函数,才像上面分析的那样,填充为真正的函数地址。
总结:
plt和got配合的延迟绑定加载,可以降低程序的启动时间,只有外部函数被调用了才真正动态加载,只需要加载一次,后续再调用,无需重复,但略为增加了开销,最致命的是,GOT表是可写的,可以被外挂利用起来,达到替换函数攻击。