如何从链接原理的角度理解 fishhook 的设计思想?
最近在三刷《程序员的自我修养:链接、装载与库》,为了加深对于相关知识的理解,我又阅读了 fishhook 的源码。本文希望从程序的链接原理出发,详细介绍 fishhook 的设计原理,学习其中的设计思想。
概述
Fishhook 是 Facebook 开源的一款面向 iOS/macOS 平台的 符号动态重绑定 工具,允许开发者在运行时修改 Mach-O 中的符号(函数),从而实现 动态库 的函数 hook 能力。
Fishhook 提供了两个用于符号重绑定的接口,分别是:
1 | int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel); |
其中,rebind_symbols
可以在所有动态库范围内进行符号重绑定,而
rebind_symbols_image
则限制了动态库的范围,只能指定某一个动态库。
这里,我们先预设几个问题,后面会逐步进行解答:
- 问题一:fishhook 是在什么时候完成函数 hook 的?
- 问题二:fishhook 为什么只支持 hook 动态库函数?
为了能介绍清楚 fishhook 的实现原理,本文我将重点介绍程序的链接原理,包括:静态链接、动态链接。其中,涉及到的术语和概念主要是基于 ELF 可执行文件(或目标文件),在真正介绍 fishhook 的原理时,我会将 Mach-O 中的术语与 ELF 进行比较和映射,从而达到一个举一反三的效果。
可执行文件格式
在介绍链接原理之前,我们有必要先了解一下可执行文件(目标文件)的基本格式,不同的平台有着不同的格式,分别是:
- 对于 Windows 平台,其采用的是 PE(Portable Executable) 格式
- 对于 Linux 平台,其采用的是 ELF(Executable Linkable Format) 格式
- 对于 iOS/macOS 平台,其采用的是 Mach-O(Mach Object) 格式
尽管不同平台的可执行文件格式不同,但是它们的组织结构和规则是基本类似的。如下图所示,不同格式的可执行文件基本都包含如下几个部分:
- 文件头
- segment 表
- section 表
- section 数据
文件头用于描述可执行文件的元信息,包括:文件类型、系统版本、segment
表的位置和大小、section 表的位置和大小等等。Section
表本质上是一个索引表,其存储了每一个 section 的元信息,比如对应 section
在文件中的位置和大小。至于 section,它是可执行文件的基本组成单元,常见
section
有:.text
、.data
、.bss
、.symtab
、.strtab
等。
那么 segment 表的作用又是什么呢?
section 与 segment
事实上,两者的区别主要在于:section 用于描述可执行文件的静态存储布局,segment 用于描述可执行文件的装载内存布局。
我们知道可执行文件是以 section 为基本单元存储的,section
的类型非常多,如:.data
、.text
、.rodata
等。假如,我们的可执行文件中有两个 section,分别是 .init
和
.text
,两者的大小分别是 3500B 和
4100B。假设系统的页面大小为 4KB,我们来分别看一下基于 section 装载和基于
segment 装载的内存占用情况。
下图右部所示为基于 section 装载的内存占用情况,其中
.init
单独占用一个页,且页没有全部使用;.text
会单独占用两个页,且第二页绝大多数内存空间没有使用,总共浪费内存 3 x 4KB
- 3500B - 4100B = 4688B。
下图左部所示为基于 segment 装载的内存占用情况,.text
占用了两个页,且与 .init
共享了一个页,总共浪费内存 2 x 4KB
- 3500B - 4100B = 592B。
很显然,相比于基于 section 装载,基于 segment
装载对于内存占用的优化非常明显,内存碎片更少。在实际中,程序在装载时会将相同权限的
section 合并在一个 segment 中,比如:.init
和
.text
都合并成为可读可执行权限的
segment,作为代码段;可读可写的 section 合并在为一个
segment,作为数据段。
程序的链接原理
链接(Linking) 的本质是把多个目标文件相互拼接到一起,使得函数调用、变量访问等指令能够找到正确的内存地址。然而,这一切都是围绕着 符号(Symbol) 完成的。
那么到底什么是符号?举个例子,目标文件 B 调用了目标文件 A 中的函数
foo
。对此,我们认为目标文件 A 定义了函数
foo
,目标文件 B 引用了函数
foo
。在链接过程中,我们将函数和变量统称为
符号(Symbol),函数名和和变量名统称为
符号名(Symbol Name)。因此,我们也可以认为目标文件 A
包含了函数 foo
的 符号定义(Symbol
Definition),目标文件 B 包含了函数 foo
的
符号引用(Symbol Reference)。
这时候问题来了,链接过程是如何基于符号完成对二进制指令中内存地址的修正呢?对此,我们可以先来了解一下静态链接。
静态链接
静态链接会在编译期将多个目标文件合并为一个可执行文件。因此,里面包含了所有的符号、重定位项、字符串等。
在编译过程中,编译器会为每一个变量或函数生成一个符号项,符号项包含的信息主要有:
- 符号名:即一个指向字符串表的索引,比如:字符串
foo
在字符串表中的偏移量。 - 符号类型:类型有很多,比如:全局符号、局部符号、未定义符号等。
- 符号值:符号定义 的内存地址,用于修正二进制指令中的内存地址。这个地址修正的过程被称为 重定位。
此外,编译器还会为每个变量引用或函数引用生成一个重定位项。由于每一个重定位项记录了每一次对于符号的引用,因此,我们可以将其称为符号引用项。这样也就构成了符号定义和符号引用的一对多关系,毕竟,我们可以在不同的地方引用同一个变量或函数。
基于如下示意图,静态链接的整体工作原理大概可以分为以下三个步骤:
- 根据重定位项中的符号索引,去符号表找到对应的符号项,并获取到对应符号的符号值,即内存地址。
- 根据重定位项中的重定位地址,找到代码段中对应的字节地址,将其修正为步骤一获取到的内存地址。
- 遍历重定位表中的所有重定位项,重复步骤一和步骤二。
由于静态链接时,程序所依赖的所有目标文件都已经合并在了一个可执行文件中,因此几乎不存在符号项中的符号值(内存地址)不确定的情况,对此,静态链接器只需要基于重定位表进行重定位即可。这其实就是大家常说『静态链接的重点是重定位』的原因。
动态链接
动态链接的基本思想是 将程序按照模块拆分成各个独立的部分,在运行时将它们链接在一起形成一个完整的程序,而不是像静态链接一样在编译时把所有的模块都链接成一个独立的可执行文件。因此,动态链接可以有效解决静态链接存在的 内存空间浪费 和 程序更新困难 的问题。
那么对于动态链接,我们是否可以直接采用静态链接的做法呢?这种方案理论上可以,但却不是最优解,因为静态链接会修改代码段,我们很难让共享对象在被多次重定位之后也能继续安全稳定的运行。
举一个例子,如下所示,一个动态共享对象 X
内部会引用外部的一个变量 a
。当程序 A
与动态共享对象 X
完成重定位后,X
代码段中的某个指令的访存地址可能是一个值;当程序 B
与动态共享对象 X
完成重定位后,X
代码段中同位置的访存地址可能会被修改成另一个值。这时候,必然会出现其他程序无法正常执行的情况。
关于如何解决多进程之间的重定位冲突问题,我们可以引用下图所示的经典名言来描述动态链接的解决方案。当然,在具体的实现中,动态链接根据链接的时机,还可以分为 装载时链接(Load-Time Linking) 和 延迟链接(Lazy Linking)。两者的实现思路只有略微的差异,下面我们将分别进行介绍。
装载时链接
下图所示为装载时链接的工作原理示意图。对于共享对象而言,其代码段会被多个进程所共享,因此不能直接在代码段中进行重定位,修改内存地址。考虑到多进程共享对象时,共享对象会为每个进程拷贝一份数据段,支持修改。因此,一种称为 地址无关代码(PIC,Position-Independent Code) 的技术诞生了,其基本思想是:在编译时配置 PIC 编译选项,将指令部分中需要被修改的部分分离出来,跟数据部分放在一起。这样指令部分可以保持不变,而数据部分可以在每个进程中有一个独立的副本。
对于 PIC 技术,代码运行性能会比静态链接要差一点。因为指令在访问外部变量或外部函数时,必须先通过指针去数据段找到对应的位置,再从中取出真实的内存地址,很显然多了一次间接操作,损耗了性能。
在装载前,共享对象 X
的符号表中的外部符号
bar
的内存地址是未定义的。但是,程序 A
的符号表中的符号 bar
的内存地址是确定的(因为符号
bar
的符号定义位于程序 A
中)。因此,在装载时我们就可以决议出共享对象 X
的外部符号
bar
的地址。这个过程,我们称之为
装载时绑定(Load-Time Binding) 或
装载时符号绑定(Load-Time Symbol Binding)。
当外部符号 bar
的内存地址绑定完成后,我们就可以进行后续的重定位了。其步骤和静态链接的重定位类似,主要包括以下几步:
- 根据动态重定位项中的符号索引,去动态符号表中找到对应的符号项,并获取对应符号的符号值,即装载时绑定的内存地址。
- 根据动态重定位项中的重定位地址,找到 数据段 中对应的字节地址,将其修正为步骤一获取到的内存地址。
- 遍历动态重定位表中的所有重定位项,重复步骤一和步骤二。
在 PIC 技术中,编译器会在数据段中为每一个符号存储一个占位桩(stub),用于存储符号的真实内存地址。这些占位桩组成了一个表,我们称之为 全局偏移表(GOT,Global Offset Table)。
综上述可以看出,装载时链接包含了两个重要的步骤,分别是装载时绑定和