5.4.6 重定位动态库

动态库在编译时,链接器并不知道最后被加载的位置,所以在编译时,共享库的地址是相对于0分配的。以动态库libf1.so为例:

5.4.6 重定位动态库 - 图1

根据动态库libf1.so的Program Header Table,注意列VirtAddr,显然地址是从0开始分配的。因此,在映射到具体进程的地址空间后,需要修订其中那些通过绝对方式引用的符号的地址,代码如下:

5.4.6 重定位动态库 - 图2

函数dl_main从main_map开始,调用函数_dl_relocate_object重定位link_map链表中的所有动态库和可执行程序,顺序是从后向前。如果有符号重定义了,那么后面发现的符号的地址将覆盖掉前面的符号地址。换句话说,链接时排在前面的动态库中的符号将被优先使用。另外,还有一点要注意,这个列表中的动态链接器将不再需要重定位,因为其已经在前面自己重定位好了。

常用的重定位方式有两种:加载时重定位(Load-time relocation)和PIC方式。

加载时重定位与编译时的重定位非常相似。动态链接器在加载动态库后,遍历动态库的重定位表,对于重定位表中的每一项记录,解析这个记录中指明的符号的地址,然后使用解析到的地址修订这个记录中指定的偏移处,当然这个偏移需要加上动态库映射的基址。

但是动态库是多个进程共享的,不同的进程映射的动态库的地址不同,因此,如果某个进程按照动态库在自己进程空间中映射的基址修改了动态库的代码段,那么这个动态库的显然就不能被其他进程所共享了,除非所有的进程映射动态库的位置相同,但是这又带来太多的限制和问题。

基于以上原因,开发者们又设计了另外一种方式—PIC(Position-Independent Code)。PIC基于两个关键的事实:

❑数据段是可写的。既然代码段是不能更改的,但是数据段总是可以更改的。于是PIC把重定位战场从代码段转移到数据段,在数据段中增加了一个GOT(GLOBAL OFFSET TABLE)表。在编译时,链接器将所有需要重定位的符号在这个表中分配一项,其中记录了符号以及其实际所在的地址。重定位时,动态链接器只修改GOT表中的值。

❑代码和数据的相对位置不变。在代码中凡是引用GOT表中的符号,只需要找到GOT表的地址,再加上变量在GOT表中的偏移即可。但是,如此还是没有避开代码段被修改的命运,因为动态库在进程地址空间中的位置只有在加载时才能确定,所以,GOT表的地址在加载时也需要重定位。但是,我们也注意到这样一个事实:对动态库来说,虽然其映射的地址在编译时不确定,但是在映射到进程的地址空间时,代码段和数据段依然按照编译时分配好的地址映射,也就是说,指令和数据的相对位置却是固定的。因此,GOT表作为数据段中的一员,代码段中的任一指令与GOT表基址之间的偏移是固定的,在编译时就可以确定。PIC恰恰是基于这个事实,在代码中凡是访问GOT表的地方,都是使用这个固定的相对偏移来引用GOT表以及其中的变量,因此,代码中引用GOT表的地址不再需要重定位,从而避开了代码段被修改的问题。接下来我们结合具体的实例进一步解释这个过程。

1.GOT表

显然,PIC技术中,GOT表是一个非常重要的数据结构,在继续深入探讨前,我们先来认识一下这个数据结构,如图5-32所示。

5.4.6 重定位动态库 - 图3

图 5-32 GOT表

由图5-32可见,这么大名鼎鼎的GOT表却如此简单,其就是一个一维数组。对于32位CPU来说,每个数组元素就是32位的地址。GOT表分成两个部分:.got和.got.plt。.got中存储的是变量的地址。.got.plt中存储的是函数的地址。在5.4.9节中我们将讨论GOT表一分为二的原因。

在编译时,链接器将定义一个符号GLOBAL_OFFSET_TABLE,指向.got和.got.plt的连接处,凡是访问GOT表中的地址时,都使用基于这个符号的偏移。比如,访问变量var 1,那么使用:

5.4.6 重定位动态库 - 图4

访问函数func1则使用:

5.4.6 重定位动态库 - 图5

