提起 iOS 中的 hook 手段,我们最先想到的是 Method Swizzling,这是 hook OC 方法最重要的手段。fishhook 则提供了 hook 系统 c 函数的手段。
动态链接
为什么要有动态链接
为了减少应用的体积,加速应用的启动速度。苹果系统会将很多系统库设计为动态库。动态库的实际地址在应用编译的时候是未知的。一些符号会在应用启动的时候链接。但是如果这样的符号过多就会拖慢应用的启动速度。因此另一些非必要符号会在第一次使用的时候绑定,也就是 Lazy Binding。
从例子看 lazy Binding
我们来通过一个系统方法 NSLog 来验证动态库符号的绑定过程。我们在 main.m 中输入两个打印语句,然后加上断点:

我们来看一下它的汇编执行过程,需要先设置:debug -> debug workflow -> always show disassembly。断点情况下的汇编代码如下:

我们看到第14行,bl 0x10470ebf0; symbol stub for: NSLog 就是执行打印的方法。这个方法对应于可执行文件的哪个段呢?我们通过 MachOView 查看它的可执行文件:

在 __Text,__stubs 中有一个对应于 NSLog 的方法。它的 offset 是 00006BF0。但是我们实际的地址是 0x10 470ebf0,这两个地址显然是不匹配的。这是因为 MachOView 中的 offset 是相对于 __Text 段开始的 offset.(Header 和 Load Commands 也是属于 __Text 段的 )。如果要换算到实际地址,就需要加上 __Text 段的实际地址。以下是地址的换算关系:
虚拟地址 + ASLR = 实际地址
__Text的虚拟地址 + ASLR =__Text的实际地址
__Text的虚拟地址 + 对象的 offset = 对象的虚拟地址
__Text的实际地址 + 对象的 offset = 对象的实际地址
我们可以通过 lldb 中执行 image list 查看 __Text 段的实际地址:

如上图所示,image list 能列举出当前应用加载的所有动态库。其中第一个就是当前应用的地址,也就是 0x0000000104708000。即
0x00006BF0 + 0x104708000 = 0x10470ebf0
由于
__Text虚拟地址默认是 0x100000000,因此,我们还能计算得到 ASLR 地址:0x104708000 - 0x100000000 = 0x4708000
当然,ASLR 地址并不是我们关注的重点,只是略带提及。
如果要直接获取 ASLR 地址,可以使用
image list -o -f指令,就会默认减去__Text段的虚拟地址
那么这个地址上的方法是什么呢?可以在 lldb 中通过 dis 命令反汇编输出:
在 MachOView 中这个地址的数据为 1F2003D5F0A0005800021FD6 。这是一段16进制的机器码。我们可以验证一下它和反汇编的结果是否一致。如果我们自己查表会非常费事,有一个 ARM to Hex 的网站,可以帮助我们将汇编代码转换为 Hex。我们只验证第二句汇编语句的正确性:

通过对比可以发现,MachOView 中相应 offset 的 Data 反汇编后就是 dis 生成的汇编代码。
重新看一下反汇编后的第二三条汇编代码。他们的作用是加载相对于当前地址,偏移量为 0x141c 的内存到寄存器 x16 中,然后执行。实际执行的地址的计算过程如下:
0x141c + 0x6BF4 = 0x8010
第一条汇编代码 offset 为 0x6BF0,那么第二条汇编代码 offset 即为 0x6BF4
在 MachOView 中体现为 __DATA,__la_symbol_ptr:

在 0x8010 的地址上的 data 为 0x0000000100006C2C。前面说到, __Text 虚拟地址默认是 0x100000000,这在 TEXT 段的 Load Commands 中有所体现:

所以实际的偏移地址为 0x6C2C。它的地址在 __Text,__stub_helper 中:

__Text,__stub_helper 会调用 dyld_stu_binder 方法计算并绑定 NSLog 函数的真实地址。
让程序走过这个方法,再次通过 dis 查看汇编代码:

此次执行的方法地址不再是 0x10470ec2c,而是 0x2065176e6。也就是说进过了绑定之后, __Text,__stub 的方法指向发生了改变,指向了系统的动态库的方法。至此完成了 lazy binding 的过程。
从例子看非 lazy binding
非 lazy binding 的符号会在应用启动的时候就 binding 完成。我们简单验证一下。首先看 __DATA,_nl_symbol_ptr 段在 MachOView 中的符号信息,在 MachOView 中的非 lazy binding 符号指向的地址都是 0x0:

当程序运行并加载成功后,我们再看相应位置的 data:

