转自https://www.freebuf.com/articles/system/135685.html
0×00 Intro
本文以Ian Wienand的博客 为蓝本,我在必要的地方予以增补、解释以及再实验,希望读者对PLT和GOT有一个初步的、相对完整的认识。
0×01 The Simplest Example
原文的标题是“PLT and GOT – the key to code sharing and dynamic libraries”,可见PLT和GOT对代码复用以及动态库的关键作用。
共享库(shared library)是现代操作系统的组成部分,但其内部机制却很少有人去了解。当然,有很多解释共享库机制的文章,希望本篇博客能为这方面的知识体系加一把火。
OK,我们从最起始部分讲起—-在二进制文件(比如object file)中会有一段叫relocations的部分,这部分的内容在链接时候(link time)再进行敲定确切的值,注意链接可以发生在运行前(称为静态链接,toolchain linker),也可发生在运行时(称为动态链接,dynamic linker)。具体relocations部分中的内容就是在讲:“确定X这个符号(symbol)的值,然后把这个值写到二进制文件的Y偏移处”。每一条relocation都有确定的类型(定义与ABI文档中),从而确切地说明每个类型的值到底该如何敲定。
如下为最简单的例子:
我们可以看到”foo”这个符号的Sym.Value是0,说明在把a.c编译成a.o的时候,“foo”这个符号的值还不知道,所以编译就在Sym.Value处先写0,然后在Type这个位置写上R_X86_64_PC32,从而告诉之后要进行link的链接器:”在最终生成的可执行文件的.text部分的0×6这个偏移位置,patch上foo这个符号的地址值”。如果我们看一下a.o这个对象文件的.text部分0×6这个位置,我们会看到如图绿线部分:
觉得刚刚看到这张图片,可能就会有些刚刚接触这些概念的同学就有些懵了。别急,我一点点地讲这张图。首先一个二进制可执行文件可被划分为多个部分:
这张图片截取自《程序员的自我修养》这本书,真心希望像搞懂“程序是如何跑起来的?”这种问题的同学,去读一下这本书,你会觉得很值的,这是一个很本质的问题,而书中解释得那样的清晰,总之,强烈推荐!
.text部分会放着这个二进制文件的执行代码,所以当我们用objdump进行反汇编的时候,就会看到a.o这个二进制文件的.text部分的机器码,前两条指令用于布置好栈空间(这部分可参考William Stallings的Computer Security那本书的第十章)不是本文的重点,后两条指令用于清理栈空间,并返回调用函数,也不是本文重点。重点在第三条语句,它在.text的Offset 0×4处。我们可以看到,a.o仅仅是一个经过编译后的object file,所以其中的真实值并没有敲定,从而看到绿线处暂时填4个00。
好,下面我问一个问题:当CPU执行到0×4出这句指令时,%rip(即PC)的值为多少?两个选项,A. 0×4 ;B. 0xa。答案:B。参考CSAPP第四章,截图如下:
其实,计算机在执行一条指令需要一个指令周期,不同的CPU架构的指令周期可以分为不同阶段,上图中我们可看到Intel x86架构CPU的指令周期分为:取指,译码,执行,访存,写回,PC update,这六个阶段。这里,我说一下我对PC的理解,图中绿线部分,vaIP对应%rip寄存器,%rip趋向于一种实际意义层面上的理解,而PC更趋向于一种意向意义层面上的理解。具体来说,在PC update阶段,CPU才去关注”我要去执行的下一条指令在哪里”(通过关注%rip中的值),而在CPU关注“下一条指令在哪里”之前(即PCupdate周期之前),在Fetch阶段,%rip寄存器按图中绿线的指示,其值便已经改变了,(改变方法是:当前指令的PC加上当前指令的长度,图中subl %edx, %ebx这条指令长度为2,所以vaIP被赋值为PC+2)。简单理解,%rip就是PC,在CPU这执行当前指令时,%rip便已经指向了下一条指令所在的地址处了,只是到了PC update环节,CPU才去看%rip的值(当然在Execute环节,CPU也可以使用%rip,但并不是为了程序的执行流而去关注%rip,此时就纯粹那%rip作为一个存值的寄存器来使用,而且此时的%rip已经指向下一条指令了)。
对于我上述理解的支撑材料是,王爽老师那本《汇编语言》的第二章的2.10节有关CPU如何执行一条指令的一系列图示。这里由于图示很长,我就不粘贴了,简单讲就是,CPU还没开始执行具体指令的时候,IP的值便已经指向下一条指令了。
好了,说了这么多,我想,我可以开始解释0×4处这条指令的意思了,就是将0×0 + %rip这个地址处所存的值,赋给%eax。而此时,%rip的值,根据我们上面问的问题的描述,应为0xa(下一条指令的地址),所以这条指令的含义进一步解释为,以%rip为基址,以0×0为偏移的内存地址处取内容,给到%eax。而此时偏移之所以为0×0,是因为还没有经过链接过程,所以真实的偏移地址还没有敲定,所以暂时写0×0。
明白了该指令的含义,但这句到底是在干啥啊?我们回头看一下a.c源码,foo是一个extern(外部)的int型数据,函数function的返回类型也是一个int型数据,其内容为将foo给return出来。我们知道a.o仅仅是经过编译的,编译器会说:“我不知道foo这个外来int会来自于那个.o文件,那是链接器的活!我就姑且把foo所在的内存地址偏移定为0×0”。而返回值一般都保存在%eax这个寄存器中,所以0×4处这个指令的含义就是function函数的返回值为0 。
经过link之后,该偏移会被patch成foo的真实地址偏移。
0×02 Position-Independent Code
接着上面,如果foo这变量的值出现在其他的对象文件(比如b.o)中,那么便可以通过静态链接,来将a.o和b.o链接到一个executable中,而在executable中,原来a.o中relocations部分foo相关的条目便可以去掉了,因为foo的真实地址,已经被linker,根据b.o中有关foo的信息,给patch好了。但是,“ there is a whole bunch of stuff for a fully linked executable or shared-librarythat just can’t be resolved until runtime. ”,对于一个executable或者共享库,有许多事,只有到了运行时(runtime)才能敲定。比如,我们即将讲到的位置无关代码(position-independent code ,PIC)。
首先,我们先看一下位置相关代码(清晰起见,用作者的32位机的例子即可):
$ readelf --headers /bin/ls
[...]
ELF Header:
[...]
Entry point address: 0x8049bb0
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
[...]
LOAD 0x000000 0x08048000 0x08048000 0x16f88 0x16f88 R E 0x1000
LOAD 0x016f88 0x0805ff88 0x0805ff88 0x01543 0x01543 RW 0x1000
可见ls这个可执行文件,它有一个fixed的加载位置,即代码部分(其flag是R和E)必须加载到0×08048000(注意这个是物理地址),而数据部分(其flag是R和W)必须加载到0x0805ff88。这种位置相关的代码,固然有其好处,不用再在runtime的时候去算一些地址信息什么的了,因为地址都是fixed。
不过,这种fixed地址对shared library(.so文件)并没有好处。so文件的一个核心观点就是拿过来,加载到任意一段物理内存上就用。而如果so文件必须被加载到一个特定的地址上才能运行的话,我们可能就得把计算机上所有so文件都给一个特定的加载地址,以保证在使用这些so文件时不会有重叠(overlap),这其实也是预链接(prelinking)要做的事。但对于一个32位机,你这么做的话,马上内存就分配完了。所以,还是得考虑一种位置无关代码的方案。实际上,当我们去检查一个so文件的时候,会看到它们并不会指定一个特定的加载基地址(可见RE flag标示的代码部分的物理地址为0,即不会指定加载基地址):
$ readelf --headers /lib/libc.so.6
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
[...]
LOAD 0x000000 0x00000000 0x00000000 0x236ac 0x236ac R E 0x1000
LOAD 0x023edc 0x00024edc 0x00024edc 0x0015c 0x001a4 RW 0x1000
共享库的第二个目标就是代码重用(code sharing)。对于shared library,我们需要让它的code段保持不变,如果变了的话,那么100个进程调用这段代码,因为code段会变的原因,就要占用100段物理内存空间,这肯定不是我们想要的,所以要保持so文件中code段不能动,从而只需将这个code段加载到一个特定的物理地址处,每个调用该so文件的进程的虚存指向这个物理地址就可以了。还有就是,so文件中data段的相对位置也不能变,从而使写死的code段,能够通过相对位置来找到data段(上面的headers中,我们可以看到data段的offset是0x023edc)。这样,通过virtual memory的神奇机制(同一虚拟地址,可以映射到不同的物理地址),“every process sees its own data section but can share the unmodified code”。于是对于每一个进程,要找到其特有的data段时,使用简单的数学即可:我当前的地址(code段某处) + 已知的相对位置 = 我索引的data段 。
0×03 Global Offset Table
不过说着轻松,想确定“我当前的地址”可不是件容易的事,比如有如下代码:
$ cat test.c
static int foo = 100;
int function(void) {
return foo;
}
$ gcc -fPIC -shared -o libtest.so test.c
-shared告诉编译器生成so文件,-fPIC告诉编译器生成位置无关的代码,综合在一起就是生成位置无关的so文件,这两个通常配套使用。
这里foo是static的,所以它会保存在so文件的data段中。对于64位机(amd64),因为可以直接访问%rip,所以当前指令地址很好获得:
000000000000056c
56c:55 push %rbp
56d:48 89 e5 mov%rsp,%rbp
570:8b 05 b2 02 20 00 mov0x2002b2(%rip),%eax# 200828
576:5d pop%rbp
第三条是重点(其余不属于本文讨论范文),我们在上一节接触过类似的,按照相对位置%rip+0x2002b2,取到该地址处的内容(即data),将其赋给%eax。对于64位机,就这么简单。
但对于32位机(i386)的话,就要麻烦一些了,因为32位架构下接触不到PC。所以,需要一点小技巧:
0000040c
40c:55 push %ebp
40d:89 e5 mov%esp,%ebp
40f:e8 0e 00 00 00 call 422 <__i686.get_pc_thunk.cx>
414:81 c1 5c 11 00 00 add$0x115c,%ecx
41a:8b 81 18 00 00 00 mov0x18(%ecx),%eax
420:5d pop%ebp
421:c3 ret
00000422 <__i686.get_pc_thunk.cx>:
422:8b 0c 24 mov(%esp),%ecx
425:c3 ret
0x40f处一个call指令调用__i686.get_pc_thunk.cx函数:先将call指令的下一条指令的地址(这里是0×414)压到栈上,然后蹦到这个函数地址处(0×422)开始执行:将刚刚压栈的下一条指令的地址赋给%ecx,然后ret到0×414继续执行add指令:0x115c + 0×414 = 0×1570。然后再到0x41a把0×1570 + 0×18 = 0×1588地址处的内容,给到%eax寄存器中。这里我们可以去看一下0×1588中写着什么:
00001588
1588: 64 00 00add%al,%fs:(%eax)
值为0×0000000064=100,正是源码中static int 类型的变量foo的值100。
我们注意到,上面源码中foo是static的,即使属于该so文件本身的。那么,如果一个so文件,想去索引其他的so文件中的data怎么办?当然,我们可以patch这个so文件的代码部分,直接把那个data的位置为patch上,但这样就破坏了so文件的 code-sharability。而计算机中有一个原理就是:所有问题都可以通过加一个间接层来解决。这里,这个间接层就叫global offset table or GOT.
考虑下面情况:
$ cat test.c
extern int foo;
int function(void) {
return foo;
}
$ gcc -shared -fPIC -o libtest.so test.c
这里foo是一个extern外部变量,大概来自什么其他的库文件吧。我们看一下在amd64架构上的情况吧:
$ objdump --disassemble libtest.so
[...]
00000000000005ac
5ac:55 push %rbp
5ad:48 89 e5 mov%rsp,%rbp
5b0:48 8b 05 71 02 20 00 mov0x200271(%rip),%rax# 200828 <_DYNAMIC+0x1a0>
5b7:8b 00 mov(%rax),%eax
5b9:5d pop%rbp
5ba:c3 retq
$ readelf --sections libtest.so
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[...]
[20] .got PROGBITS 0000000000200818 00000818
0000000000000020 0000000000000008 WA 0 0 8
$ readelf --relocs libtest.so
Relocation section '.rela.dyn' at offset 0x418 contains 5 entries:
Offset Info Type Sym. ValueSym. Name + Addend
[...]
000000200828 000400000006 R_X86_64_GLOB_DAT 0000000000000000 foo + 0
我们可以看到返回值来自于%rip + 0×200271 = 0×200828。我们再看一下这个so文件的headers,可见0×200828属于.got范围内。然后,我们在看一下这个so文件的relocations,我们看到“R_X86_64_GLOB_DAT ”告诉链接器:“链接器啊,你去到其他对象文件中找一下foo的值,然后把它patch到0×200828这个位置”。
所以,当动态加载器加载该so文件时,会先去看它的relocations,然后找到foo的值,然后把它patch到该so文件的属于.got部分的0×200828地址处。当code段索引到foo这个值的时候,直接到.got的0×200828处去拿就好了,everything just works:code段也不需要修改,从而就没有破坏so文件的code sharability。
0×04 Procedure Linkage Table
上节我们可以处理so文件对外部变量的引用了,但如果是外部函数调用呢?此处,我们使用的“间接层”叫做procedure linkage table or PLT。code只会通过PLT stub(PLT桩代码,其实就是一小段代码),实现外部函数调用。如下例子:
$ cat test.c
int foo(void);
int function(void) {
return foo();
}
$ gcc -shared -fPIC -o libtest.so test.c
$ objdump --disassemble libtest.so
[...]
00000000000005bc
5bc:55 push %rbp
5bd:48 89 e5 mov%rsp,%rbp
5c0:e8 0b ff ff ff callq 4d0
5c5:5d pop%rbp
$ objdump --disassemble-all libtest.so
00000000000004d0
4d0: ff 25 82 03 20 00 jmpq *0x200382(%rip)# 200858 <_GLOBAL_OFFSET_TABLE_+0x18>
4d6: 68 00 00 00 00 pushq $0x0
4db: e9 e0 ff ff ff jmpq 4c0 <_init+0x18>
$ readelf --relocs libtest.so
Relocation section '.rela.plt' at offset 0x478 contains 2 entries:
Offset Info Type Sym. ValueSym. Name + Addend
000000200858 000400000007 R_X86_64_JUMP_SLO 0000000000000000 foo + 0
执行function这个函数时,我们看到有一句callq指令,从而执行流跳到0x4d0处执行,该处为jmpq *0×200382(%rip),即以0×200382 + 0x4d6 = 0×200858地址处取内容作为地址,进行跳转。那么我们看一下第一次这么执行时,0×200858处的内容是什么:
$ objdump --disassemble-all libtest.so
Disassembly of section .got.plt:
0000000000200840 <.got.plt>:
200840: 98 cwtl
200841: 06 (bad)
200842: 20 00 and%al,(%rax)
...
200858: d6 (bad)
200859: 04 00 add$0x0,%al
20085b: 00 00 add%al,(%rax)
20085d: 00 00 add%al,(%rax)
20085f: 00 e6 add%ah,%dh
200861: 04 00 add$0x0,%al
200863: 00 00 add%al,(%rax)
200865: 00 00 add%al,(%rax)
jmpq是quadra word,即8个字节,所以取0x00000000000004d6(即从地址0×200858到0x20085f地址,取这8个字节,注意小端模式,所以倒着写)。这恰好是jmpq的下一条指令的地址(注意,这是第一次call foo这个外部函数时的情形)。0x4d6的指令是,push $0×0,这里这个push的0是foo这个函数符号在.rela.plt数据结构中的下标(也可称为索引或者index),用于定位这个foo这个符号(具体参见《程序员的自我修养》,估计以后我也会专门写一篇有关对象文件构成的文章)。执行完push后,就jmp到了0x4c0这个位置,我们看一下0x4c0处写了什么:
00000000000004c0
4c0: ff 35 82 03 20 00 pushq 0x200382(%rip)# 200848 <_GLOBAL_OFFSET_TABLE_+0x8>
4c6: ff 25 84 03 20 00 jmpq *0x200384(%rip)# 200850 <_GLOBAL_OFFSET_TABLE_+0x10>
4cc: 0f 1f 40 00 nopl 0x0(%rax)
又push了一个值,这个值是什么呢?在后面注释中我们可以看到_GLOBAL_OFFSET_TABLE_+0×8这种字样,它又是什么呢?
我先查阅了CSAPP,看到了如下内容:
然后,查阅了《程序员的自我修养》,看到如下内容:
所以,我的理解是,我们可以把_GLOBAL_OFFSET_TABLE_理解成为一个类似数组一样的东西,每个元素保存着一个地址,32位机就是4字节地址,64位机就是8字节地址。
所以_GLOBAL_OFFSET_TABLE_+0×8就是_GLOBAL_OFFSET_TABLE_的第二个元素,参考上面两幅图片可知,这个代表了libtest.so(即模块的ID),_GLOBAL_OFFSET_TABLE_+0×10则为_GLOBAL_OFFSET_TABLE_的第三个元素,为动态解析函数的入口地址。
然后在0x4c6,跳到这个动态解析函数,开始对foo这个函数名进行解析,找到其地址,并patch到0×200858这个位置(这个位置在GOT中),从而第二次调用foo的时候,jmp的地址就不是0x00000000000004d6了,而是foo的实际地址了。
这种,第一次调用foo函数,通过PLT stub(PLT桩代码)进行动态链接(地址解析),找到foo函数真实地址并patch GOT表,第二次直接通过PLT桩代码跳到foo函数真实地址的方式,称为“延迟绑定技术”(lazy binding)。
好了,以上便是PLT技术的一些细节实现。另外多说一句,我们可以在运行某使用so库的可执行文件时,使用LD_PRELOAD 来修改动态链接时,符号解析的顺序。也就是说,LD_PRELOAD会告诉动态链接器,想找什么符号的话,先从这里找,如果LD_PRELOAD中所提供so库里有foo这个符号,那么动态链接器会首先到这里找foo的地址,而不会去其他so库中去找。
0×05 Summary
so库的代码段要保持只读,而且数据段也要为各个进程所私有。需要在编译时通过已知的各个符号的偏移量,建立GOT和PLT表,从而间接地达成第一句的目标。