GOT表中除了记录变量和函数的地址外,还有另外三个特殊的表项,我们在图5-32中也已经标出,它们就是.got.plt的前三项。其中第1项记录的是动态库或者可执行文件的.dynamic段的地址;第2项记录的是代表动态库或者可执行文件的link_map对象;第3项记录的是动态链接器提供的解析符号地址的函数_dl_runtime_resolve的地址。我们以动态库libf1.so为例,看看在一个已经编译好的动态库中,这三项的值:

5.4.6 重定位动态库 - 图6

从地址0x2000处起,就是.got.plt开始的地方。其中使用黑体标识的3个32位地址就分别是这三项的值。可见,除了第1项被赋予了具体的值外,其余两项全部是0。原因是段.dynamic的地址是编译时就确定的。我们查看动态库libf1.so的段.dynamic的值:

5.4.6 重定位动态库 - 图7

上面,使用"-x"显示段.got.plt的内容时,是以little-endian表示的,所以.dynamic段的地址"00001ef8"被显示为"f81e0000"。

记录动态库信息的link_map是在加载后创建的,编译时当然不知道这个运行时创建的对象的地址。同理,因为动态链接器也是以动态库的形式加载到进程地址空间的,其映射地址也是加载时才确定的,所以动态链接器中的函数_dl_runtime_resolve的地址也是在动态链接器加载后才能确定。因此,与段.dynamic的地址在编译时就可确定不同,这两项是由动态链接器动态填充的,代码如下:

5.4.6 重定位动态库 - 图8

5.4.6 重定位动态库 - 图9

其中第6行语句将相关宏进行替换后,展开如下:

5.4.6 重定位动态库 - 图10

前面,讨论结构体link_map时,我们提到过,这个结构体中的数组l_info就是为了方便存储段.dynamic的信息的。因此,这条语句的目的就是从段.dynamic中取得GOT表的基地址,也就是got.plt的基址。

接下来的第8行和第10行语句的目的是在获得了.got.plt的基址之后,分别设置其中第2项和第3项的值。很明显,一个是代表动态库的link_map对象,另外一个就是函数_dl_runtime_resolve的地址。

读者这里了解GOT表中这特殊的三项就可以了,更具体的我们后面会讨论。其中第1项主要是动态链接器重定位自己时使用,我们将在5.4.8节讨论;第2项和第3项主要是用在函数的延迟绑定中使用,我们在5.4.6节中讨论。

2.重定位变量

变量的重定位在动态库加载时进行,注意不要将这里的加载时与前面特指的“加载时重定位”混淆,这里指的是使用PIC技术在加载时进行的变量重定位的过程。我们分别从代码中引用变量以及动态链接器修订GOT表两个角度来讨论PIC中的变量重定位。

(1)代码中引用变量

我们以库libf1中的函数foo1_func引用库libf2中的符号foo2为例,具体看一下PIC中的变量重定位。我们反汇编动态库libf1.so,其中引用全局变量foo2的反汇编代码片段如下:

5.4.6 重定位动态库 - 图11

1)获取下一条指令的运行时地址。注意偏移0x587处的指令,其调用了偏移0x57b处的函数x86.get_pc_thunk.cx。在调用这个函数时,call指令会将下一条指令的地址0x58c压入到栈中。而在进入函数x86.get_pc_thunk.cx后,其将栈顶的值取出到寄存器ebx中,然后返回。显然,调用这个函数的目的就是取得下一条指令的运行时地址。这里之所以这么做,是因为x86指令集中没有提供获取指令指针值的指令,不得以才采用的一个小技巧。

2)计算GOT表的运行时地址。现在,下一条指令的绝对地址保存在寄存器ebx中,而下一条指令与GOT之间的偏移又是固定的,因此寄存器ebx加上这个固定的偏移后,就确定了GOT表在运行时所在的地址。

编译时,链接器定义了一个变量GLOBAL_OFFSET_TABLE代表GOT表的基址,库libf1中该符号地址如下:

5.4.6 重定位动态库 - 图12

