iOS Hook 全局方法先导篇之 - Fishhook 原理解析
什么是 Fishhook
Fishhook 是 facebook 提供的一个动态修改链接 Mach-O 符号表的开源工具。通过 fishhook 我们可以很轻松的 Hook C 语言中的函数,从而达到修改函数功能的目的。
在了解 Fishhook 的原理之前,我们需要了解一些背景知识。
Mach-O (Mach Object File Format)
Wikipedia
Mach-O 是 Mach object 文件格式的缩写,它是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。作为 a.out 格式的替代品,Mach-O 提供了更好的扩展性,并提升了符号表中信息的访问速度。
大多数基于 Mach 内核的操作系统都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用这种格式作为本地可执行文件、库和对象代码的例子。
Mach-O 可以是以下形式:
- Executable 可执行的二进制文件
- Dynamic Library 动态库
- Bundle 非独立的二进制文件,显式加载
- Static Library 静态库
- Relocatable Object File 可重定位的目标文件,中间结果
Mach-O 的文件结构示意图:
我们可以看看 Mach-O 格式到底是什么样的格式,先引用一张国外热心网友发布的图片
我们可以看出,并不是所有的数据都是相连的,而是被分成了若干个段落
下面,我们再结合官方文档提供的一张示意图
可以看出 Mach-O 主要由3部分构成
- Mach-O 头(Mach Header):这里描述了 Mach-O 的 CPU 架构(比如x86,arm64)、文件类型以及加载命令等信息
- 加载命令(Load Command):它是一张包含很多内容的表,内容包括区域的位置、符号表、动态符号表等。每个加载指令都包含一个元信息,比如指令类型、名称、在二进制文件中的位置等等
- 数据区(Data):Data 中每一个段(Segment)的数据都保存在此,段的概念和 ELF 文件中段的概念类似,都拥有一个或多个 Section ,用来存放数据和代码
除了以上三大部分,Mach-O 还包含了符号表、字符串表、动态加载信息、代码签名等信息。
然后我们再结合上面那张彩色的图片,我们就会明白,黄色的部分是 Mach Header,红色部分是 Load Command,其他部分则是被分割成 Segments 的数据。
通用二进制文件
OS X 和 iOS 有两种类型的目标文件:Mach-O 文件和通用二进制文件,也叫作胖文件。它们之间的区别是:Mach-O 文件包含一种架构(i386、x86_64、arm64 等等)的对象代码,而胖文件可能包含若干包含不同架构(i386、x86_64、arm、arm64 等等)对象代码的对象文件。
我们可以在 /usr/include/mach-o/fat.h 中找到 fat_header 的定义
#define FAT_MAGIC 0xcafebabe
struct fat_header {
uint32_t magic; /* FAT_MAGIC or FAT_MAGIC_64 */
uint32_t nfat_arch; /* number of structs that follow */
};
struct fat_arch {
cpu_type_t cputype; /* cpu specifier (int) */
cpu_subtype_t cpusubtype; /* machine specifier (int) */
uint32_t offset; /* file offset to this object file */
uint32_t size; /* size of this object file */
uint32_t align; /* alignment as a power of 2 */
};
#define FAT_MAGIC_64 0xcafebabf
struct fat_arch_64 {
cpu_type_t cputype; /* cpu specifier (int) */
cpu_subtype_t cpusubtype; /* machine specifier (int) */
uint64_t offset; /* file offset to this object file */
uint64_t size; /* size of this object file */
uint32_t align; /* alignment as a power of 2 */
uint32_t reserved; /* reserved */
};
其中,fat_header 结构体包含了一个 magic 魔数,胖二进制文件的魔数值是 0xcafebabe 或 0xcafebabf,没错,就是 cafebaby。nfat_arch 标识包含的不同架构的 Mach-O 文件的数量。
fat_header 后会跟着 fat_arch,有多少个不同架构的 Mach-O 文件,就有多少个 fat_arch,用于说明对应 Mach-O 文件大小、支持的 CPU 架构、偏移地址等;
准备工作
下面,我们就用一段简单的代码,来探究一下 Mach-O 的内部文件结构
首先,我们新建一个 helloworld.c 文件
touch helloworld.c
然后在 c 文件中输入如下代码
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}
然后用 clang 编译成可执行文件
xcrun clang helloworld.c
以上命令会将 helloworld.c 编译成一个名字为 a.out 的 Mach-O 可执行文件,因为我们没有指定名字,编译器会默认的将其指定为 a.out。
得到了 Mach-O 文件之后,我们用 MachOView 来看一下 a.out 的文件布局。
Mach-O Header
Mach-O Header 的作用,主要是用来说明整个 Mach-O 文件的基本信息,帮助系统迅速的定位Mach-O 文件的运行环境,文件类型。
与 Mach-O 文件格式有关的结构体定义都可以从 /usr/include/mach-o/loader.h 中找到,也就是 <mach-o/loader.h> 头。以下是 64 位定义的代码,32 位的区别是缺少了一个预留字段:
/*
* The 64-bit mach header appears at the very beginning of object files for
* 64-bit architectures.
*/
struct mach_header_64 {
uint32_t magic; /*主要是快速的获取当前的二进制文件用于 32 位还是 64 位CPU*/
cpu_type_t cputype; /* CPU 类型标识符,同通用二进制格式中的定义 */
cpu_subtype_t cpusubtype; /* CPU 子类型标识符,同通用二级制格式中的定义 */
uint32_t filetype; /* 文件类型 */
uint32_t ncmds; /* 加载器中加载命令的条数 */
uint32_t sizeofcmds; /* 加载器中加载命令的总大小 */
uint32_t flags; /* dyld 的标志 */
uint32_t reserved; /* 64 位的保留字段 */
};
由于 Mach-O 支持多种类型文件,所以此处引入了 filetype 字段来标明,这些文件类型定义在 loader.h 文件中同样可以找到。
#define MH_OBJECT 0x1 /* Target 文件:编译过程中产生的 obj文件 (gcc -c xxx.c 生成的 xxx.o 文件) */
#define MH_EXECUTE 0x2 /* 可执行二进制文件 */
#define MH_FVMLIB 0x3 /* VM 共享库文件(还不清楚是什么东西) */
#define MH_CORE 0x4 /* Core 文件,一般在 App Crash 产生 */
#define MH_PRELOAD 0x5 /* preloaded executable file */
#define MH_DYLIB 0x6 /* 动态库 */
#define MH_DYLINKER 0x7 /* 动态连接器 /usr/lib/dyld */
#define MH_BUNDLE 0x8 /* 非独立的二进制文件,往往通过 gcc-bundle 生成 */
#define MH_DYLIB_STUB 0x9 /* 静态链接文件(还不清楚是什么东西) */
#define MH_DSYM 0xa /* 符号文件以及调试信息,在解析堆栈符号中常用 */
#define MH_KEXT_BUNDLE 0xb /* x86_64 内核扩展 */
另外在 loader.h 中还可以找到 flags 中所取值的全部定义,这里只列出一些常用的:
#define MH_NOUNDEFS 0x1 /* Target 文件中没有带未定义的符号,常为静态二进制文件 */
#define MH_SPLIT_SEGS 0x20 /* Target 文件中的只读 Segment 和可读写 Segment 分开 */
#define MH_TWOLEVEL 0x80 /* 该 Image 使用二级命名空间(two name space binding)绑定方案 */
#define MH_FORCE_FLAT 0x100 /* 使用扁平命名空间(flat name space binding)绑定(与 MH_TWOLEVEL 互斥) */
#define MH_WEAK_DEFINES 0x8000 /* 二进制文件使用了弱符号 */
#define MH_BINDS_TO_WEAK 0x10000 /* 二进制文件链接了弱符号 */
#define MH_ALLOW_STACK_EXECUTION 0x20000/* 允许 Stack 可执行 */
#define MH_PIE 0x200000 /* 对可执行的文件类型启用地址空间 layout 随机化 */
#define MH_NO_HEAP_EXECUTION 0x1000000 /* 将 Heap 标记为不可执行,可防止 heap spray 攻击 */
其中:
flags: MH_PIE - 随机地址空间(ASLR)
进程每一次启动,地址空间都会随机化。如果采用传统的方式,程序每启动一次,启动的虚拟内存镜像一致的话,黑客很容易就重写内存来破解程序。所以,ASLR 可以有效避免黑客的攻击。
flags : MH_TWOLEVEL - 二级名称空间
这是 dyld 的一个独有特性,说是符号空间中还包括所在库的信息,这样子就可以让两个不同的库导出相同的符号,与其对应的是平摊名称空间.
Mach-O Load Command
Load Commands 直接就跟在 Header 后面,所有 command 占用内存的总和在 Mach-O Header 里面已经给出了。在加载过 Header 之后就是通过解析 Load Command 来加载接下来的数据了。Mach-O Header 已经描述了整个 Mach-O 文件的基本信息,但是加载器还是不知道如何加载 Mach-O 文件,这时 Load Command 就起了很重要了作用。
Load Command 规定了文件的逻辑结构和文件在虚拟内存中的布局。这些加载指令清晰地告诉加载器如何处理二进制数据,所有的这些加载命令由系统内核加载器直接使用,或由动态链接器处理。其中几个常见的加载命令有 LC_SEGMENT、LC_LOAD_DYLINKER、LC_LOAD_DYLIB、LC_MAIN、LC_CODE_SIGNATURE、LC_ENCRYPTION_INFO 等。
Load Command 也是被定义在 loader.h 中的
struct load_command {
uint32_t cmd; /* cmd字段代表当前加载命令的类型 */
uint32_t cmdsize; /* cmdsize字段代表当前加载命令的大小 */
};
cmd 字段
根据 cmd 字段的类型不同,使用了不同的函数来加载。简单的列出一张表看一看在内核代码中不同的 command 类型都有哪些作用。
-
LC-SEGMENT;LC-SEGMENT-64 在内核中由 load-segment 函数处理(将segment中的数据加载并映射到进程的内存空间去)
-
LC-LOAD-DYLINKER 在内核中由 load-dylinker 函数处理(调用/usr/lib/dyld程序)
-
LC-UUID 在内核中由 load-uuid 函数处理 (加载128-bit的唯一ID)
-
LC-THREAD 在内核中由 load-thread 函数处理 (开启一个MACH线程,但是不分配栈空间)
-
LC-UNIXTHREAD 在内核中由 load-unixthread 函数处理 (开启一个UNIX posix线程)
-
LC-CODE-SIGNATURE 在内核中由 load-code-signature 函数处理 (进行数字签名)
-
LC-ENCRYPTION-INFO 在内核中由 set-code-unprotect 函数处理 (加密二进制文件)
加载数据时,主要加载的就是 LC-SEGMET 或者 LC-SEGMENT_64。其他的 segment 的用途在这里不做深究。对于每一个 segment,以及 segment 中的每个 section,加载命令规定了它们在内存中结束的位置,以及保护模式等。
Mach-O Data
在正式进入加载命令这一过程之前,先来学习一下 Mach-O 的 Data 区域,其中由 Segment 段和 Section 节组成。先来说 Segment 的组成,以下代码仍旧来自 loader.h:
#define SEG_PAGEZERO "__PAGEZERO" /* 当时 MH_EXECUTE 文件时,捕获到空指针 */
#define SEG_TEXT "__TEXT" /* 代码/只读数据段 */
#define SEG_DATA "__DATA" /* 数据段 */
#define SEG_OBJC "__OBJC" /* Objective-C runtime 段 */
#define SEG_LINKEDIT "__LINKEDIT" /* 包含需要被动态链接器使用的符号和其他表,包括符号表、字符串表等 */
进而来看一下 Segment 的数据结构具体是什么样的(同样这里也只放出 64 位的代码,与 32 位的区别就是其中 uint64_t 类型的几个字段取代了原先 32 位类型字段):
struct segment_command_64 {
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* section_64 结构体所需要的空间 */
char segname[16]; /* segment 名字,上述宏中的定义 */
uint64_t vmaddr; /* 所描述段的虚拟内存地址 */
uint64_t vmsize; /* 为当前段分配的虚拟内存大小 */
uint64_t fileoff; /* 当前段在文件中的偏移量 */
uint64_t filesize; /* 当前段在文件中占用的字节 */
vm_prot_t maxprot; /* 段所在页所需要的最高内存保护,用八进制表示 */
vm_prot_t initprot; /* 段所在页原始内存保护 */
uint32_t nsects; /* 段中 Section 数量 */
uint32_t flags; /* 标识符 */
};
部分的 Segment (主要指的 __TEXT 和 __DATA)可以进一步分解为 Section。之所以按照 Segment -> Section 的结构组织方式,是因为在同一个 Segment 下的 Section,可以控制相同的权限,也可以不完全按照 Page 的大小进行内存对齐,节省内存的空间。而 Segment 对外整体暴露,在程序载入阶段映射成一个完整的虚拟内存,更好的做到内存对齐。下面给出 Section 具体的数据结构:
struct section_64 {
char sectname[16]; /* Section 名字 */
char segname[16]; /* Section 所在的 Segment 名称 */
uint64_t addr; /* Section 所在的内存地址 */
uint64_t size; /* Section 的大小 */
uint32_t offset; /* Section 所在的文件偏移 */
uint32_t align; /* Section 的内存对齐边界 (2 的次幂) */
uint32_t reloff; /* 重定位信息的文件偏移 */
uint32_t nreloc; /* 重定位条目的数目 */
uint32_t flags; /* 标志属性 */
uint32_t reserved1; /* 保留字段1 (for offset or index) */
uint32_t reserved2; /* 保留字段2 (for count or sizeof) */
uint32_t reserved3; /* 保留字段3 */
};
下面列举一些常见的 Section。
- __TEXT.__text 主程序代码
- __TEXT.__cstring C 语言字符串
- __TEXT.__const const 关键字修饰的常量
- __TEXT.__stubs 用于 Stub 的占位代码,很多地方称之为桩代码。
- __TEXT.__stubs_helper 当 Stub 无法找到真正的符号地址后的最终指向
- __TEXT.__objc_methname Objective-C 方法名称
- __TEXT.__objc_methtype Objective-C 方法类型
- __TEXT.__objc_classname Objective-C 类名称
- __DATA.__data 初始化过的可变数据
- __DATA.__la_symbol_ptr lazy binding 的指针表,表中的指针一开始都指向 __stub_helper
- __DATA.nl_symbol_ptr 非 lazy binding 的指针表,每个表项中的指针都指向一个在装载过程中,被动态链接器搜索完成的符号
- __DATA.__const 没有初始化过的常量
- __DATA.__cfstring 程序中使用的 Core Foundation 字符串(CFStringRefs)
- __DATA.__bss BSS,存放为初始化的全局变量,即常说的静态内存分配
- __DATA.__common 没有初始化过的符号声明
- __DATA.__objc_classlist Objective-C 类列表
- __DATA.__objc_protolist Objective-C 原型
- __DATA.__objc_imginfo Objective-C 镜像信息
- __DATA.__objc_selfrefs Objective-C self 引用
- __DATA.__objc_protorefs Objective-C 原型引用
- __DATA.__objc_superrefs Objective-C 超类引用
接下来我们再用 MachOView 来看看刚刚 a.out 中 Load Command 的细节,LC_SEGMENT_64 和 LC_SEGMENT 是加载的主要命令,它负责指导内核来设置进程的内存空间。
第一个 segment 是 __PAGEZERO。它的大小为 4GB。这 4GB 并不是文件的真实大小,但是规定了进程地址空间的前 4GB 被映射为不可执行、不可写和不可读。这就是为什么当读写一个 NULL 指针或更小的值时会得到一个 EXC_BAD_ACCESS 错误。这是操作系统在尝试防止引起系统崩溃。
当运行一个可执行文件时,虚拟内存 (VM - virtual memory) 系统将 segment 映射到进程的地址空间上。映射完全不同于我们一般的认识,如果你对虚拟内存系统不熟悉,可以简单的想象虚拟内存系统将整个可执行文件加载进内存 -- 虽然在实际上不是这样的。VM 使用了一些技巧来避免全部加载。
当虚拟内存系统进行映射时,segment 和 section 会以不同的参数和权限被映射。
上面的代码中:
__TEXT segment 包含了被执行的代码。它被以只读和可执行的方式映射。进程被允许执行这些代码,但是不能修改。这些代码也不能对自己做出修改,因此这些被映射的页从来不会被改变。
__DATA segment 中包含了可读写数据。在我们的程序中只有 __nl_symbol_ptr 和 __la_symbol_ptr,它们分别是 non-lazy 和 lazy 符号指针。延迟符号指针用于可执行文件中调用未定义的函数,例如不包含在可执行文件中的函数,它们将会延迟加载。而针对非延迟符号指针,当可执行文件被加载同时,也会被加载。
__LINKEDIT segment 是支持 dyld 的,一个完整的用户级 Mach-O 文件的末端是链接信息。其中包含了动态加载器用来链接可执行文件或者依赖库所需使用的符号表,字符串表等等。
在 segment 中,一般都会有多个 section。它们包含了可执行文件的不同部分。在 __TEXT segment 中,__text section 包含了编译所得到的机器码。__stubs 和 __stub_helper 是给动态链接器 (dyld) 使用的。通过这两个 section,在动态链接代码中,可以允许延迟链接。__const 是常量,不可变的,就像 __cstring (包含了可执行文件中的字符串常量 -- 在源码中被双引号包含的字符串) 常量一样。
dyld
dyld 的全称是 dynamic loader,它是苹果开源的一个项目,它的作用是加载一个进程所需要的 image
其中有一个函数:
extern void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))
通过这个函数,可以为程序加载的每一个模块注册回调。当_dyld_register_func_for_add_image
函数被调用后,回调函数会对已经加载过的每个image 调用一次,并且之后加载的模块,也会被调用。
这里有一个 slide ,那么 slide 代表了什么呢?前面有提到 ASLR,就是将可执行程序随机装载到内存中, 这里的随机只是偏移,而不是打乱,具体做法就是通过内核将 Mach-O 的段“平移”某个随机系数。slide 正是 ASLR 引入的偏移。这个参数为寻找程序的基地址提供了必要信息。
PIC (Position Indepent Code)
使用 PIC 的 Mach-O 文件,在引用符号(比如printf)的时候,并不是直接去找到符号的地址(编译期并不知道运行时 printf 的函数地址),而是通过在 __DATA Segment 上创建一个指针,等到启动的时候,dyld 动态的去做绑定(bind),这样 __DATA Segment 上的指针就指向了 printf 的实现。
以上的知识点非常重要,因为 fishhook 的核心工作原理,就是通过 rebind_symbols 修改 __DATA Segment 上的符号指针指向,来动态 hook c 函数。
在 __DATA 段中,有两个 Sections 和动态符号绑定有关:
- __nl_symbol_ptr 存储了 non-lazily 绑定的符号,这些符号在 mach-o 加载的时候绑定。
- __la_symbol_ptr 存储了 lazy 绑定的符号(方法),这些方法在第一调用的时候,由 dyld_stub_binder 来绑定。
通过 dyld 相关的 API,我们可以很容易的访问到这些 Symbols 指针,但是并不知道这些指针具体代表哪种函数。
所以,要解决的问题就是找到这些指针代表的字符串,和当前的要替换的进行比较,如果一样替换当前指针的实现即可。
lazy load
懒加载(lazy load),又叫做延迟加载。在实际需要使用该符号(或资源)的时候,该符号才会通过 dyld 中的 dyld_stub_binder 来进行加载。与之相对的是非懒加载(non-lazy load),这些符号在动态链接库绑定的时候,就会被加载。
在 Mach-O 中,相对应的就是 _nl_symbol_ptr(非懒加载符号表)和 _la_symbol_ptr(懒加载符号表)。这两个指针表,保存着与字符串表对应的函数X指针。
Dynamic Symbol Table
Dynamic Symbol Table(Indirect Symbols): 动态符号表是加载动态库时导出的函数表,是符号表的 subset。动态符号表的符号 = 该符号在原所属表指针中的偏移量(offset)+ 原所属表在动态符号表中的偏移量 + 动态符号表的基地址(base)。在动态表中查找到的这个符号的值又等于该符号在 Symbol Table 中的 offset。
Symbol Table
Symbol Table: 即符号表。每个目标文件都有自己的符号表,记录了符号的映射。在 Mach-O 中,符号表是由结构体 n_list 构成。
符号表里的内容就是描述某个符号的名称、类型、地址、索引、连接方式等信息。通过符号表可以找到特定符号在指针表中的索引。
/*
* This is the symbol table entry structure for 64-bit architectures.
*/
struct nlist_64 {
union {
uint32_t n_strx; /* index into the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
以上为 n_list 的结构。通过在动态符号表中找的偏移,再加上符号表的基址,就可以找到这个符号的 n_list,其中 n_strx 的值代表该字符串在 strtab 中的偏移量(offset)。
String Table
是放置 Section 名、变量名、符号名的字符串表,字符串末尾自带的 \0 为分隔符(机器码00)。知道 strtab 的基地址(base),然后加上在 Symbol Table 中找到的该字符串的偏移量(offset)就可以找到这个字符串。
Fishhook 的原理
下面这张图,是 fishhook 在 GitHub 上提供的一张原理示意图
基本思路为:
- 先找到 Mach-O 文件的 Load_Commands 中的 LC_SEGMENT_64(_DATA),然后找到这条加载指令下的 Section64 Header(_nl_symbol_ptr),以及 Section64 Header(_la_symbol_ptr);
- 其中 Section Header 字段的 reserved1 的值即为该 Section 在 Dynamic Symbol Table 中的 offset。然后通过定位到该 Section 的数据,找到目标符号在 Section 中的偏移量,与之前的 offset 相加,即为在动态符号表中的偏移;
- 通过 Indirect Symbols 对应的数值,找到在 Symbol Table 中的偏移,然后取出 n_list->n_un->n_strx 的值;
- 通过这个值找到在 strtab 中的偏移,得到该字符串,进行匹配置换。