PWN基础-动态链接
什么动态链接(Dynamic linking)
动态链接 是指在程序装载时通过 动态链接器 将程序所需的所有 动态链接库(Dynamic linking library) 装载至进程空间中( 程序按照模块拆分成各个相对独立的部分),当程序运行时才将他们链接在一起形成一个完整程序的过程。它诞生的最主要的原因是 静态链接 太过于浪费内存和磁盘的空间,并且现在的软件开发都是模块化开发,不同的模块都是由不同的厂家开发,在 静态链接 的情况下,一旦其中某一模块发生改变就会导致整个软件都需要重新编译,而通过 动态链接 的方式就推迟这个链接过程到了程序运行时进行。
优点
1、节省内存、磁盘空间
例如磁盘中有两个程序,p1、p2,且他们两个都包含 lib.o 这个模块,在 静态链接 的情况下他们在链接输出可执行文件时都会包含 lib.o 这个模块,这就造成了磁盘空间的浪费。当这两个程序运行时,内存中同样也就包含了这两个相同的模块,这也就使得内存空间被浪费。当系统中包含大量类似 lib.o 这种被多个程序共享的模块时,也就会造成很大空间的浪费。在 动态链接 的情况下,运行 p1 ,当系统发现需要用到 lib.o ,就会接着加载 lib.o 。这时我们运行 p2 ,就不需要重新加载 lib.o 了,因为此时 lib.o 已经在内存中了,系统仅需将两者链接起来,此时内存中就只有一个 lib.o 节省了内存空间。
2、程序更新更简单
比如程序 p1 所使用的 lib.o 是由第三方提供的,等到第三方更新、或者为 lib.o 打补丁的时候,p1 就需要拿到第三方最新更新的 lib.o ,重新链接后在将其发布给用户。程序依赖的模块越多,就越发显得不方便,毕竟都是从网络上获取新资源。在 动态链接 的情况下,第三方更新 lib.o 后,理论上只需要覆盖掉原有的 lib.o ,就不必重新链接整个程序,在程序下一次运行时,新版本的目标文件就会自动装载到内存并且链接起来,就完成了升级的目标。
3、增强程序扩展性和兼容性
动态链接 的程序在运行时可以动态地选择加载各种模块,也就是我们常常使用的插件。软件的开发商开发某个产品时会按照一定的规则制定好程序的接口,其他开发者就可以通过这种接口来编写符合要求的动态链接文件,以此来实现程序功能的扩展。增强兼容性是表现在 动态链接 的程序对不同平台的依赖差异性降低,比如对某个函数的实现机制不同,如果是 静态链接 的程序会为不同平台发布不同的版本,而在 动态链接 的情况下,只要不同的平台都能提供一个动态链接库包含该函数且接口相同,就只需用一个版本了。
总而言之,动态链接 的程序在运行时会根据自己所依赖的 动态链接库 ,通过 动态链接器 将他们加载至内存中,并在此时将他们链接成一个完整的程序。
Linux 系统中,ELF 动态链接文件被称为 动态共享对象(Dynamic Shared Objects) , 简称 共享对象 一般都是以 “.so” 为扩展名的文件;在 windows 系统中就是常常软件报错缺少xxx.dll 文件。
GOT(Global offset table)
共享对象 在被装载时,如何确定其在内存中的地址?
要使 共享对象 能在任意地址装载就需要利用到 装载时重定位 的思想,即在链接时对所有的绝对地址的引用不做重定位而将这一步推迟到装载时再完成,一旦装载模块确定,系统就对所有的绝对地址引用进行重定位。但是随之而来的问题是,指令部分无法在多个进程之间共享,这又产生了一个新的技术 地址无关代码 (PIC,Position-independent Code),该技术基本思想就是将指令中需要被修改的部分分离出来放在数据部分,这样就能保证指令部分不变且数据部分又可以在进程空间中保留一个副本,也就避免了不能节省空间的情况。那么重新定位后的程序是怎么进行数据访问和函数调用的呢?下面用实际代码验证 :
编写两个模块,一个是程序自身的代码模块,另一个是共享对象模块。以此来学习动态链接的程序是如何进行模块内、模块间的函数调用和数据访问
编写共享文件如下:got_extern.c
int b;
void test()
{
printf("test\n");
}
编译成32位共享对象文件:gcc got_extern.c -fPIC -shared -m32 -o got_extern.so
-fPIC选项是生成地址无关代码的代码,gcc 中还有另一个 -fpic 选项,差别是fPIC产生的代码较大但是跨平台性较强而fpic产生的代码较小,且生成速度更快但是在不同平台中会有限制。一般会采用fPIC选项-shared选项是生成共享对象文件-m32选项是编译成32位程序-o选项是定义输出文件的名称
编写代码模块如下got.c
static int a;
extern int b;
extern void test();
int fun()
{
a = 1;
b = 2;
}
int main(int argc, char const *argv[])
{
fun();
test();
printf("hey!");
return 0;
}
和共享模块一同编译:gcc got.c ./got_extern.so -m32 -o got
用 objdump 查看反汇编代码 objdump -D -Mintel got:000011b9 <fun>:
11b9: 55 push ebp
11ba: 89 e5 mov ebp,esp
11bc: e8 63 00 00 00 call 1224 <__x86.get_pc_thunk.ax>
11c1: 05 3f 2e 00 00 add eax,0x2e3f
11c6: c7 80 24 00 00 00 01 mov DWORD PTR [eax+0x24],0x1
11cd: 00 00 00
11d0: 8b 80 ec ff ff ff mov eax,DWORD PTR [eax-0x14]
11d6: c7 00 02 00 00 00 mov DWORD PTR [eax],0x2
11dc: 90 nop
11dd: 5d pop ebp
11de: c3 ret
000011df <main>:
11df: 8d 4c 24 04 lea ecx,[esp+0x4]
11e3: 83 e4 f0 and esp,0xfffffff0
11e6: ff 71 fc push DWORD PTR [ecx-0x4]
11e9: 55 push ebp
11ea: 89 e5 mov ebp,esp
11ec: 53 push ebx
11ed: 51 push ecx
11ee: e8 cd fe ff ff call 10c0 <__x86.get_pc_thunk.bx>
11f3: 81 c3 0d 2e 00 00 add ebx,0x2e0d
11f9: e8 bb ff ff ff call 11b9 <fun>
11fe: e8 5d fe ff ff call 1060 <test@plt>
1203: 83 ec 0c sub esp,0xc
1206: 8d 83 08 e0 ff ff lea eax,[ebx-0x1ff8]
120c: 50 push eax
120d: e8 2e fe ff ff call 1040 <printf@plt>
1212: 83 c4 10 add esp,0x10
1215: b8 00 00 00 00 mov eax,0x0
121a: 8d 65 f8 lea esp,[ebp-0x8]
121d: 59 pop ecx
121e: 5b pop ebx
121f: 5d pop ebp
1220: 8d 61 fc lea esp,[ecx-0x4]
1223: c3 ret
模块内部调用
main()函数中调用 fun()函数 ,指令为:11f9: e8 bb ff ff ff call 11b9 <fun>
fun() 函数所在的地址为 0x000011b9 ,机器码 e8 代表 call 指令,为什么后面是 bb ff ff ff 而不是 b9 11 00 00 (小端存储)呢?这后面的四个字节代表着目的地址相对于当前指令的下一条指令地址的偏移,即 0x11f9 + 0x5 + (-69) = 0x11b9 ,0xffffffbb 是 -69 的补码形式,这样做就可以使程序无论被装载到哪里都会正常执行。
模块内部数据访问
ELF 文件是由很多很多的 段(segment) 所组成,常见的就如 .text (代码段) 、.data(数据段,存放已经初始化的全局变量或静态变量)、.bss(数据段,存放未初始化全局变量)等,这样就能做到数据与指令分离互不干扰。在同一个模块中,一般前面的内存区域存放着代码后面的区域存放着数据(这里指的是 .data 段)。
那么指令是如何访问远在 .data 段 中的数据呢?
观察 fun() 函数中给静态变量 a 赋值的指令:11bc: e8 63 00 00 00 call 1224 <__x86.get_pc_thunk.ax>
11c1: 05 3f 2e 00 00 add eax,0x2e3f
11c6: c7 80 24 00 00 00 01 mov DWORD PTR [eax+0x24],0x1
11cd: 00 00 00
从上面的指令中可以看出,它先调用了 __x86.get_pc_thunk.ax()函数:00001224 <__x86.get_pc_thunk.ax>:
1224: 8b 04 24 mov eax,DWORD PTR [esp]
1227: c3 ret
这个函数的作用就是把返回地址的值放到 eax 寄存器中,也就是把0x000011c1保存到eax中,然后再加上 0x2e3f ,最后再加上 0x24 。即 0x000011c1 + 0x2e3f + 0x24 = 0x4024,这个值就是相对于模块加载基址的值。通过这样就能访问到模块内部的数据。
模块间数据访问
变量 b 被定义在其他模块中,其地址需要在程序装载时才能够确定。利用到前面的代码地址无关的思想,把地址相关的部分放入数据段中,然而这里的变量 b 的地址与其自身所在的模块装载的地址有关。解决:ELF 中在数据段里面建立了一个指向这些变量的指针数组,也就是我们所说的 GOT 表(Global offset Table, 全局偏移表 ),它的功能就是当代码需要引用全局变量时,可以通过 GOT 表间接引用。
查看反汇编代码中是如何访问变量 b 的:11bc: e8 63 00 00 00 call 1224 <__x86.get_pc_thunk.ax>
11c1: 05 3f 2e 00 00 add eax,0x2e3f
11c6: c7 80 24 00 00 00 01 mov DWORD PTR [eax+0x24],0x1
11cd: 00 00 00
11d0: 8b 80 ec ff ff ff mov eax,DWORD PTR [eax-0x14]
11d6: c7 00 02 00 00 00 mov DWORD PTR [eax],0x2
计算变量 b 在 GOT 表中的位置,0x11c1 + 0x2e3f - 0x14 = 0x3fec ,查看 GOT 表的位置。
命令 objdump -h got ,查看ELF文件中的节头内容:21 .got 00000018 00003fe8 00003fe8 00002fe8 2**2
CONTENTS, ALLOC, LOAD, DATA
这里可以看到 .got 在文件中的偏移是 0x00003fe8,现在来看在动态连接时需要重定位的项,使用 objdump -R got命令00003fec R_386_GLOB_DAT b
xxxxxxxxxx7 1 DW 定义字(2字节). 2 PROC 定义过程. 3 ENDP 过程结束. 4 SEGMENT 定义段. 5 ASSUME 建立段寄存器寻址. 6 ENDS 段结束. 7 END 程序结束.C
模块间函数调用
模块间函数调用用到了延迟绑定,都是函数名@plt的形式11fe: e8 5d fe ff ff call 1060 <test@plt>
延迟绑定(Lazy Binding) && PLT(Procedure Linkage Table)
因为动态链接的程序是在运行时需要对全局和静态数据访问进行GOT定位,然后间接寻址。同样,对于模块间的调用也需要GOT定位,再才间接跳转,这么做势必会影响到程序的运行速度。而且程序在运行时很大一部分函数都可能用不到,于是ELF采用了当函数第一次使用时才进行绑定的思想,也就是我们所说的 延迟绑定。ELF实现 延迟绑定 是通过 PLT ,原先 GOT 中存放着全局变量和函数调用,现在把他拆成另个部分 .got 和 .got.plt,用 .got 存放着全局变量引用,用 .got.plt 存放着函数引用。
查看 test@plt 代码,用 objdump -Mintel -d -j .plt got
-Mintel 选项指定 intel 汇编语法
-d 选项展示可执行文件节的汇编形式
-j 选项后面跟上节名,指定节
00001060 <test@plt>: |
查看 main()函数 中调用 test@plt 的反汇编代码11ee: e8 cd fe ff ff call 10c0 <__x86.get_pc_thunk.bx>
11f3: 81 c3 0d 2e 00 00 add ebx,0x2e0d
11f9: e8 bb ff ff ff call 11b9 <fun>
11fe: e8 5d fe ff ff call 1060 <test@plt>
__x86.gett_pc_thunk.bx函数与之前的 __x86.get_pc_thunk.ax功能一样 ,得出 ebx = 0x11f3 + 0x2e0d = 0x4000 ,ebx + 0x14 = 0x4014 。首先 jmp 指令,跳转到 0x4014 这个地址,这个地址在 .got.plt 节中 :
也就是当程序需要调用到其他模块中的函数时例如 fun() ,就去访问保存在 .got.plt 中的 fun@plt 。这里有两种情况,第一种就是第一次使用这个函数,这个地方就存放着第二条指令的地址,也就相当于什么都不做。
用 objdump -d -s got -j .got.plt 命令查看节中的内容
-s 参数显示指定节的所有内容
4014 处存放着 66 10 00 00 ,因为是小端序所以应为 0x00001066,这个位置刚好对应着 push 0x10 这条指令,这个值是 test 这个符号在 .rel.plt 节中的下标。继续 jmp 指令跳到 .plt 处
push DWORD PTR [ebx + 0x4] 指令是将当前模块ID压栈,也就是 got.c 模块,接着 jmp DWORD PTR [ebx + 0x8] ,这个指令就是跳转到 动态链接器 中的 _dl_runtime_resolve函数中去。这个函数的作用就是在另外的模块中查找需要的函数,就是这里的在 got_extern.so 模块中的 test 函数。
然后_dl_runtime_resolve函数会将 test() 函数的真正地址填入到 test@got 中去也就是 .got.plt 节中。
那么第二种情况就是,当第二次调用test()@plt 函数时,就会通过第一条指令跳转到真正的函数地址。整个过程就是所说的通过 plt 来实现 延迟绑定 。程序调用外部函数的整个过程就是,第一次访问 test@plt 函数时,动态链接器就会去动态共享模块中查找 test 函数的真实地址然后将真实地址保存到test@got中(.got.plt);第二次访问test@plt时,就直接跳转到test@got中去。
简例
|
编译
gcc -Wall -g -o test.o -c test.c -m32
链接
gcc -o test test.o -m32注意:现代Linux系统都是x86_64系统了,后面需要对中间文件test.o以及可执行文件test反编译,分析汇编指令,因此在这里使用-m32选项生成i386架构指令而非x86_64架构指令。
经编译和链接阶段之后,test可执行文件中print_banner函数的汇编指令会是怎样的呢?我猜应该与下面的汇编类似:080483cc <print_banner>:
80483cc: push %ebp
80483cd: mov %esp, %ebp
80483cf: sub $0x8, %esp
80483d2: sub $0xc, %esp
80483d5: push $0x80484a8
80483da: call **<printf函数的地址>**
80483df: add $0x10, %esp
80483e2: nop
80483e3: leave
80483e4: ret
print_banner函数内调用了printf函数,而printf函数位于glibc动态库内,所以在编译和链接阶段,链接器无法知知道进程运行起来之后printf函数的加载地址。故上述的**<printf函数地址>**一项是无法填充的,只有进程运运行后,printf函数的地址才能确定。
那么问题来了:进程运行起来之后,glibc动态库也装载了,printf函数地址亦已确定,上述call指令如何修改(重定位)呢?
一个简单的方法就是将指令中的**<printf函数地址>**修改printf函数的真正地址即可。
但这个方案面临两个问题:
- 现代操作系统不允许修改代码段,只能修改数据段
- 如果print_banner函数是在一个动态库(.so对象)内,修改了代码段,那么它就无法做到系统内所有进程共享同一个动态库。
因此,printf函数地址只能回写到数据段内,而不能回写到代码段上。
注意:刚才谈到的回写,是指运行时修改,更专业的称谓应该是运行时重定位,与之相对应的还有链接时重定位。
说到这里,需要把编译链接过程再展开一下。我们知道,每个编译单元(通常是一个.c文件,比如前面例子中的test.c)都会经历编译和链接两个阶段。
编译阶段是将.c源代码翻译成汇编指令的中间文件,比如上述的test.c文件,经过编译之后,生成test.o中间文件。print_banner函数的汇编指令如下(使用强调内容objdump -d test.o命令即可输出):00000000 <print_banner>:
0: 55 push %ebp
1: 89 e5 mov %esp, %ebp
3: 83 ec 08 sub $0x8, %esp
6: c7 04 24 00 00 00 00 movl $0x0, (%esp)
d: e8 fc ff ff ff call e <print_banner+0xe>
12: c9 leave
13: c3 ret
是否注意到call指令的操作数是fc ff ff ff,翻译成16进制数是0xfffffffc(x86架构是小端的字节序),看成有符号是-4。这里应该存放printf函数的地址,但由于编译阶段无法知道printf函数的地址,所以预先放一个-4在这里,然后用重定位项来描述:这个地址在链接时要修正,它的修正值是根据printf地址(更确切的叫法应该是符号,链接器眼中只有符号,没有所谓的函数和变量)来修正,它的修正方式按相对引用方式。
这个过程称为链接时重定位,与刚才提到的运行时重定位工作原理完全一样,只是修正时机不同。
链接阶段是将一个或者多个中间文件(.o文件)通过链接器将它们链接成一个可执行文件,链接阶段主要完成以下事情:
-各个中间文之间的同名section合并
- 对代码段,数据段以及各符号进行地址分配
- 链接时重定位修正
除了重定位过程,其它动作是无法修改中间文件中函数体内指令的,而重定位过程也只能是修改指令中的操作数,换句话说,链接过程无法修改编译过程生成的汇编指令。
那么问题来了:编译阶段怎么知道printf函数是在glibc运行库的,而不是定义在其它.o中
答案:编译器是无法知道的
那么编译器只能老老实实地生成调用printf的汇编指令,printf是在glibc动态库定位,或者是在其它.o定义这两种情况下,它都能工作。如果是在其它.o中定义了printf函数,那在链接阶段,printf地址已经确定,可以直接重定位。如果printf定义在动态库内(链接阶段是可以知道printf在哪定义的,只是如果定义在动态库内不知道它的地址而已),链接阶段无法做重定位。
根据前面讨论,运行时重定位是无法修改代码段的,只能将printf重定位到数据段。那在编译阶段就已生成好的call指令,怎么感知这个已重定位好的数据段内容呢?
答案是:链接器生成一段额外的小代码片段,通过这段代码支获取printf函数地址,并完成对它的调用。
链接器生成额外的伪代码如下:.text
...
// 调用printf的call指令
call printf_stub
...
printf_stub:
mov rax, [printf函数的储存地址] // 获取printf重定位之后的地址
jmp rax // 跳过去执行printf函数
.data
...
printf函数的储存地址:
这里储存printf函数重定位后的地址
链接阶段发现printf定义在动态库时,链接器生成一段小代码print_stub,然后printf_stub地址取代原来的printf。因此转化为链接阶段对printf_stub做链接重定位,而运行时才对printf做运行时重定位。
PLT & GOT
前面由一个简单的例子说明动态链接需要考虑的各种因素,但实际总结起来说两点:
- 需要存放外部函数的数据段
- 获取数据段存放函数地址的一小段额外代码
如果可执行文件中调用多个动态库函数,那每个函数都需要这两样东西,这样每样东西就形成一个表,每个函数使用中的一项。
总不能每次都叫这个表那个表,于是得正名。存放函数地址的数据表,称为重局偏移表(GOT, Global Offset Table),而那个额外代码段表,称为程序链接表(PLT,Procedure Link Table)。