因此,库libf1中偏移0x58c处的指令到GOT表所在位置的差为:0x2000-0x58c=0x1a74,这就是地址0x58c处的值0x1a74的由来。也就是说,这个0x1a74就是指令与GOT表之间的那个固定偏移。

3)计算符号foo2在GOT表中的偏移。取得了GOT表的绝对地址后,如要访问变量foo2,还要加上变量foo2在GOT表中的偏移。那这个偏移是多少呢?我们看看动态库libf1的重定位表:

5.4.6 重定位动态库 - 图13

根据重定位表可见,符号foo2在偏移0x00001fe8处。而GOT表基址在0x2000处,因此,根据这两个值之差就可以确定符号foo2在GOT表中的偏移:0x1fe8-0x2000=-0x18,也就是说,变量foo2相对GOT表的偏移是-0x18。根据ELF文件中段的布局:

5.4.6 重定位动态库 - 图14

可见,GOT表的基址是介于.got和.got.plt之间的。对于.got部分来说,GOT表的基址位于.got部分的底部,这就是偏移为负的原因。之所以将GOT表的基址设置在.got和.got.plt之间,并无特别的目的,这样访问.got.plt就是正值了。所以,我们看到在库libf1的地址0x592处在ebx的基础上又加了偏移-0x18。

(2)动态链接器修订GOT表

我们还是以库libf1中引用的库libf2中的符号foo2为例,来看看在加载时,动态链接器是如何解析这个符号并修订GOT表的。

1)获取动态库libf1的重定位表。重定位信息保存在重定位表中,因此,动态链接器首先要找到重定位表。段.dynamic中类型为REL的条目记录的就是重定位表的位置,动态库libf1段.dynamic中记录的重定位表如下:

5.4.6 重定位动态库 - 图15

可见,保存重定位变量的表位于0x38c处。因此,动态链接器按照如下公式计算重定位表的地址:

5.4.6 重定位动态库 - 图16

2)根据重定位表,确定需要修订的位置。确定重定位表后,动态链接器就遍历重定位表中的每一条记录。以libf1.so中的引用的全局变量dummy、foo2和foo1的重定位记录为例:

5.4.6 重定位动态库 - 图17

其中第一条重定位记录表示需要使用符号dummy的值修订下面位置处的值:

5.4.6 重定位动态库 - 图18

第二条重定位记录表示需要使用符号foo2的值修订下面位置处的值:

5.4.6 重定位动态库 - 图19

第三条重定位记录表示需要使用符号foo1的值修订下面位置处的值:

5.4.6 重定位动态库 - 图20

3)寻找动态符号表。需要修订的位置确定后,那么接下来就需要解析符号的值。动态链接器从link_map这个链表的表头,即代表可执行程序的main_map开始,依次在它们的动态符号表中查找符号。所以,要解析符号的地址,首先要确定动态符号表的地址。以动态库libf2为例,动态链接器确定其动态符号表的过程如下。

动态链接器根据代表库libf2的link_map中的字段l_ld找到段.dynamic,然后在该段中取出动态符号表的地址:

5.4.6 重定位动态库 - 图21

段.dynamic中类型为SYMTAB的项记录的是动态符号表的地址。可见,libf2的动态符号表的地址是0x178,因此,其在运行时的绝对地址使用如下公式计算:

5.4.6 重定位动态库 - 图22

4)解析符号地址。动态链接器找到了动态符号表后,进一步在动态符号表中查找符号的地址。以全局变量foo2为例,动态链接器将在库libf2的动态符号表中找到这个符号的信息:

5.4.6 重定位动态库 - 图23

上述动态符号表中符号的地址是相对于0的,因此需要加上libf2在进程地址空间中映射的基址,所以符号foo2的运行时地址是:

5.4.6 重定位动态库 - 图24

然后,动态链接器使用上述这个地址,修订前面确定的需要修订的位置。

前面是静态的分析,下面我们将这个例子运行起来,动态地观察一下全局变量foo2的重定位过程。

5.4.6 重定位动态库 - 图25

我们在另外一个终端中查看动态库libf2在进程hello的地址空间中映射的基址:

5.4.6 重定位动态库 - 图26

可见,库libf1和libf2在hello进程的地址空间中映射的基址分别是0xb7fd8000和0xb7e15000。那么libf1中需要修订的地址是:

5.4.6 重定位动态库 - 图27

符号foo2的地址是:

5.4.6 重定位动态库 - 图28

下面我们使用gdb查看内存0xb7fd9fe8处的值,如果计算正确,那么该内存处的值应该已经被动态链接器修订为0xb7e17018:

5.4.6 重定位动态库 - 图29

根据输出结果可见,内存0xb7fd9fe8处输出的值与我们理论上计算的符号foo2的地址完全吻合。

综上可知,变量foo2的重定位过程如图5-33所示。

5.4.6 重定位动态库 - 图30

图 5-33 变量foo2的重定位过程

不知道读者注意到没有,在例子中,我们在可执行文件hello和动态库libf1中分别定义了全局变量dummy。这不是我们的笔误,而是故意为之。不知读者想过没有,对于变量foo2,其定义在动态库libf2中,编译时动态库libf1对其一无所知,所以在加载时进行重定位,我们没有任何疑义。但是,对于变量dummy,其在动态库libf1中已经定义了,既然指令和数据的相对位置是固定的,那么为什么不采用与寻址GOT表一样的方法,编译时就直接定义好位置,而还是通过GOT表,在加载时进行重定位呢?

我们先反过来问读者一个问题:动态库libf1中函数foo1_func中引用的变量dummy是动态库libf1中定义的,还是可执行程序hello中定义的?答案是后者。对于一个全局符号,包括函数,其可能在本地定义,但在其他库中、甚至包括使用动态库的可执行程序中也可能有定义。在动态链接器解析符号时,将沿着以可执行程序的link_map对象main_map开头的这个链表依次查找动态符号表,使用最先找到的符号值。如我们的例子中,可执行程序hello的动态符号表将先于动态库libf1的动态符号表被查找,所以,库libf1中的函数foo1_func将使用可执行程序hello中dummy的定义。

除此之外,还有一种所谓的Copy Relocation,也要求即使引用同一个动态库中定义的全局变量,也要使用重定位的方式,我们在5.4.7节讨论这种重定位情况。

3.重定位函数

前面我们讨论了变量的重定位,本小节我们讨论函数的重定位。理论上,函数的重定位使用与变量相同的方法即可。但是,因为相对比较少的全局变量的引用,函数引用的数量可能要大得多,因此函数重定位的时间不得不考虑。

事实上,读者回想一下我们日常开发的程序,其实很多代码不一定能全部执行,比如有些分支、错误处理等。而且,即使可执行程序本身使用的函数数量并不大,但是可执行程序依赖的动态库可能还会引用其他动态库中的函数,这些动态库再依赖其他的动态库,如此,需要重定位的函数的数量不容小觑。更重要的是,可执行程序可能根本就用不到这些动态库中的函数,因此,加载时重定位函数只会延长程序启动的时间,但是重定位的某些函数却可能根本就用不到。出于以上考虑,PIC对于函数的重定位引入了延迟绑定技术(lazy binding)。

也就是说,在加载时,动态链接器不解析任何一个需要重定位的函数的地址,而是在运行时真正调用时,再去重定位。为此,开发者们引入了PLT(Procedure Linkage Table)机制。在GOT表的巧妙配合下,PIC将函数地址的解析推迟到了运行时。

在编译时,链接器在代码段中插入了一个PLT代码片段,每个外部函数在PLT中都占据着一小段代码。我们可以将这些片段看作外部函数在本地代码中的代理。代码段中所有引用外部函数的地方,全部指向其相应的本地代理。其他具体的事情就交由本地代理去处理。

PLT的代码片段的逻辑如图5-34所示。

5.4.6 重定位动态库 - 图31

图 5-34 PLT代码片段

由图5-34可见:

1)代码中所有引用函数如func1、func2的地方全部替换为指向PLT中的代码片段。因为这里使用的是相对寻址,所以运行时代码段无须再进行任何修订,也就是说,代码段不需要重定位了。保证了代码段的可读属性,从而在多个进程间可以共享。

2)PLT中每个函数的代码片段除了两处数据外,基本完全相同。以调用函数func1为例,它的基本逻辑是:如果不是第一次调用func1,就说明函数func1的地址已经被解析,并且GOT表中对应的func1的地址的项也已经被正确修订了,那么直接跳转到GOT表中对应的项即可,也就是说,这样就直接跳转到了函数foo2的开头。这里,因为GOT表的前3项有特殊的用途,所以func1的地址占据GOT表的第4项。ELF标准规定,在调用PLT中的代码片段前,主调函数需要将GOT表的基址装载进寄存器ebx,所以,PLT中凡是访问got的地方,都使用ebx,*0xc(%ebx)就是GOT表中第4项的值,即函数func1的地址。读者可以回顾一下前面讨论的重定位变量一节,那里讨论确定GOT的基地址时,正是将GOT表的地址装入了寄存器ebx。

3)如果是第一次调用,那么将调用动态链接器提供的函数_dl_runtime_resolve解析函数foo1的地址。这里显然不能将函数_dl_runtime_resolve的地址直接写在PLT代码中,如果这样的话,那么PLT也需要重定位这个函数,除非使用前面提到的加载时重定位,但前面已经提到了其种种弊端。因此,动态链接器在加载库时,将函数_dl_runtime_resolve的地址填充到动态库的GOT表的第3项,而在PLT表中,则直接跳转到GOT表中第3项保存的地址,即*0x8(%ebx)。

4)在跳转到函数_dl_runtime_resolve的地址前,有两条push指令,它们就是为函数_dl_runtime_resolve准备参数的。在具体看这两条直指令前,我们先来看一下修订GOT表中的函数地址时需要的信息:

❑第一个需要的信息是当前重定位的函数在重定位表中的偏移。根据这个偏移,_dl_runtime_resolve找到相应的重定位条目,从而确定需要解析的符号的名字,以及需要修订的位置。对于函数在重定位表中的偏移,这个在编译时就可以确定,所以我们看到PLT中直接使用了确定的数字。如函数func1在重定位表中占据第1个条目,那么偏移就是0x0,这就是汇编指令"push$0x0"的作用。而对于函数foo2,因为其在重定位表中占据第2个条目,所以偏移就是0x8。

❑第二个是需要个代表当前动态库的link_map对象。要获得重定位表,当然需要知道动态库映射的基址以及段.dynamic所在的地址,而这些信息记录在库的link_map对象中。在查找符号时,其需要遍历可执行程序的link_map链表,因此,函数_dl_runtime_resolve要根据动态库的link_map对象找到link_map链表。而link_map也是在动态链接器加载库时填充到GOT表中的,它占据GOT表的第2项,这就是PLT代码中汇编语句"push 0x4(%ebx)"的作用。

5)准备好参数后,_dl_runtime_resolve将开始寻找符号,最后修订GOT表中的地址。相关代码如下:

5.4.6 重定位动态库 - 图32

_dl_runtime_resolve中核心的是调用函数_dl_fixup进行符号解析,并修订GOT表。这里使用的是寄存器传参,所以_dl_runtime_resolve在调用_dl_fixup前,将动态库的link_map存储在寄存器eax中,作为传给_dl_fixup的第1个参数;将重定位函数在重定位表中的偏移存储在寄存器edx,作为传给_dl_fixup的第2个参数。

然后,在_dl_fixup执行完毕后,会将解析的函数的地址返回。这个返回值会放在寄存器eax中,所以我们看到_dl_runtime_resolve在_dl_fixup执行完毕后,会将保存在寄存器eax中的值放到栈顶,然后调用ret指令,将这个返回地址弹出到指令指针之中,从而跳转到解析后的地址运行。

下面我们再简要看一下解析函数地址的函数_dl_fixup:

5.4.6 重定位动态库 - 图33

先看函数_dl_fixup的两个参数,第一个参数l就是传递进来的动态库的link_map;第2个参数reloc_arg就是重定位表的偏移,根据第2行代码的宏定义可见,函数体中使用的变量reloc_offset就是reloc_arg。

代码第9~12行根据传递来的link_map,首先取得动态库的动态符号表,包括SYMTAB和STRTAB。

代码第14~15行根据传进来的函数在重定位表中的偏移,从重定位表中获取对应的重定位记录reloc。

第16行代码根据重定位记录reloc中符号在动态符号表中的索引,从动态符号表symtab中取出符号的名字。

第17行代码根据重定位记录reloc中的记录的偏移,加上库映射的基址,计算出需要修订的位置。当然这个位置对应的是GOT表中的某一项。

代码第21~22行调用_dl_lookup_symbol_x遍历link_map链表,查找符号的地址。

代码第26~27行调用elf_machine_fixup_plt修订GOT表中对应的项,函数elf_machine_fixup_plt中就一条代码,如代码第37行,就是给GOT表的某一项赋个符号地址而已。

理论上,函数的重定位过程可以就此完成了。但是,上述方法还有些瑕疵:

❑在PLT代码片段中,需要设计标志来表示函数是否是第一次调用。

❑在PLT代码片段中,编译器的实现者们不想做那个多余的if判断,即函数是否是第一次调用的判断。尽管这可能只是一次跳转和一次访存,但是编译器的实现者们还是想把它们节省下来。

于是,编译器的设计者们在上述基础上,做出了更进一步的改进,如图5-35所示。

5.4.6 重定位动态库 - 图34

图 5-35 PLT代码片段

我们看到,PLT中的代码片段不再进行任何判断,而是直接跳转到GOT表中用来保存解析的函数的地址的表项。这里面最关键的一个技巧就是图5-35中用黑体标识的GOT表中的两项。编译时,编译器将函数对应的项的地址初始化为PLT代码片段中jmp语句的下一条地址。在动态库加载时,动态器会在此基础上,再加上动态库的映射的基址。如此,当第一次执行这个函数时,jmp语句并没有跳转到真正的函数的地址处,而是直接相当于执行PLT代码片段中的下一条语句,即压栈参数,然后调用_dl_runtime_resolve解析函数地址,使用解析的符号的地址修订GOT表中的项,然后跳转到解析的函数的地址,执行函数。

这里不知是否有读者有过这样的设想:程序加载时,将函数的GOT表项直接填写为函数_dl_runtime_resolve的地址,是不是更合理?非也,GOT表一项只有4字节,只能保存一个地址,而调用_dl_runtime_resolve之前,还需要其他指令准备参数。

经过第一次调用后,GOT表中的函数对应的项已经变为真正的函数的地址,下次再次调用时,将直接跳转到函数的地址继续执行,如图5-36所示。

5.4.6 重定位动态库 - 图35

图 5-36 PLT代码片段

观察图5-36会发现,PLT中func1@plt中的地址为0x7和0x8处两行的代码,以及func2@plt中地址0xe和0xf处的代码完全一样。事实上,所有函数的PLT片段的最后两行都完全相同。于是,PLT将这两行代码独立为一个“子函数”plt0。进一步改进后PLT的代码如图5-37所示。

5.4.6 重定位动态库 - 图36

图 5-37 PLT代码片段

下面我们以库libf1中的函数foo1_func调用库libf2中的函数foo2_func为例,来具体体会一下前面的理论分析。反汇编库libfoo2,并截取引用函数foo2_func的有关部分:

5.4.6 重定位动态库 - 图37

先来看地址0x5b3处的指令。汇编指令call的操作数0xfffffe98(补码)对应的原码是-0x168,call指令的操作数是一个相对寻址,因此-0x168是目标地址和下一条指令的差值。因为下一条指令的地址是0x5b8,所以跳转的目的地址是:

5.4.6 重定位动态库 - 图38

地址0x450处正是PLT中对应函数foo2_func的片段。我们看到地址0x450处的汇编指令跳转到GOT表中偏移为0x14处中的值表示的地址处。那么GOT表中这个位置处保存的是什么呢?我们需要到记录函数重定位的表—.rel.plt中寻找答案:

5.4.6 重定位动态库 - 图39

动态库libf1的GOT表的基址为0x2000,所以偏移0x14处的地址即为0x2014,也就是重定位表中的第3条记录。可见,这条重定位记录要求动态链接器使用符号foo2_func的值填充地址为0x2014处的GOT表项。根据前面的理论分析,初始时,这个地址指向下一条push指令,即地址0x456处的指令。所以,当首次调用foo2_func时,地址0x450处的指令跳转到了地址0x456处。

