GOT and PLT

写在前头

​ 首先, 我们要知道, GOT和PLT只是一种重定向的实现方式. 所以为了理解他们的作用, 就要先知道什么是重定向, 以及我们为什么需要重定向.

重定向(relocations), 简单来说就是二进制文件中留下的”坑”, 预留给外部变量或函数. 这里的变量和函数统称为符号(symbols). 在编译期我们通常只知道外部符号的类型 (变量类型和函数原型), 而不需要知道具体的值(变量值和函数实现). 而这些预留的”坑”, 会在用到之前(链接期间或者运行期间)填上. 在链接期间填上主要通过工具链中的连接器, 比如GNU链接器ld; 在运行期间填上则通过动态连接器, 或者说解释器(interpreter)来实现.

符号表

函数和变量作为符号被存在可执行文件中, 不同类型的符号又聚合在一起, 称为符号表.
有两种类型的符号表, 一种是常规的(.symtab和.strtab), 另一种是动态的(.dynsym和.dynstr),
他们都在对应的section中, 以main为例:

1
2
3
4
5
6
7
$ readelf -S ./main
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 5] .dynsym DYNSYM 080481ec 0001ec 0000b0 10 A 6 1 4
[ 6] .dynstr STRTAB 0804829c 00029c 000085 00 A 0 0 1
...
[33] .symtab SYMTAB 00000000 00120c 000490 10 34 52 4
[34] .strtab STRTAB 00000000 00169c 0001e1 00 0 0 1

GOT表和PLT表:

GOT(Global Offset Table,全局偏移表)是Linux ELF文件中用于定位全局变量和函数的一个表。

这是链接器在执行链接时 实际上要填充的部分, 保存了所有外部符号的地址信息。 不过值得注意的是,在i386架构下, 除了每个函数占用一个GOT表项外,GOT表项还保留了 3个公共表项, 每项32位(4字节), 保存在前三个位置, 分别是:

  • got[0]: 本ELF动态段(.dynamic段)的装载地址
  • got[1]: 本ELF的link_map数据结构描述符地址
  • got[2]: _dl_runtime_resolve函数的地址

PLT(ProcedureLinkage Table,过程链接表)是Linux ELF文件中用于延迟绑定的表,即函数第一次被调用的时候才进行绑定。

这个表里包含了一些代码,用来(1)调用链接器来解析某个外部函数的地址, 并填充到.got.plt中, 然后跳转到该函数;或者(2)直接在.got.plt中查找并跳转到对应外部函数(如果已经填充过)。

延迟绑定:

所谓延迟绑定,就是当函数第一次被调用的时候才进行绑定(包括符号查找、重定位等),如果函数从来没有用到过就不进行绑定。基于延迟绑定可以大大加快程序的启动速度,特别有利于一些引用了大量函数的程序。

下面简单介绍一下延迟绑定的基本原理。假如存在一个bar函数,这个函数在PLT中的条目为bar@plt,在GOT中的条目为bar@got,那么在第一次调用bar函数的时候,首先会跳转到PLT,伪代码如下:

1
2
3
bar@plt:
jmp bar@got
patch bar@got

这里会从PLT跳转到GOT,如果函数从来没有调用过,那么这时候GOT会跳转回PLT并调用patch bar@got,这一行代码的作用是将bar函数真正的地址填充到bar@got,然后跳转到bar函数真正的地址执行代码。当我们下次再调用bar函数的时候,执行路径就是先后跳转到bar@plt、bar@got、bar真正的地址。具体来看个实例:

vulnerable_function函数调用了read函数,由于read函数是动态链接加载进来的只有在链接的时候才知道地址,编译时并不知道地址

执行call _read函数会跳到plt表中寻找:

4484910-cc89e9b35db2643a

plt表中会继续跳入到got表中寻找

4484910-36c8d9ecd0b0033f

got表中的所存的read函数的地址便是在so.6进程中的实际地址

4484910-7e00f562ffefb5c1

也就是

4484910-72c5fc694475e1f8

PLT->GOT调用过程:

图片1

图片2

