pwn入门之got和plt表
pwn入门之got和plt表
本文摘自:https://linyt.blog.csdn.net/?type=blog CSDN博主「海枫」博客,感谢大佬详细的教程,仅供个人学习使用,请参考原文:
https://blog.csdn.net/linyt/article/details/51635768
https://blog.csdn.net/linyt/article/details/51636753
https://blog.csdn.net/linyt/article/details/51637832
https://linyt.blog.csdn.net/article/details/51893258
0x01 什么是got&plt表
简单的说,调用系统函数时,调用语句并不直接指向glibc,而是指向plt表中该函数的地址,plt表中该函数中记录了一句jmp指令,指令的参数为got表中的值。最后,通过plt表的跳转,跳到glibc的实际位置进行调用。
那么,plt表就是与函数调用相映射的跳板,用于跳到glibc的函数;而跳板需要一个地图,记录glibc中函数的位置,这个地图就是got表。
0x02 got表存在的意义何在
写c代码的时候,是没有库函数地址的,写完的东西也只是ascii,没有任何作用。
编译、链接的时候我们把c变成汇编,汇编再变成机器码,形成可执行文件格式(windows下的PE、linux下的elf)。这时候,一个elf拿在手里,这个elf里的系统调用可以有地址吗?答曰:无。
因为我们这个elf在各种系统上跑,也不一定是用的glibc库(即使确定是,加载的位置也可能不同),因此这个地址完全不能确定。也就是说,编译运行后的可执行文件里也没有地址。那么,哪有地址?
我们可以在程序运行之前,额外运行一个程序把地址装载到call指令的后面的操作数中,这样源程序就是一个有地址的程序了,可以吗?答曰:依然不行,有两条原因:
- 现代操作系统不允许修改代码段,只能修改数据段
- 如果print_banner函数是在一个动态库(.so对象)内,修改了代码段,那么它就无法做到系统内所有进程共享同一个动态库。
由于这样的背景,我们就必须要在代码段之外,也就是数据段中添加一个表格,来记录系统函数的地址,在运行的时候,把真正的地址装填进去,然后在代码段去调用数据段的内容就可以了。这里的数据段中记录函数地址的表,就是GOT表。那么这个找地址的事情由谁来做呢,就是动态链接器。
0x03 got表的地址被调用前,如何实现装填(延迟绑定)
首先,什么是延迟绑定?
在上文中,我们提到要开辟一个数据段中的表存各种库函数的地址,也就是程序运行时要维护这个表的内容(需要把动态链接库的地址填入got表),才能达成函数调用,那么,如何维护,是刚开始运行一次就把这个表填好,还是每次调用的时候往里填?linux系统选择了后者,原因如下:
如果可执行文件调用的动态库函数很多时,那在进程初始化时都对这些函数做地址解析和重定位工作,大大增加进程的启动时间。所以Linux提出延迟重定位机制,只有动态库函数在被调用时,才会地址解析和重定位工作,这就是延迟绑定。
那么,怎么实现延迟绑定?
进程启动时,先不对GOT表项做重定位,等到要调用该函数时才做重定位工作。要实现这个机制必须要有一个状态位,用于描述该GOT表项是否已完重定位。
正常的思路就像这样:
1 | void printf@plt() |
这个方案是可行的,但是每个函数就必须使用两个GOT表项(存地址的,和存状态的),占用内存明显增长了一倍,而且非常冗余,看着难受。Linux动态链接器想出了一个绝妙的方案,将这两个GOT表项合二为一。
具体怎么做呢?
1 | void printf@plt() |
在链接成可执行文件test时,链接器将printf@got表项的内容填写lookup_printf标签的地址。
就是说,程序第一次调用printf是时,通过printf@got表项引导到查找printf的plt指令的后半部分。在后半部分中跳到动态链接器中将printf址解析出来,并重定位回printf@got项内。第二次调用printf时,通过printf@got直接跳到printf函数执行了。简直太聪明了。
0x04 plt如何实现对于库函数的查找
所有动态库函数的plt指令最终都跳进(jmp)公共plt执行,而公共plt指向的是存在got表中**_dl_runtime_resolve**函数的地址。所有动态库函数在第一次调用时,都是通过XXX@plt -> 公共@plt -> _dl_runtime_resolve调用关系做地址解析和重定位的。
现在存在两个问题:
问题一,如何传参到**_dl_runtime_resolve函数?
问题二,如何获得_dl_runtime_resolve函数的返回值,即_dl_runtime_resolve**如何把值回填到GOT表?
这就涉及到了另一个表:**.rel.plt** 查看其内容:
1 | $ readelf -r test |
按照这个表中的偏移进行传参,比如puts偏移是0x00,main则是0x10。同时将offset字段作为返回地址,也就是offset字段记录了GOT表的地址。
0x05 _dl_runtime_resolve函数的装载
上文中,我们提到延迟绑定的时候查动态链接函数地址需要_dl_runtime_resolve函数, _dl_runtime_resolve函数的值是存在got表中的,但是 _dl_runtime_resolve函数的值是啥时候放进去的,谁放进去的?
答案很简单,可执行文件在Linux内核通过exeve装载完成之后,不直接执行,而是先跳到动态链接器(ld-linux-XXX)执行。在ld-linux-XXX里将_dl_runtime_resolve地址写到GOT表项内。
事实上,不单单是预先写_dl_runtime_resolve地址到GOT表项中,在i386架构下,除了每个函数占用一个GOT表项外,GOT表项还保留了3个公共表项,也即got的前3项,分别保存:
- got[0]: 本ELF动态段(.dynamic段)的装载地址
- got[1]:本ELF的link_map数据结构描述符地址
- got[2]:_dl_runtime_resolve函数的地址
动态链接器在加载完ELF之后,都会将这3地址写到GOT表的前3项。
其实上述公共的plt指令里面,还有一个操作数是没有分析的,其实它就是got[1](本ELF的link_map)地址,因为只有link_map结构,结合.rel.plt段的偏移量,才能真正找到该elf的.rel.plt表项。
0x06 编译后plt、got表总览
PLT表结构有以下特点:
- PLT表中的第一项为公共表项,剩下的是每个动态库函数为一项(当然每项是由多条指令组成的,jmp *0xXXXXXXXX这条指令是所有plt的开始指令);
- 每项PLT都从对应的GOT表项中读取目标函数地址;
GOT表结构有以下特点:
- GOT表中前3个为特殊项,分别用于保存 .dynamic段地址、本镜像的link_map数据结构地址和_dl_runtime_resolve函数地址;
- 在编译时,无法获取知道link_map地址和_dl_runtime_resolve函数地址,所以编译时填零地址,进程启动时由动态链接器进行填充;
- 3个特殊项后面依次是每个动态库函数的GOT表项;
0x07 运行时plt、got表总览
PLT属于代码段,在进程加载和运行过程都不会发生改变,PLT指向GOT表的关系在编译时已完全确定,唯一能发生变化的是GOT表。
Linux加载进程时,通过execve系统调用进入内核态,将镜像加载到内存,然后返回用户态执行。返回用户态时,它的控制权并不是交给可执行文件,而是给动态链接器去完成一些基础的功能,比如上述的GOT[1],GOT[2]的填写就是这个阶段完成的。下图是动态链接器填完GOT[1],GOT[2]后的GOT图:
动态链接器怎么知道GOT的首地址?其实这个值记录在ELF的**.dynamic段**里面。
0x08 重定位前后概况
参考上文中对于延迟绑定重定位的解释,在第一次调用put函数时,实际的重定位分为九部:
- 代码调用puts函数,调用时EIP跳转到plt表中;
- plt表中跳转指令指向GOT表中的初值;
- 初值直接跳转到**_dl_runtime_resolve函数**的执行流程;
- 该执行流程跳转到plt表的common头;
- plt表的common头中的跳转指令的参数指向got表第三个表项,也就是**_dl_runtime_resolve函数**的地址;
- 传参(参数是要查询的函数的序列号,通过**.rel.plt记录)后,跳转到_dl_runtime_resolve函数**;
- _dl_runtime_resolve函数执行时修改GOT表中的对应值;
- _dl_runtime_resolve函数执行完毕后直接跳转到puts函数中;
- 返回调用母函数;
下一次调用时就简单多了,如下:
至此全文完,感谢CSDN博主「海枫」的优秀教程!