可以看到相应位置已经不再是 0x0,而是具体的链接完成后的地址了。
fishhook
通过上面的两个例子的分析,我们能知道,动态链接的符号不是位于程序的 __Text 段的,而是存在于 __DATA 段中。位于 __Text 段的符号是只读的,而位于 __DATA 段的符号是可读可写的
这种懒绑定的方式其实叫做 PIC(Position Independent Code 地址无关代码),fishhook 能够帮助我们修改这部分符号的地址
使用
fishhook 的使用需要创建一个 rebinding 结构体,结构体中需要包含要 hook 的函数的名称,要替换的方法实现,被 hook 的方法的容器:
1 |
|
源码解析
rebind_symbols 方法
先从调用方法 rebind_symbols 方法入手:
1 | struct rebindings_entry { |
先看 prepend_rebindings 方法,它会把传入的 rebindings 数组串成一个链表,链表的头部用 _rebindings_head 保存:
1 | /** |
在进行过了可能的多次 prepend_rebindings 方法后,会形成如下链表:

当然由于最开始 rebindgs_header 是 null,所以 next_entry->next = *rebindings_headr 就是 null,也就是说 *rebinding_head = new_entry 后, *rebinding_head->next 为 null。在 rebind_symbolds 方法中会执行 _dyld_register_func_for_add_image(_rebind_symbols_for_image); 方法。
_dyld_register_func_for_add_image 方法是 dyld 注册回调函数的方法,当镜像被加载的时候,就会主动触发注册的回调方法。
一个可执行文件会加载非常多的动态库,每个动态库的成功加载都会触发注册的回调方法。每个动态库镜像都会根据设置重绑定符号
此处注册了 _rebind_symbols_for_image 方法:
1 | static void _rebind_symbols_for_image(const struct mach_header *header, |
_rebind_symbols_for_image 方法非常的朴实,它会受到 dyld 加载成功时候传入的两个参数 mach_header *header 和 intptr_t slide。这两个参数分别是当前可执行文件的内存地址和 ASLR 偏移量。也就是说 mach_header *header 就是通过 image list 获取到的地址,如下图:

另外可以发现 mach_header *header 和 intptr_t slide 相差就是 0x100000000,也侧面印证了 mach_header *header 就是 __Text 段的实际起始地址
rebind_symbols_for_image 方法
进入 rebind_symbols_for_image 方法,这是一个非常重要的方法,我们可以将其分为几个阶段来看。
获取动态静态符号表位置以及 linkedit_segment 的 load command 位置
这一部分其实很简单,就是通过遍历 load commands 找到 symtab_command 和 dysymtab_command 以及 linkedit_segment 的位置:
1 | static void rebind_symbols_for_image(struct rebindings_entry *rebindings, const struct mach_header *header, intptr_t slide) { |
前面提到过,在 Mach-O 加载进内存后,__Text 段的起始位置时 0x100000000,并且所有 offset 都是以 __Text 段为基准的。header 和 load commands 也是属于 __Text 段的一部分。上面的代码正印证了这个观点。
计算静态符号表和动态符号表以及字符串表的位置
前面拿到了静态符号表以及动态符号表的 load command,现在就可以根据 load command 中的信息计算得到静态符号表,动态符号表以及字符串表的位置了:
1 | static void rebind_symbols_for_image(struct rebindings_entry *rebindings, const struct mach_header *header, intptr_t slide) { |
这里通过 linkedit 计算基地址,所有的偏移量都是以基地址为参照的。计算公式上面也有写到。获取到基地址后,就可以通过前面获取到的静态符号表以及动态符号表的 load commands 中保存的偏移量计算得到符号表 symtab,字符串表 strtab 以及动态符号表 indirect_symtab 的位置了。
跳转重绑定
之后,重新遍历 load commands,获取 __DATA 段中的 __nl_symbol_ptr 和 __la_symbol_ptr 两个 section 的信息,然后执行真正的重绑定方法 perform_rebinding_with_section:
1 | static void rebind_symbols_for_image(struct rebindings_entry *rebindings, const struct mach_header *header, intptr_t slide) { |
重绑定
这是执行方法替换的最关键方法,我们先看一下代码:
1 | static void perform_rebinding_with_section(struct rebindings_entry *rebindings, section_t *section, intptr_t slide, nlist_t *symtab, char *strtab, uint32_t *indirect_symtab) { |
获取 __la_symbol_ptr 或者 __nl_symbol_ptr 的符号在动态符号表的位置
1 | uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1; |
indirect_symtab 是 Dynamic Symbol Table 段:

它汇集了 __Text,__stubs ,__DATA,__nl_symbol_ptr,__DATA,__got, __DATA,__la_symbol_ptr 这几个段的所有动态链接符号的所有信息。
section 就是上面获取到的在 Load Command是中的 __la_symbol_ptr 以及 __nl_symbol_ptr 段的信息,它的 reserved1 就是该段在 Dynamic Symbol Table 中的位置。在 MachOView 中显示为 Indirect Sym Index:

由于 indirect_symtab 是 uint32 类型的指针,所以一个指针占用4个字节。因此偏移 reserved1即偏移 reserved1 * 4 的地址。
获取 __la_symbol_ptr 或者 __nl_symbol_ptr的实际地址
1 | void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr); |
之前我们说过一个公式:
ASLR偏移 + 段的虚拟地址 = 段的实际地址
section->addr 就是段的虚拟地址,indirect_symbol_bindings 就是指向段的实际地址。由于 __la_symbol_ptr 或者 __nl_symbol_ptr 段内保存的是一个个指向实际方法的指针,因此 indirect_symbol_bindings 就被声明为一个二级指针。
遍历 __la_symbol_ptr 或者 __nl_symbol_ptr替换符号实现方法
1 | for (uint i = 0; i < section->size / sizeof(void *); i++) { |
遍历 __la_symbol_ptr 或者 __nl_symbol_ptr 是为了将每一个动态符号和要替换的符号方法匹配,那么通过什么匹配的呢?通过符号名。也就是每个循环中的内容。
根据符号在 Dynamic Symbol Tabel 的位置,拿到它在 Symbol Tabel 的索引位置 symtab_index,然后在 Symbol Tabel 的相应位置拿到他在 String Tabel 的偏移 strtab_offset。获取到偏移后就加上 string table 的基地址,拿到符号的位置,也就获得了符号名 symbol_name。
符号名拿到后,就可以遍历自定义的要替换的符号数组 rebindings,一一匹配符号名和要替换的符号名是否匹配。如果匹配了,并且没有替换过(替没替换过只要判断 rebindings 结构体的 replaced 是否为空即可),就让 indirect_symbol_bindings 相应的符号指向 rebindings 相应的 replacement 方法的地址。
至此,fishhook 整个替换流程就结束了。
总结
到这里你一定被各种跳转和各种偏移绕晕了。下面我来整理一个过程:
lazy binding 过程
- lazy binding 的符号指向
__TEXT,__stubs,调用的时候会执行__TEXT,__stubs指向的方法。它会调用__DATA,__la_symbol_ptr指向的地址上的方法。 - 未绑定时,
__DATA,__la_symbol_ptr指向的地址为___TEXT,stub_helper,它会执行系统函数修改__DATA,__la_symbol_ptr的指向。 - 绑定后,
__DATA,__la_symbol_ptr指向实际的函数的地址
fishhook 替换过程
- 通过注册系统回调
_dyld_register_func_for_add_image获取 image 的起始地址和 ASLR 偏移。 - 通过 image 的起始地址,加上 Header 的大小(Header 固定大小为 0x20),得出 Load Commands 的起始地址
- 遍历 Load Commands 拿到
__DATA,__nl_symbol_ptr和__DATA,__la_symbol_ptr的各项信息,包括段的位置,段的大小,段在 Dynamic Symbol Table 的起始索引reserved1(也就是 MachOView 中的 Indirect Sym Index)。 - 再次遍历 Load Commands 拿到符号表的 LC:
LC_SYMTAB,从中获取 Symbol Table 和 String Table 的起始位置;同时拿到动态符号表的 LC:LC_DYSYMTAB获取动态符号表DYSYMTAB的起始位置; - 根据第四步获取的动态符号表的起始位置,以及第三步获取的起始索引,在
DYSYMTAB中遍历__DATA,__nl_symbol_ptr或者__DATA,__la_symbol_ptr的各个符号,其中保存了它在 Symbol Table 中的的索引。 - 根据从动态符号表中得到的这个索引,获取该符号在
SYMTAB的信息,可以拿到它在 String Table 的 offset。这个 offset 保存着符号的名字。 - 拿到这个符号的名字和我们要替换的各个符号名做对比,如果相同,那么把
__DATA,__nl_symbol_ptr或者__DATA,__la_symbol_ptr相应位置的符号指向要替换的方法的地址。至此,fishhook 替换完成
思考题
- 为什么需要动态链接?
- 什么是 Lazy Binding?
- 为什么 fishhook 可以重新绑定符号?这和动态链接有什么关系?
- 懒绑定的符号存在哪个段中?
- 系统的 Lazy Binding 是如何实现的?
- 如何拿到 Load Command 的偏移?