1
2
3
4
Push n ;n是符号引用在重定位表中的下标。
Push 0x80496f0;是模块的id
Jmp *0x80496f4是_dl_runtime_resolve()函数地址,找到一个函数地址,必须知道是那个模块,哪个函数
Got表前三项是.dynamic地址;本模块id;_dl_runtime_resolve()地址

图片3

信息泄漏的实现

在进行缓冲区溢出攻击的时候,如果我们将EIP跳转到write函数执行,并且在栈上安排和write相关的参数,就可以泄漏指定内存地址上的内容。比如我们可以将某一个函数的GOT条目的地址传给write函数,就可以泄漏这个函数在进程空间中的真实地址。

如果泄漏一个系统调用的内存地址,结合libc.so.6文件,我们就可以推算出其他系统调用(比如system)的地址。

ibc.so.6文件的作用

在一些CTF的PWN题目中,经常可以看到题目除了提供ELF文件之外还提供了一个libc.so.6文件,那么这个额外提供的文件到底有什么用呢?

如果我们可以利用目标程序的漏洞来泄漏某一个函数的地址,那么我们就可以计算出system函数的地址了,当然,被泄露地址的函数必须也定义在libc.so.6中(libc.so.6中通常也存在有/bin/bash或者/bin/sh这个字符串)。

计算system函数地址的基本原理是,在libc.so.6中,各个函数的相对地址是固定的,比如函数A相对于libc.so.6的起始地址为addr_A,函数B相对于libc.so.6的起始地址为addr_B,那么,如果我们能够泄漏进程内存空间中函数A的地址address_A,那么函数B在进程空间中的地址就可以计算出来了,为address_A + addr_B - addr_A

相关知识:

LEAh和offset的区别:

lea 是机器指令,offset 是伪指令。

LEA BX, BUFFER ;在实际执行时才会将变量buffer的地址放入bx

MOV BX, OFFSET BUFFER ;在编译时就已经计算出buffer的地址为4300(假设),然后将上句替换为: mov bx,4300

lea可以进行比较复杂的计算,比如lea eax,[esi+ebx4],把ebx的值4,加上esi的值,存入eax中。
mov就不行了。

OFFSET只能取得用”数据定义伪指令”定义的变量的有效地址,不能取得一般操作数的有效地址(摘自80x86汇编语言程序设计教程)
MOV BX,OFFSET [BX+200]这句是错误的 应该用LEA BX,[BX+200]

lea eax,[ebp]
说明: eax得到ebp指向的堆栈内容的偏移地址, 和寄存器ebp的值是相同的

PIE相关

按照链接器的约定, 32位程序会加载到0x08048000这个地址中(为什么?),
所以我们写程序时, 可以以这个地址为基础, 对变量进行绝对地址寻址. 以main为例:

1
2
3
$ readelf -S ./main | grep "\.data"
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[25] .data PROGBITS 0804a014 001014 00000c 00 WA 0 0 4

.data部分在可执行文件中的偏移量为0x1014, 那么加载到虚拟内存中的地址应该是
0x8048000+0x1014=0x804a14, 正好和显示的结果一样. 再看看main函数的汇编代码:

(这里的0x08048000是虚拟地址)

按绝对地址寻址, 对可执行文件来说不是什么大问题, 因为一个进程只有一个主函数.
可对于动态链接库而言就比较麻烦, 如果每个.so文件都要求加载到某个绝对地址,
那简直是个噩梦, 因为你无法保证不和别人的.so加载地址冲突. 所以就有了位置无关代码的概念.
以位置无关的方式编译的main_pi, 来看看其相关信息:

1
2
3
$ readelf -S ./main_pi | grep "\.data"
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[25] .data PROGBITS 00002014 001014 00000c 00 WA 0 0 4

偏移量还是固定的, 但Addr部分不再是绝对地址. 也就是说程序可以加载到虚拟内存的任意位置.
听起来很神奇? 其实实现很简单, 继续看看main()的汇编:

(汇编指令长度是不固定的!不是定长16位、32位或者64位)

参考文献:

http://blog.sina.com.cn/s/blog_54f82cc201011oqv.html

https://www.jianshu.com/p/0ac63c3744dd

https://www.cnblogs.com/pannengzhi/p/2018-04-09-about-got-plt.html