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表

image-20220508133405845

​ 简单的说,调用系统函数时,调用语句并不直接指向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
2
3
4
5
6
7
8
9
10
void printf@plt()
{
if (printf@got[0] != RELOCATED) { // 如果没完成重定位
call relocate(got)//调用重定位函数
printf@got[1] = 地址解析发现的printf地址;
printf@got[0] = RELOCATED;
}

jmp *printf@got[1];
}

​ 这个方案是可行的,但是每个函数就必须使用两个GOT表项(存地址的,和存状态的),占用内存明显增长了一倍,而且非常冗余,看着难受。Linux动态链接器想出了一个绝妙的方案,将这两个GOT表项合二为一。

​ 具体怎么做呢?

1
2
3
4
5
6
7
8
9
void printf@plt()
{
address_good:
jmp *printf@got // 链接器将printf@got填成下一语句lookup_printf的地址

lookup_printf:
调用重定位函数查找printf地址,并写到printf@got
goto address_good;
}

​ 在链接成可执行文件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
2
3
4
5
6
7
$ readelf -r test
....
Relocation section '.rel.plt' at offset 0x25c contains 3 entries:
Offset Info Type Sym.Value Sym. Name
080496f8 00000107 R_386_JUMP_SLOT 00000000 puts
080496fc 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__
08049700 00000407 R_386_JUMP_SLOT 000000000 __libc_start_main

​ 按照这个表中的偏移进行传参,比如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表总览

image-20220508160003226

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图:

image-20220508160539703

​ 动态链接器怎么知道GOT的首地址?其实这个值记录在ELF的**.dynamic段**里面。

0x08 重定位前后概况

image-20220508160809867

参考上文中对于延迟绑定重定位的解释,在第一次调用put函数时,实际的重定位分为九部:

  1. 代码调用puts函数,调用时EIP跳转到plt表中;
  2. plt表中跳转指令指向GOT表中的初值;
  3. 初值直接跳转到**_dl_runtime_resolve函数**的执行流程;
  4. 该执行流程跳转到plt表的common头
  5. plt表的common头中的跳转指令的参数指向got表第三个表项,也就是**_dl_runtime_resolve函数**的地址;
  6. 传参(参数是要查询的函数的序列号,通过**.rel.plt记录)后,跳转到_dl_runtime_resolve函数**;
  7. _dl_runtime_resolve函数执行时修改GOT表中的对应值;
  8. _dl_runtime_resolve函数执行完毕后直接跳转到puts函数中;
  9. 返回调用母函数;

下一次调用时就简单多了,如下:

image-20220508161828578

至此全文完,感谢CSDN博主「海枫」的优秀教程!