PLT表与GOT表延迟绑定机制
Linux动态链接中的PLT和GOT
动态链接和静态链接的简单理解
可以理解为:如果文章引用了别人的一部分文字,把别人的段落复制到我的文章中·,就属于静态链接,动态链接就可以理解为给一个超链接让自己去看
PLT和GOT
Linux下的动态链接是通过PLT & GOT实现的,使用测试代码test.c来进行理解
|
使用下列命令:
编译:gcc -Wall -g -o test.o -c test.c -m32
链接:gcc -o test test.o -m32
现在的Linux系统为x86_64系统,因为后续需要对中间文件test.o以及可执行文件test反编译,分析汇编指令,因此使用-m32来生成i386架构指令
通过objdump -d test.o
查看反汇编
00000000 <print_banner>: |
print_banner()内调用了printf(),printf()函数在glibc动态库里。在编译和链接阶段,链接器无法知道进程运行起来后printf函数的加载地址,所以上面call <print_banner+0x22>
是无法填充的,只有进程运行起来以后,printf函数的地址才能确定。
接下来要做的就是修改(重定位)call指令,简单的办法就是将指令中的printf_banner改为printf真正的函数地址,但是会出现两个问题:
目前的操作系统不允许修改代码段,只能够修改数据段
如果printf_banner是类似于printf()一样的函数,在动态链接库(.so对象内),那么修改它就没有办法做到系统内所有进程共享同一个动态库。
因此,printf函数地址只能回写到数据段内,而不能回写到代码段上。
回写:指运行时修改,即运行时重定位,与之对应的还有链接时重定位。
编译阶段,是将.c文件的源代码翻译成汇编指令的中间文件,即上面生成的.o中间文件。分析上面生成的printf_banner的汇编指令:
00000000 <print_banner>: |
可以注意到,call指令的操作数为e8 fc ff ff ff
,由x86的小端序可以翻译为0xfffffffc,即有符号数-4。这里应该存放printf函数的地址,但是由于编译阶段无法知道printf函数的地址,所以预先放一个-4,用重定位项来描述:这个地址在链接时会被修正,它的修正值是根据printf的地址(符号)来修正,修正方式按相对引用方式。这个过程被称为链接时重定位,与上面的运行时重定位原理完全一样,只是修正的时机不一样。
链接阶段,是将一个或多个中间文件(.o文件)通过链接器将它们链接成一个可执行文件,链接阶段的主要任务:
- 将各个中间文件之间同名的section合并
- 对代码段、数据段以及各符号段进行地址分配
- 链接时重定位修正
除了重定位过程,其他的动作不能够修改中间文件的函数体内指令,重定位也不能修改编译过程生成的汇编指令,只能修改指令中的操作数。
但是在编译阶段,编译器没有办法知道printf函数是在glibc运行库还是在其他的.o中,那么编译器只能生成调用printf的指令,不管printf在glibc还是在其他.o定义都能工作。如果是在其他的.o中定义了printf函数,那么在链接阶段,printf的地址就已经确定,可以直接重定位。如果printf定义在动态库内(定义在动态库内不知道地址),链接阶段无法做重定位。
前面说运行时重定位无法修改代码,只能将printf重定位到数据段,那么就需要call指令感知到重定位好的数据段内容。链接器生成一段额外的小代码片段,通过这段代码获取printf函数地址,并完成对它的调用。
链接器生成的额外代码如下:
.text |
链接阶段能够发现printf函数在定义动态库时,链接器会生成一段代码printf_stub,printf_stub取代原来的printf,因此转化为链接阶段对printf_stub做链接重定位,而运行时才对printf做运行时重定位。
总结来说,动态链接每个函数需要两个东西:
- 用来存放外部函数地址的数据段
- 用来获取数据段记录的外部函数地址的代码
如果可执行文件中调用多个动态库函数,那每个函数都需要这两样东西,这样每样东西就形成一个表,每个函数使用其中一项。对应两个表,用来存放外部函数地址的数据表称为全局偏移表(GOT,Global Offset Table),存放额外代码的表称为程序链接表(PLT,Procedure Link Table)。
用大佬的文章里的示意图来说明PLT&GOT是怎么运行的:
可执行文件里面保存的是PLT表的地址,对应PLT地址指向的是GOT的地址,GOT表指向的是glibc的地址,当然这个图不能完整地描述Linux下的PLT&GOT真实过程。
延迟重定位
前面说到的PLT从GOT表中获取地址并完成调用,这个前提是GOT必须在PLT执行之前,所有函数都已经完成运行时重定位。
在Linux中,fork之后的父子进程内存的写时拷贝机制、Linux用户态内存空间分配、C++库的string类写时拷贝机制、动态链接中的延迟重定位机制,都会尽可能地延迟退后,直到无法回避才做最后的修正工作。
当可执行文件调用的动态库很多时,在进程初始化时都会对这些函数做地址解析和重定位工作,大大增加进程的启动时间, 所以Linux提出延时重定位机制,只有动态库函数被调用时,才会对地址解析和重定位工作。
要实现等到调用函数时才做中定位工作的机制就要有一个状态描述该GOT表是否已完成重定位。
首先能想到的办法就是在GOT表中增加一个状态位,用来描述GOT表项是否已完成重定位,那么每个函数就有两个GOT表项了。
相应的伪代码如下:
void printf@plt() |
使用这个方案会导致占用内存增加一倍,但是仔细观察能发现,这两项明显不会出现同时使用的情况,那么直接用一个GOT项来实现。Linux动态链接器就使用了类似方案。
把上面的伪代码倒过来写就可以得到新的伪代码:
void printf@plt() |
在链接成可执行文件test时,链接器将printf@got表项的内容填写lookup_printf的地址。
即程序第一次调用printf时,通过printf@got表引导到查找printf的plt指令的后半部分,在后半部分中跳转到动态链接器中将printf地址解析出来,并重定位回printf@got项内。
在第二次调用printf时,通过printf@got直接跳到printf执行。
通过 objdump -d test > test.asm
可以看到其中 plt 表项有三条指令:
Disassembly of section .plt: |
(将第一项plt表修改成**<common@plt>
**项,objdump -d输出结果会使用错误的符号名。那是因为该项是没有符号的,而objdump输出时,给它找了一个地址接近符号,所以会显示错误的符号名,为了避免引起误解,直接删掉)
每个plt指令中的jmp指令都是访问相应的got表项,在函数第一次调用之前,这些got项的内容都是链接器生成的,它的值指向plt中jmp的下一条指令。
可以通过gdb查看got表内容:(先 gdb test
然后 b main
,再 run
, 再 x/x 地址
或x/xw 地址
就可以)
0x080182e6
即jmp下一条的指令地址。
push $0x0
将数据压到栈上,作为将要执行的函数的参数。
jmp 80482d0
跳转到第一个表项,所有的plt都跳转到common@plt中执行,这是动态链接做符号解析和动态链接的公共入口,并不是每个plt表都有的重复的一份指令。
公共GOT表项
将前面提到的公共plt摘取出来:
080482d0 <common@plt-0x10>: |
pushl 0x804a004
是将地址压到栈上,向最终调用的函数传递参数,jmp *0x804a008
跳到最终的函数去执行,即跳到能解析动态库函数地址的代码里面执行
gdb-peda$ file test |
可以看出,进程还没有运行时,值为0x00000000,当进程运行起来时,值变为了0xf7fee000,如果做更进一步的调试会发现这个地址位于动态链接器内,对应的函数为**_dl_runtime_resolve**。
接下来需要解决三个问题:
- _dll_runtime_resolve是怎么知道要查找printf函数的
- _dll_runtime_resolve找到printf函数地址后,怎么知道回写到哪个GOT表项
- 到底 _dll_runtime_resolve是什么时候被写到GOT表的
前两个问题,以printf@plt为例:
printf@plt>: |
第二条指令中,每个xxx@plt的第二条指令push的操作数都不一样,相当于函数的id,动态链接器通过它可以知道要解析哪个函数。
使用readelf -r test
命令可以查看test可执行文件中的重定位信息,查看.rel.plt这段:
giantbranch@ubuntu:~/Desktop/PWN$ readelf -r test |
和前面各函数plt指令中的push操作数结合起来看:
printf对应push 0x0, _libc_start_main对应push 0x8,这些操作数对应这两个函数在.rel.plt段的偏移量,在 _dl_runtime_resolve函数内,根据这个offset和.rel.plt段的信息,就知道要解析的函数。.rel.plt最左边的offset字段,它就是GOT表项的地址,也即 _dl_runtime_resolve做完符号解析之后,重定位回写的空间。
第三个问题, 可执行文件在Linux内核通过exeve装载完成后,不直接执行,而是先跳到动态链接器(Id-linux-XXX)执行,在Id-linux-XXX内将_dl_runtime_resolve地址写到GOT表内。
在i386架构下,除了每个函数占用一个GOT表项以外,GOT表项还保留了三个公共表项,也就是GOT表的前三项,分别为:
- GOT[0]:本ELF动态段(。dynamic段)的装载地址
- GOT[1]:本ELF的link_map数据结构描述符地址
- GOT[2]:_dl_runtime_resolve函数的地址
动态链接器在加载完ELF后,都会将这三个地址写入GOT表的前三项。
关于GOT[1]地址,只有link_map结构,结合.rel.plt段的偏移量,才能真正找到该elf的.rel.plt表项。
总结
关系图
下图为编译完成后,PLT和GOT的关系图:
图中重点标注了从调用printf函数语句的汇编指令call puts@plt跳转过程,序号为跳转顺序
PLT表结构有如下特点:
- PLT表的第一项为公共表项,剩下的是每个动态库函数为一项
- 每项PLT都从对应的GOT表项中读取目标函数地址
GOT表结构有如下特点:
- GOT表中的前3个为特殊项,分别用于保存.dynamic段地址、本镜像的link_map数据结构地址和_dl_runtime_resolve函数地址,但在编译时,无法获取知道link_map地址和dl_runtime_resolve函数地址,所以编译时填零地址,进程启动时有动态链接器进行填充
- 3个特殊项后面依次是每个动态库函数的GOT表项
可以抽象为以下的伪代码:
plt[0]: |
进程启动以后的GOT表
PLT属于代码段,在进程加载和运行过程都不会发生改变,PLT指向GOT表的关系在编译时已完全确定,唯一能变化的是GOT表。
Linux加载进程时,通过execve系统调用进入内核态,将镜像加载到内存,然后返回用户态执行。返回用户态时,它的控制权并不是交给可执行文件,而是给动态链接器去完成一些基础的功能,比如上述的GOT[1],GOT[2]的填写就是这个阶段完成的,下面是动态链接器填完GOT[1],GOT[2]后的GOT图:
使用readelf -d test
(可以在ELF中寻找动态节并显示)命令显示ELF文件的.dynamic段,通过PLTGOT项,动态链接器可以知道GOT表的首地址
giantbranch@ubuntu:~/Desktop/PWN$ readelf -d test |