GOT与PLT
一、前言
这两天在研究ELF文件结构,遇到这个got和plt的核心部分,故记录下来分享。
GOT(Global Offset Table)全局偏移表,PLT(Procedure Linkage Table)过程链接表。是实现地址无关代码与延迟绑定的灵魂。其核心在于"解耦":代码层通过PLT这一固定跳板发起调用,而运行时的真实函数地址则是由GOT这个表实时维护。
二、GOT表
在动态链接的过程中,共享对象(.so等)最终装载地址在编译时期是不确定的,而是在装载时,装载器根据当前地址空间情况分配。而共享对象当中可能会有很多绝对地址的引用,如果共享对象被装载在了其他位置,这些绝对地址也需要更改,解决这个问题的方法就是:把静态链接中的重定位推迟到模块装载完成之后,这个时候目标i地址确定,就可以对所有绝对地址引用进行重定位了。
这个过程也叫做装载时重定位,和windows下的基址重置(Rebasing)相似。
地址无关代码
装载时重定位时解决动态模块中有绝对地址引用的办法之一,但是有一个很大的缺点,就是指令部分无法在多个进程之间共享。这时候需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。
这里的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。实现的基本想法就是将指令中那些需要被修改的部分分离出来,根据数据部分放在一起,这样指令部分就能够保持不变,而数据部分可以在每个进程中拥有一个副本,这种方法就是地址无关代码技术。
这段话读起来可能有点拗口,这里解析一下:
首先共享库,系统中可能有100个进程都在使用它。比如libc.so在物理内存中保留一份libc的指令代码,所有进程都映射这同一块物理内存,这样就能极大地节省内存空间。
但是在装载时重定位破坏了这个机制:
- 假设现在代码中有一条指令是:
mov eax, [0x1000],0x1000是一个绝对地址
- **进程A:**将该模块加载到
0x0000,那么这条指令就是对的
- **进程B:**将该模块加载到
0x2000,那么就需要修改为mov eax, [0x3000]
后果就是,一旦进程B修改了这段代码指令,这段物理内存就变了,就无法跟进程A共享。操作系统不得不为进程B复制一份全新的代码页,这就是指令部分无法在多个进程共享
这里先来了解一下模块中的各种类型的地址引用方式
模块中地址引用方式
这里将共享对象模块中的地址引用按照是否为跨模块分为两类:模块内部引用和模块外部引用。而按照不同引用方式可分为:指令引用和数据访问。这样就有4种情况:
- 模块内部的函数调用、跳转等
- 模块内部的数据访问,如模块中定义的全局变量、静态变量
- 模块外部的数据访问,比如定义在其他模块中的全局变量
- 模块外部的函数调用、跳转
使用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | static int a;
extern int b;
extern void ext();
void bar()
{
a = 1;
b = 2;
}
void foo()
{
bar();
ext();
}
|
类型一 模块内部调用或跳转
这种类型是最简单的。被调用的函数与调用这都处于一个模块,他们相对位置是固定的。而call,jmp这些指令都是相对地址调用的,对于这种指令来说不需要重定位。例如下面这一段汇编:

这是在foo函数中调用bar函数,通过硬编码可以看到这个call 0x8048344的字节码是:e8 e8 ff ff ff,而后面这个0xffffffe8其实就是下一条指令到指定地址的偏移:
1 | 0xffffffe8(-24) = 0x8048344 - 0x804835C
|
那么其实只要这个两个函数的相对位置不变,这条指令就是地址无关的。
这里还存在一个全局符号介入的问题,这里先不管
类型二 模块内部数据访问
这种类型很明显也不能够直接包含数据的绝对地址,那么唯一的方法就是相对寻址。一般来说,一个模块是若干个页的代码后面紧跟若干个页的数据。这些也的相对位置是固定的,那么只需要相对于当前指令地址加上固定偏移量就能访问模块内部数据了。elf当中使用了一个很巧妙的方式来获取当前指令位置:
call get_pc_thunk.cx
add ecx, xxxx
mov [ecx], xxxx
<get_pc_thunk.cx>:
mov ecx, [esp]
ret
通过这么一个get_pc_thunk就能够获取到add ecx, xxxx的指令地址了。原理就是call会将下一条指令地址入栈,此时栈顶就是下一条指令地址,然后再将该值赋值给寄存器即可。
类型三 模块间数据访问
模块间的数据访问就比较麻烦了,因为模块间的数据访问目标地址需要等到装载时才能决定。例如上边例子的变量b,他被定义在其他模块中,并且加载时才能确定。要使得代码这个变量b代码地址无关,基本思想就是将地址相关的部分放到数据段中。ELF的做法就是在数据段当中建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table, GOT),当需要引用这些全局变量的时候,可以通过GOT表中对应的项间接引用