当然这个原理图并不是Linux下的PLT/GOT真实过程,Linux下的PLT/GOT还有更多细节要考虑了。这个图只是将这些躁声全部消除,让大家明确看到PLT/GOT是如何穿针引线的。
图解
程序开始执行时,.plt节和.got.plt节的状态如下:
printf@plt是指plt节中与printf相关代码的地址,printf@got同理;printf@plt+6恰好是get_address的首地址;*(printf@got)是指地址为printf@got的内存单元中的数据(图中为printf@plt+6)get_address是一系列操作,该操作的功能是将printf在内存中的实际地址填写到printf@got这一地址对应的内存单元中。
首次调用printf的程序执行流如下:
- ①:程序执行至
call printf@plt,因此跳转至相应地址; - ②:跳转到
printf@plt后,执行jmp *(printf@got),但由于printf@got保存的数据是printf@plt+6,因此程序又跳转至get_address的首地址; - ③:程序执行
get_address,将printf的真实地址写入printf@got中(图中未体现)
当完成上述操作后,程序便能第一次执行printf。并且此时.plt节和.got.plt节的状态变为:
可以看到,此时printf@got中保存的地址变为了printf真正的首地址。
显然,后续再次执行jmp *(printf@got)命令时,程序可以直接跳转至printf的首地址,并执行相关命令。