地址0x456处的指令压栈了一个立即数0x10。根据前面的理论分析,这是为符号解析函数_dl_runtime_resolve压栈的一个参数,即需要重定位的函数在重定位表中的偏移。根据重定位表中的信息,函数_dl_runtime_resolve就可以找到与重定位函数相关的信息,如重定位函数的符号名称、需要修订的位置等。0x10用十进制表示是16,也就是从重定位表.rel.plt开始偏移16字节,重定位表中每个条目占据8字节,因此偏移16字节处的第3条重定位记录正是记录函数f002-func的重定位信息。

继续看下一条指令,即地址0x45b处的指令。也是一条相对跳转指令,补码0xffffffc0的原码是-0x40,所以跳转的目的地址是:

5.4.6 重定位动态库 - 图40

objdump工具虽然显示地址0x420处的函数的名字是"cxa_finalize@plt-0x10",实际上与函数"cxa_finalize"没有任何关系,这里解析的有一点bug,忽略即可。地址0x420处就是PLT表的第0项。我们看到plt0首先将GOT表中偏移0x4处,即GOT表第2项的值(库libf1的link_map)压栈,显然是给解析函数传参。然后跳转到GOT表的偏移0x8处,即第3项,也就是解析函数_dl_runtime_resolve的地址处执行,该函数解析符号foo2_func,然后使用解析得到的符号f002-func的运行时地址修订GOT表中偏移0x14处,即第6项,然后跳转到函数foo2_func执行。

首次调用函数foo2_func后,GOT表中第6项保存的就是foo2_func的地址了。以后再次调用该函数时,PLT中的foo2_func@plt将不再跳转到函数_dl_runtime_resolve处解析函数了,而是直接跳转到函数foo2_func处。

在静态分析后,下面我们再动态观察一下函数foo2_func的重定位过程。

我们首先来看一下编译时库libf1的GOT表中第6项,即偏移0x2014处,保存的内容是什么,前面我们已经讨论过了,理论上这里应该是foo2_func@plt中push指令的地址:

5.4.6 重定位动态库 - 图41

5.4.6 重定位动态库 - 图42

注意上面使用黑体标识的部分,编译时偏移0x2014处的4字节初始化为0x0456,正是foo2_func@plt中push指令的地址。

我们将hello运行起来,观察一下GOT表中第6项的变化情况:

5.4.6 重定位动态库 - 图43

我们在另外一个终端中查看库libf1在进程hello的地址空间中映射的基址:

5.4.6 重定位动态库 - 图44

根据输出可见库libf1在进程hello的地址空间中映射基址是0xb7fd8000。虽然说函数foo2_func的地址是在使用时再去重定位,但是加载时动态链接器还是要做一个重定位。读者不禁要问,重定位什么呢?我们以GOT表的第6项,即偏移0x2014处的值为例。在编译时,我们看到链接器将此处的地址填充为0x0456,即jmp后的push指令的地址。但是不知读者是否注意到,这个地址是相对于0的地址,在加载后,当动态库libf1的映射基址确定为0xb7fd8000后,显然需要修订这个地址为:

5.4.6 重定位动态库 - 图45

我们通过gdb看一下实际的输出:

5.4.6 重定位动态库 - 图46

可见,GOT表中的这一项在加载时确实修订了。

在foo2_func第一次执行后,这个GOT表中的地址就应该修订为foo2_func的地址,我们看一下库libf2中为foo2_func分配的地址:

5.4.6 重定位动态库 - 图47

5.4.6 重定位动态库 - 图48

而动态库libf2在进程hello的地址空间中映射的基址是:

5.4.6 重定位动态库 - 图49

所以,符号foo2_func的运行时地址是:

5.4.6 重定位动态库 - 图50

我们通过gdb来查看一下foo2_func执行一次后,GOT表中的保存这个函数的地址被修订成了什么:

5.4.6 重定位动态库 - 图51

可见,在首次调用后,GOT表中的值已经修订为符号foo2_func的运行时地址。