需要访问变量b的时候,程序先访问got表,拿到b地址之后,再访问目标地址。
在装载的过程中,链接器会查找每个变量所在的地址,然后填充GOT表的各个项,以确保每个指针指向正确的位置。而GOT表本身就是放在数据段的,每个进程都有独立的副本,所以就是地址无关的。
类型四 模块间调用、跳转
对于模块间的调用和跳转也可以使用上面类型三的方法来解决。不同的是,got表项中存放的是目标函数地址。

小结
通过对地址无关代码的了解从而引申出来了GOT表,这个表是为了解决动态链接过程中模块外部的数据访问和跳转调用问题的。
三、延迟绑定(PLT)
动态链接问题
动态链接比静态链接灵活得多,但它是以牺牲一部分性能为代价的。而动态链接比静态链接慢的主要原因是动态链接下对与全局和静态的数据访问都要进行复杂GOT定位,然后间接寻址。对于模块的调用也要先定位GOT表,然后再间接跳转,这样一来程序运行速度就会降低。
另外一个减慢运行速度的原因是动态链接的连接工作,在程序开始时会进行一次链接工作,此时动态链接器会寻找并装载所需要的共享对象,然后进行地址重定位等工作。这些工作会减慢程序启动的时间。
这是影响动态链接性能的两个主要问题。接下来就来介绍一下优化动态链接性能的方式
延迟绑定
在动态链接下,程序模块之间包含了大量的函数引用。但是在程序的一次运行当中,可能很多的函数都用不上,此时在启动时进行链接就变成了一种浪费。所以ELF采用了一种延迟绑定(Lazy Binding)的做法,基本思想就是当函数第一次被使用到的时候才进行绑定。这种做法可以大大加快程序的启动速度。
ELF使用PLT(Procedure Linkage Table)的方法来实现延迟绑定。
绑定函数猜测
在开始详细介绍PLT之前,先从动态链接器的角度设想一下:当第一次调用某个动态库的xxx函数的时候,需要调用这么一个绑定函数来完成绑定工作,这里假设这个函数叫做**lookup。那么lookup函数,至少需要知道这个绑定发生在哪个模块**,哪个函数?那么就可以假设这个函数原型是:lookup(module, function)。在Glibc当中,这个函数真正的名字叫做:_dl_runtime_resolve()
具体实现
当我们调用某个外部函数的时候,通常是通过GOT相应的项进行简介跳转。PLT为了实现延迟绑定,在这个过程中又增加了一层间接跳转,通过一个叫做PLT项的结构来进行跳转。每个外部函数在plt中都有一个对应的项,这里随便举一个例:test()函数在plt中的项叫做bar@plt,理论上来说,每一个plt项的实现差不多都是这样的:
dtest@plt:
jmp [test@GOT]
push n
push moduleID
jmp _dl_runtime_resolve
test@plt第一条指令就是通过GOT间接跳转的指令。为了实现延迟绑定,链接器在初始化阶段将push n的地址填入到test@GOT中,这个过程不需要链接符号,所以速度很快。
第二条指令push n,这个指令当中的n是test这个符号引用在.rela.plt或者.rel.plt中的下标。
第三条指令是push moduleID,这个moduleID是一个指向link_map结构的指针,_dl_runtime_resolve通过这个link_map可以获取到其他模块的基址。
最后就是调用_dl_runtime_resolve,_dl_runtime_resolve经过一系列操作之后将test真正的地址填入到test@GOT当中
一旦test()这个函数被解析完毕,再次调用test@plt时,第一条jmp指令就能跳转到真正的test()函数当中。
PLT实际结构
上面描述的时PLT的基本原理,PLT真实的实现比这个结构复杂一些,ELF将GOT拆分成两个表 .got和.got.plt。其中.got用来保存全局变量的引用地址,.got.plt用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了.got.plt。
其中.got.plt前三项是有特殊含义的,如下:
- 第一项:
.dynamic段的地址,这个段描述了本模块动态链接的相关信息
- **第二项:**本模块的
moduleID
- 第三项:
_dl_runtime_resolve()
其中第二项和第三项有动态链接器在装载共享模块的时候将他们初始化。在IDA中就是这样的:

接着就是plt项的结构,与上面的也不太一样。实际上,push moduleID和jmp _dl_runtime_resolve都是可以复用的,elf中将这块部分放到了PLT[0]当中,而这两个指令的位置就换成了jmp PLT[0]的位置:
PLT0:
push [GOT+4]
jmp [GOT+8]
...
test@plt:
jmp [test@GOT]
push n
jmp PLT0
ida反汇编如下:

小结
通过上面的分析,我们可以画出绑定前和绑定后的两个结构图
绑定前:

绑定后:

参考
第7章 动态链接 - 程序员的自我修养
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 6小时前
被x0rrrrr编辑
,原因: