Hook 原理之 fishhook 源码解析

基础知识提要

各种表在 Mach-O 文件中,是位于 Section 数据之后的一些记录数据。下面介绍本文会用到的几个表。

懒加载(lazy load),又叫做延迟加载。在实际需要使用该符号(或资源)的时候,该符号才会通过 dyld 中的 dyld_stub_binder 来进行加载。与之相对的是非懒加载(non-lazy load),这些符号在动态链接库绑定的时候,就会被加载。

在 Mach-O 中,相对应的就是 _nl_symbol_ptr(非懒加载符号表)和 _la_symbol_ptr(懒加载符号表)。这两个指针表,保存着与字符串表对应的函数指针。

Dynamic Symbol Table(Indirect Symbols): 动态符号表是加载动态库时导出的函数表,是符号表的 subset。动态符号表的符号 = 该符号在原所属表指针中的偏移量(offset)+ 原所属表在动态符号表中的偏移量 + 动态符号表的基地址(base)。在动态表中查找到的这个符号的值又等于该符号在 symtab 中的 offset。

Symbol Table(以下简称为 symtab): 即符号表。每个目标文件都有自己的符号表,记录了符号的映射。在 Mach-O 中,符号表是由结构体 n_list 构成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct nlist {
union {
#ifndef __LP64__
char *n_name; /* for use when in-core */
#endif
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 */
int16_t n_desc; /* see <mach-o/stab.h> */
uint32_t n_value; /* value of this symbol (or stab offset) */
};
/*
* 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)。关于 n_list 的具体结构解析详见 nlist-Mach-O文件重定向信息数据结构分析

String Table(以下简称为 strtab): 是放置 Section 名、变量名、符号名的字符串表,字符串末尾自带的 \0 为分隔符(机器码00)。知道 strtab 的基地址(base),然后加上在 Symbol Table 中找到的该字符串的偏移量(offset)就可以找到这个字符串。

fishhook 概述

fishhook 是 facehook 开源的重绑定 Mach-O 符号的库,用来 hook C 语言函数(即只能重绑定 C 符号)。主要原因在于只针对 C 语言做了符号修饰。

基本思路为:

  1. 先找到 Mach-O 文件的 Load_Commands 中的 LC_SEGMENT_64(_DATA),然后找到这条加载指令下的 Section64 Header(_nl_symbol_ptr),以及 Section64 Header(_la_symbol_ptr);
  2. 其中 Section Header 字段的 reserved1 的值即为该 Section 在 Dynamic Symbol Table 中的 offset。然后通过定位到该 Section 的数据,找到目标符号在 Section 中的偏移量,与之前的 offset 相加,即为在动态符号表中的偏移;
  3. 通过 Indirect Symbols 对应的数值,找到在 symtab 中的偏移,然后取出 n_list->n_un->n_strx 的值;
  4. 通过这个值找到在 strtab 中的偏移,得到该字符串,进行匹配置换。

源码解析

fishhook 的源文件很少,只有一个 .h 头文件和一个 .c 文件,其中 fishhook.h 文件只暴露出了两个函数接口和一个结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* A structure representing a particular intended rebinding from a symbol
* name to its replacement
*/
struct rebinding {
const char *name;
void *replacement;
void **replaced;
};
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);
int rebind_symbols_image(void *header,
intptr_t slide,
struct rebinding rebindings[],
size_t rebindings_nel);

rebind_symbols 函数

以 ReadMe 中的示例为例,先是声明与将要被 hook 的函数签名相同的函数指针,接着自定义了替换后的函数,my_close、my_open。然后在 main 函数中调用 rebind_symbols 函数。

1
rebind_symbols((struct rebinding[2]){{"close", my_close, (void *)&orig_close}, {"open", my_open, (void *)&orig_open}}, 2);

在传递的参数中定义了一个结构体数组,传递了两个 rebinding 结构体,以及数组的个数2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static struct rebindings_entry *_rebindings_head;
......
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
if (retval < 0) {
return retval;
}
// If this was the first call, register callback for image additions (which is also invoked for
// existing images, otherwise, just run on existing images
if (!_rebindings_head->next) {
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
uint32_t c = _dyld_image_count();
for (uint32_t i = 0; i < c; i++) {
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
return retval;
}

在 rebind_symbols 函数中,首先调用了 prepend_rebindings 函数,传入了 rebindings_head 的二级指针, rebind_symbols 函数参数中的 rebindings 数组,以及数组个数。然后将这个函数的返回值作为整个函数的返回值。

如果这个函数返回值>0,且 _rebindings_head->next 的值为空(其具体含义在 prepend_rebindings 函数中讲),则调用_dyld_register_func_for_add_image 来注册回调函数 _rebind_symbols_for_image。

在 dyld 加载镜像(即 image,在 Mach-O 中,所有的可执行文件、dylib、Bundle 都是 image)的时候,会执行注册过的回调函数。这一步可以使用 _dyld_register_func_for_add_image 方法来注册自定义的回调函数,传入这个 image 的 mach_header 和 slide,同时也会为所有已加载的 image 执行回调。

1
2
3
extern void _dyld_register_func_for_add_image(
void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide)
);

如果 _rebindings_head->next 的值不为空,则直接调用回调函数。

prepend_rebindings 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct rebindings_entry {
struct rebinding *rebindings;
size_t rebindings_nel;
struct rebindings_entry *next;
};
......
static int prepend_rebindings(struct rebindings_entry **rebindings_head,
struct rebinding rebindings[],
size_t nel) {
struct rebindings_entry *new_entry = malloc(sizeof(struct rebindings_entry));
if (!new_entry) {
return -1;
}
new_entry->rebindings = malloc(sizeof(struct rebinding) * nel);
if (!new_entry->rebindings) {
free(new_entry);
return -1;
}
memcpy(new_entry->rebindings, rebindings, sizeof(struct rebinding) * nel);
new_entry->rebindings_nel = nel;
new_entry->next = *rebindings_head;
*rebindings_head = new_entry;
return 0;
}

这里主要是一个将 rebingdings 数组拷贝到 new_entry 结构体中,并把这个结构体添加到 _rebings_head 这个链表首部的操作。首先定义一个 rebindings_entry 类型的 new_entry 结构体,并初始化,给 new_entry 以及 new_entry->rebindings 分配内存。

然后拷贝传入的参数数组 rebindings 到 new_entry->rebindings 中。同时给 new_entry->rebindings_nel 赋值为数组的个数,将 new_entry->next 赋值为 *rebindings_head 指针,即 _rebindings_head 内的数值。最后再使 _rebindings_head 与 new_entry 指向同一个地址。

这里比较容易混淆的是 rebindings_head 与 _rebindings_head。rebind_symbols 函数调用 prepend_rebindings 函数时,传入的是 &_rebindings_head,也就是结构体指针的地址,是一个二级指针。prepend_rebindings 函数接收这个参数用的是 struct rebindings_entry **rebindings_head,也就是说 *rebindings_head 就是 _rebinding_head 指针。

上面的动图很容易看出,这个链表是如何形成的。回到 rebind_symbols 函数中的遗留问题,_rebindings_head->next 的值为空时,是什么意思?这意味着 rebind_symbols 函数第一次被调用,因为之后被调用,_rebindings_head->next 都指向的是前一个被添加进链表的 new_entry。也只有在第一次被调用时,才需要注册回调函数,之后都是直接调用即可。

rebind_symbols_for_image 函数

在 _rebind_symbols_for_image 中,就执行了一个调用 rebind_symbols_for_image 函数的操作。接下来是比较核心的部分了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
const struct mach_header *header,
intptr_t slide) {
Dl_info info;
if (dladdr(header, &info) == 0) {
return;
}
segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
linkedit_segment = cur_seg_cmd;
}
} else if (cur_seg_cmd->cmd == LC_SYMTAB) {
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
} else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
!dysymtab_cmd->nindirectsyms) {
return;
}
//接下段代码

首先定义了4个会被用到的结构体指针。其中 segment_command_t 就是LC_SEGMENT_64 结构,symtab_command 是 Section Header 中 LC_SYMTAB 的结构,dysymtab_command 是 Section Header 中 LC_DYSYMTAB 的结构。(有关 Mach-O 文件的结构可以参考我之前的文章 解读 Mach-O 文件格式

接下来跳过 Mach-O 的 Header 结构,开始遍历 Load Commands。通过 Header->ncmds,以及 Segment->cmdsize 来控制循环。通过遍历,找到 LC_SEGMENT_64(_LINKEDIT),赋值给 linkedit_segment,然后给 symtab_cmd 和 dysymtab_cmd 赋值。

1
2
3
4
5
6
7
8
9
//接上段代码
// Find base symbol/string table addresses
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// Get indirect symbol table (array of uint32_t indices into symbol table)
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
//接下段代码

通过找到的 _LINKEDIT 段和传入的参数 slide 来计算 base 地址。也就是这个 Mach-O 文件在 ASLR 偏移后的首地址(因为这里用到的这些表都是属于 _LINKEDIT 段的)。 base = vmaddr - fileoffset + slide。

然后 base + symtab 段的 Symbol Table Offset(该表在文件中的偏移) = symtab 的首地址(该表在内存中的偏移),base + symtab 段的 String Table Offset = strtab 的首地址。base + DYSYTAB 段的 IndSym Table Offset = 动态符号表的首地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//接上段代码
cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
continue;
}
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
section_t *sect =
(section_t *)(cur + sizeof(segment_command_t)) + j;
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
}
}
}

这是一个嵌套循环,外层循环依旧是遍历 Load Commands,内循环则是遍历 LC_SEGMENT_64(_DATA) 段内的 Section Header,通过 Section->flags & SECTION_TYPE 来寻找 _nl_symbol_ptr 和 _la_symbol_ptr。找到后调用 perform_rebinding_with_section 函数。

在循环内的 if 语句嵌套,一般最多用两层,太多层会显得代码冗杂,且可读性较差,容易出错。那两层以上怎么办呢?fishhook 给了我们一个很好的示范。用 if 语句作非判断,然后加上 continue 跳出本次循环。详见上面的代码。

perform_rebindin_with_section 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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) {
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
for (uint i = 0; i < section->size / sizeof(void *); i++) {
uint32_t symtab_index = indirect_symbol_indices[i];
if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
continue;
}
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
char *symbol_name = strtab + strtab_offset;
if (strnlen(symbol_name, 2) < 2) {
continue;
}
struct rebindings_entry *cur = rebindings;
while (cur) {
for (uint j = 0; j < cur->rebindings_nel; j++) {
if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
if (cur->rebindings[j].replaced != NULL &&
indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
}
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
goto symbol_loop;
}
}
cur = cur->next;
}
symbol_loop:;
}
}

这里需要注意的是指针的加法。比如 uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1; 这句代码中,三个变量均为 uint32_t 格式,即4个字节,那么 indirect_symtab 指针实际应该加上 (reserved1 的值 * 4)个字节。以 _nl_symbol_ptr 为例:

reserved1(也就是上图 MachOView 中显示的 Indirect Sym Index)为25,那么在动态符号表中 _nl_symbol_ptr 所在的首地址应该是(先不考虑 slide): Dynamic Symbol Table 的首地址 + (reserved1 * 4) = 0x100005F30 + 0x64 = 0x100005F94。

slide + section->addr 为 _nl_symbol_ptr section 数据所在的地址。然后遍历 dysymtab 中从 _nl_symbol_ptr 开始的符号,取得 Symbol 数据,如果为 INDIRECT_SYMBOL_ABS(即懒加载符号指针结束的地方)等,则跳出本次循环。否则将取得的 Symbol 数据作为 symtab 中的 offset。

如上图所示,_dyld_stub_binder 符号(也是这个程序中的 _nl_symbol_ptr 的首个符号)在 dysymtab 中的 Symbol 数据为0xA1,那么在对应的 symtab 中,它的地址应为 symtab 首地址 + 0xA1 * 16 = 0x10005510 + 161 * 16 = 0x10005F20。(16是 n_list 结构共16个字节)

Symbol Table 中的 n_strx(之前提到的 n_list 结构)即为 strtab 中的 index。

最后就是匹配替换了。比较字符串表中的字符串与 rebindings 数组中的 name 字段,匹配成功后,将 _nl_symbol_ptr 或 _la_symbol_ptr 这两个 Section 的指针表中对应的函数指针(indirect_symbol_bindings[i])赋值给 rebindings 数组中的 replaced 字段,然后用数组中的 replacement 字段(也就是自定义的 my_open 或 my_close 函数的指针)覆盖原来的函数指针。

这里使用了 goto 来跳出双重循环,值得参考。


Reference

[1] 动态修改 C 语言函数的实现 http://draveness.me/fishhook/
[2] 趣探 Mach-O:FishHook 解析 http://www.open-open.com/lib/view/open1487057519754.html
[3] 编译体系漫游 http://www.tuicool.com/articles/uI7Bria

本文标题:Hook 原理之 fishhook 源码解析

文章作者:Amywushu

原始链接:https://amywushu.github.io/2017/02/27/源码学习-Hook-原理之-fishhook-源码解析.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。