深度探索Linux操作系统 —— 从内核空间到用户空间2:https://developer.aliyun.com/article/1598086
3. 重定位函数
前面我们讨论了变量的重定位,本小节我们讨论函数的重定位。理论上,函数的重定位使用与变量相同的方法即可。但是,因为相对比较少的全局变量的引用,函数引用的数量可能要大得多,因此函数重定位的时间不得不考虑。
事实上,读者回想一下我们日常开发的程序,其实很多代码不一定能全部执行,比如有些分支、错误处理等。而且,即使可执行程序本身使用的函数数量并不大,但是可执行程序依赖的动态库可能还会引用其他动态库中的函数,这些动态库再依赖其他的动态库,如此,需要重定位的函数的数量不容小觑。更重要的是,可执行程序可能根本就用不到这些动态库中的函数,因此,加载时重定位函数只会延长程序启动的时间,但是重定位的某些函数却可能根本就用不到。出于以上考虑, PIC 对于函数的重定位引入了延迟绑定技术(lazy binding)。
也就是说,在加载时,动态链接器不解析任何一个需要重定位的函数的地址,而是在运行时真正调用时,再去重定位。为此,开发者们引入了 PLT(Procedure Linkage Table)机制。在 GOT 表的巧妙配合下,PIC 将函数地址的解析推迟到了运行时。
在编译时,链接器在代码段中插入了一个 PLT 代码片段,每个外部函数在 PLT 中都占据着一小段代码。我们可以将这些片段看作外部函数在本地代码中的代理。代码段中所有引用外部函数的地方,全部指向其相应的本地代理。其他具体的事情就交由本地代理去处理。
PLT 的代码片段的逻辑如图 5-34 所示。
由图 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 表中的地址。相关代码如下:
_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 指令,将这个返回地址弹出到指令指针之中,从而跳转到解析后的地址运行。
我们看到,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-36 会发现,PLT 中 func1@plt 中的地址为 0x7 和 0x8 处两行的代码,以及 func2@plt 中地址 0xe 和 0xf 处的代码完全一样。事实上,所有函数的 PLT 片段的最后两行都完全相同。于是,PLT 将这两行代码独立为一个 “子函数” plt0 。进一步改进后 PLT 的代码如图 5-37 所示。
下面我们以库 libf1 中的函数 foo1_func 调用库 libf2 中的函数 foo2_func 为例,来具体体会一下前面的理论分析。反汇编库 libfoo2,并截取引用函数 foo2_func 的有关部分:
先来看地址 0x5b3 处的指令。汇编指令 call 的操作数 0xfffffe98(补码)对应的原码是 -0x168,call 指令的操作数是一个相对寻址,因此 -0x168 是目标地址和下一条指令的差值。因为下一条指令的地址是 0x5b8 ,所以跳转的目的地址是:
地址 0x450 处正是 PLT 中对应函数 foo2_func 的片段。我们看到地址 0x450 处的汇编指令跳转到 GOT 表中偏移为 0x14 处中的值表示的地址处。那么 GOT 表中这个位置处保存的是什么呢?我们需要到记录函数重定位的表 —— .rel.plt 中寻找答案:
动态库 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 条重定位记录正是记录函数 foo2_func 的重定位信息。
继续看下一条指令,即地址 0x45b 处的指令。也是一条相对跳转指令,补码 0xffffffc0 的原码是 -0x40,所以跳转的目的地址是:
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 指令的地址:
注意上面使用黑体标识的部分,编译时偏移 0x2014 处的 4 字节初始化为 0x0456 ,正是 foo2_func@plt 中 push 指令的地址。
我们将 hello 运行起来,观察一下 GOT 表中第 6 项的变化情况:
我们在另外一个终端中查看库 libf1 在进程 hello 的地址空间中映射的基址:
根据输出可见库 libf1 在进程 hello 的地址空间中映射基址是 0xb7fd8000 。虽然说函数 foo2_func 的地址是在使用时再去重定位,但是加载时动态链接器还是要做一个重定位。读者不禁要问,重定位什么呢?我们以 GOT 表的第 6 项,即偏移 0x2014 处的值为例。在编译时,我们看到链接器将此处的地址填充为 0x0456,即 jmp 后的 push 指令的地址。但是不知读者是否注意到,这个地址是相对于 0 的地址,在加载后,当动态库 libf1 的映射基址确定为 0xb7fd8000 后,显然需要修订这个地址为:
我们通过gdb看一下实际的输出:
可见,GOT 表中的这一项在加载时确实修订了。
在 foo2_func 第一次执行后,这个 GOT 表中的地址就应该修订为 foo2_func 的地址,我们看一下库 libf2 中为 foo2_func 分配的地址:
而动态库 libf2 在进程 hello 的地址空间中映射的基址是:
所以,符号 foo2_func 的运行时地址是:
我们通过 gdb 来查看一下 foo2_func 执行一次后,GOT 表中的保存这个函数的地址被修订成了什么:
可见,在首次调用后,GOT 表中的值已经修订为符号 foo2_func 的运行时地址。
7、重定位可执行程序
可执行程序如果引用的是自身定义的函数和变量,这些符号在编译时就已经确定,不需要任何重定位。即使其他动态库中也定义了与可执行程序中相同的符号,链接器也优先使用可执行程序自身定义的函数和变量。
如果引用了动态库中的函数和全局变量,那么编译时可执行程序根本不知道这些符号最终的地址,在重定位了动态库之后,可执行程序也需要重定位这些符号。可执行程序的重定位与共享库原理基本一致,只有一点差别,我们这里简单讨论一下它们之间的差别。
(1)重定位引用的动态库中的函数
我们以 hello 中引用动态库 libf1 中的函数 foo1_func 为例,来看关于函数的重定位。可执行程序 hello 中调用 foo1_func 的反汇编代码如下:
可见,可执行程序也使用了延迟绑定的技术。再来看看 PLT 部分的代码:
与动态库不同,可执行程序的地址在编译时就已经分配好了,所以,GOT 的地址在编译时就确定了,不必再如动态那样在运行时动态获取 GOT 表的基址。我们来看看 hello 的 GOT 表的基址:
GOT 表的基址为 0x0804a000,所以任何以 GOT 表基址为参照的偏移,直接使用这个地址即可。比如访问 GOT 表中的第 3 项,即函数 _dl_runtime_resolve 时,直接在此地址上加两个 4 字节偏移即可(因为 _dl_runtime_resolve 占据 GOT 表的第 3 项,所以偏移 8 字节):
观察 hello 中 plt0 部分,即地址 0x8048486 处,我们看到,指令中也确实是这么做的,jmp 的目标地址在编译时就计算好了,就是 *0x804a008 。
除 GOT 表的基址固定外,可执行程序函数的重定位与动态库中函数的重定位完全一致。
(2)重定位引用的动态库中的变量
可执行程序与动态库不同,一般而言,其地址是编译时分配好的,是固定的(这里我们不考虑为了安全而使用 PIE 技术)。如果编译时没有传给编译器参数 “-fPIC” ,那么对于引用的外部的全局变量,可执行程序不使用 GOT 表的方式寻址。换句话说,可执行程序引用的变量,在编译链接时就需要在编译链接时确定好地址,不能在加载时再进行重定位。
但是,编译时动态库都不能确定自己的变量的最终加载地址,更别提可执行程序了。那怎么办呢?于是 ELF 标准定义了一种新的重定位类型 —— R_386_COPY。对于这种重定位类型,编译器、链接器和动态链接器是这样协作的:编译时,编译器将偷偷地在可执行程序的 BSS 段创建了一个变量,这样就解决了编译时,变量地址不确定的问题。在程序加载时,动态链接器将动态库中的变量的初值复制到可执行程序的 BSS 段中来。然后,动态库(包括其他动态库)在引用这个变量时,因为可执行程序在 link_map 的最前面,所以解析符号都将使用可执行程序中的这个偷偷创建的变量。
下面我们结合 hello 引用动态库 libf1 中的变量 foo1 来具体的讨论一下。先来看一下 hello 的动态符号表:
虽然我们没有在可执行程序中定义变量 foo1,但是根据动态符号表可见,可执行程序 hello 中却定义了变量 foo1,其所在地址是 0x0804a028,而且在第 25 个段中。我们来看看第 25 个段是什么:
可见,第 25 个段是 .bss 。也就是说,编译时,链接器为可执行程序 hello 定义了一个未初始化的全局变量 foo1 。而 hello 中,使用的恰恰是 hello 自己的 foo1 ,而不是库 libf1 中的 foo1。观察下面中引用的符号 foo1 的地址,正是 hello 中定义的符号 foo1 的地址:
链接器将 hello 的重定位表中 foo1 的重定位类型设置为 R_386_COPY ,当处理这个类型的重定位时,动态链接器将在加载时,将库 libf1 中变量 foo1 的值复制到 hello 中的 foo1:
下面我们将程序运行起来,动态观察一下 R_386_COPY 类型的重定位过程。
理论上,动态链接器应该将库 libf1 中的 foo1 的初值 10 复制到 hello 中定义的 foo1 处。我们将 hello 中定义变量 foo1 所在地址实际的值打印出来:
可见,hello 中的 foo1 已经被赋值为库 libf1 中的 foo1 的初值 10 了。
另外,库 libf1 中 GOT 表中保存的 foo1 的地址,也应该指向 hello 中定义的 foo1 的地址,而不是库 libf1 中的变量 foo1 的地址。原因是链接时,可执行程序排在链表 link_map 的表头,所以 hello 中的符号 foo1 当然要优先于库 libf1 中的 foo1 。我们来实际验证一下这一点,首先找到库 libf1 中变量 foo1 所在位置:
在另外一个终端中查看库 libf1 在进程 hello 的地址空间中映射的基址:
库 libf1 的 GOT 表中记录符号 foo1 的地址是:
我们打印一下 GOT 表中的值:
根据输出可见,地址 0x0804a040 正是 hello 中定义的符号 foo1 的地址。可见,动态库 libf1 中使用的 foo1 变量是可执行程序中创建的这个副本。显然,虽然这个副本仅仅是编译器为其偷偷分配的,但是实际已经取代了库 libf1 中的 foo1,已经转正了。
当然,在编译可执行程序时也可以给其传递参数 “-fPIC” ,如此,可执行程序中对外部变量的应用也将采用 GOT 表的方式,但是这对可执行程序没有任何意义。
8、重定位动态链接器
在 Linux 中,动态链接器被实现为一个动态库的形式,而且这个动态库是自包含的(self-contained),没有引用其他库的符号,但是与普通动态库一样的道理,它在编译时也不知道自己的确切位置,所以它也难逃重定位的命运。事实上,当 C 库加载后,动态链接使用了 C 库中的内存管理相关函数替换了自身的实现。
查看一下动态链接器的重定位表就可见其需要重定位的符号:
但是,与动态库和可执行程序不同,它们有动态链接器负责为它们重定位,而动态链接器则没有这么好的命。在内核跳转到动态链接器时,它是非常残酷的,并没有给动态链接器如 link_map 信息。好在动态链接器不依赖其他的动态库,只需要确定自己被加载的基地址,然后找到动态链接需要的段 .dynamic 就可以解决问题,后续的重定位过程与动态库的过程基本完全相同。因此,动态链接器重定位自己的关键是:
◆ 确定自己被加载的基地址;
◆ 找到段 .dynamic 。
动态链接器被加载的地址就相当于 link_map 中的 l_addr 了。运行后,动态链接器可以获取到某个符号的地址,但是这并不足以计算出动态链接器在进程地址空间中映射的基址,只有对比,才能求出基址。因此,动态链接器还是需要编译时的链接器作一点小小的配合。在编译时,链接器定义了一个符号 “_DYNAMIC” :
定义这个符号的目的就是为了标识段 .dynamic 所在的地址,看下面动态链接器的 Section Header Table:
由上可见,符号 _DYNAMIC 的地址正是段 .dynamic 的地址。在运行时,动态链接器使用如 x86 指令 lea 读取符号 _DYNAMIC 的运行时地址,实际就是读取运行时段 .dynamic 的地址。
除了定义了这个符号外,在编译时,段 .dynamic 的地址也被装载到了 GOT 表中的第 1 项。读者回忆一下在 5.4.6 节讨论 GOT 表时的内容。其中,第 2 项的 link_map 和第 3 项的解析函数我们都已经看到其作用了,但是尚未看到第 1 项的意义。在重定位动态链接器时,这一项发挥了关键作用。前面我们就已经看到过 ,编译时定义了另外一个符号 _GLOBAL_OFFSET_TABLE_ ,目的与 _DYNAMIC 相似,是为了标识 GOT 表的地址。因此,动态链接器就可以使用符号 _GLOBAL_OFFSET_TABLE_ 找到 GOT 表,从而取出 GOT 表中第 1 项的值。
然后,使用取得的符号 _DYNAMIC ,也就是段 .dynamic 的运行时地址,与 GOT 表第一项在编译时保存的段 .dynamic 的地址(其是相对于 0 的)做差,得出的就是动态链接器在进程地址空间映射的基址了。相关代码如下:
注意变量 bootstrap_map,相信从名字读者已经猜出来了,相当于代表普通动态库和执行程序的 link_map。而且根据这个变量的名字,我们也可以揣摩到开发者的用意是在表达这是动态链接器的自举过程。变量 bootstrap_map 中的关键两项读者应该非常熟悉了,l_addr 是代表动态链接器自己被映射的地址,l_ld 代表动态链接器的段 .dynamic 所在的地址。找到段 .dynamic 后,动态链接器调用 elf_get_dynamic_info 读取了这个段的信息。
我们来看看获取 l_addr 和 l_ld 这两个地址的函数:
函 数 elf_machine_dynamic 利用在编译时定义的符号 _GLOBAL_OFFSET_TABLE_ 读取 GOT 表中第 0 项的值。
函数 elf_machine_load_address 计算动态链接器加载的地址。其首先取得符号 _DYNAMIC 的运行时地址,对于 x86 来说,可以使用指令 lea ,然后与 GOT 表中保存的编译时的地址做差,从而得出动态库在进程地址空间中映射的基址。
事实上,动态连接器重定位表中的那些动态内存管理的函数,如 malloc、free 等,最初动态链接器使用的是自己内部的实现:
但是一旦 C 库加载后,动态链接器将再次重定位这几个函数,使用 C 库中的相应实现。
9、段 RELRO
最初,编译时链接器并没有过多考虑 ELF 文件中各个段的布局,一个 ELF 文件各个段的大致布局如图 5-38 所示。
可见,动态链接器重定位涉及的 GOT 表、段 .dynamic 都位于数据段的后面,一旦数据段发生溢出,动态链接器使用的 GOT 表、段 .dynamic 都可能受到破坏,尤其是作为函数跳转表的 GOT 表,更容易被攻击者利用。而事实上,除了函数被延迟到运行时重定位外,变量等的重定位在加载时就已经完成了,后续动态链接器不再会对这些段进行写操作,也就是说完全可以在完成加载时重定位后,把这部分数据修改为只读。
因此,如今的链接器重新安排了各个段的布局,将动态链接器涉及到的段提到了数据段的前面,并将 GOT 拆分为两个部分:.got 和 .got.plt 。.got 部分用于记录需要重定位的变量,.got.plt 部分用于记录需要重定位的函数。在加载时完成重定位后,除了 .got.plt 仍然保留可写属性,允许在运行时进行重定位外,包括 .got 在内的其余部分全部更改为只读,减少被攻击的可能。
这些在重定位后更改为只读的段被称为 RELRO 段。从 Program Header Table 的角度看,段 RELRO 仍然包含于数据段中,只不过是数据段开头部分一块只读的数据而已。经过上述调整后,一个 ELF 文件的大致布局演化为如图 5-39 所示的形式。
在加载时完成重定位后,动态链接器将检查 ELF 文件的 Program Header Table 中是否存在段 RELRO 。如果这个段存在,则将这个段更改为只读,从而达到保护更多数据的目的。