一 知识回顾
在上一节,我们分析了fishhook的原理, 知道fishhook 通过动态修改懒加载或非懒加载指针表来达到hook 的目的, 这篇文章就是讲一下fishhook 的具体是实现
二 fishhook的源码分析
预备知识
1:外部函数调用前的样式以及加载链接的过程
在Mach-o 文件中,包含__DATA.__la_symbol_ptr 和 __DATA.__nl_symbol_ptr 这两个指针表,分别为lazy binding指针表和non lazy binding指针表。并且这两个指针表,保存着与字符串标对应的函数指针。
调用外部函数时,比如调用print函数,会采用下面的方式调用
进入imp __stub_printf查看
我们发现imp___stubs__printf 这个指针指向了0x100001010,而这个地址是在__la_symbol_ptr 表中创建的指针变量(上一章中提到)
结合我们上一篇文章讲的__DATA.__la_symbol_ptr 创建的指针变量的内容一开始是0, 我们发现实际上这个指针变量的内容不是0,而是一个地址01 00 00 0f 84,继续追踪这个地址,发现01 00 00 0f 84指向在 __TEXT.__stubs 这个 Section中的一串代码
我们称这串代码是imp___stubs__printf 的桩,也就是说__DATA.__la_symbol_ptr 创建的指针变量一开始指向的是桩代码
继续追踪0x100000f74这个地址,然后会跳到 __TEXT.__stub_helper
在这里通过 _stub_helper 来调用 dyld_stu_binder 方法计算 printf 函数的真实地址,
binder 方法的作用简单来讲就是计算对应的函数地址进行绑定
在第二次输出 0x10001010 的值的时候,__DATA.__la_symbol_ptr 中指向 printf 地址的值已经发生了改变,即指向桩代码的地址替换成了真实函数的地址
2:认识Dynamic Symbol Table 动态符号表
用来加载动态库的时候导出的符号表,lazy指针表与之一一对应的符号表(Indirect Symbols)。
我们可以看到,dyld 通过动态符号表去跳转到__DATA.__la_symbol_ptr 的0x100001010处,并最终执行到dyld_stu_binder函数,内部拿到第三方库的GOT表,然后在拿到库PLT表开始解析地址
源码分析
fishhook 的使用
struct rebinding nslogBind;
//函数的名称
nslogBind.name = "NSLog";
//新的函数地址
nslogBind.replacement = myMethod;
//保存原始函数地址变量的指针
nslogBind.replaced = (void *)&old_nslog;
//定义数组
struct rebinding rebs[] = {nslogBind};
/**
arg1: 存放rebinding结构体的数组
arg2: 数组的长度
*/
rebind_symbols(rebs, 1);
这里一看主要是分析rebind_symbols(rebs, 1)
函数
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
// 维护一个 rebindings_entry 的结构
// 将 rebinding 的多个实例组织成一个链表
int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
// 判断是否 malloc 失败,失败会返回 -1
if (retval < 0) {
return retval;
}
// _rebindings_head -> next 是第一次调用的标志符,NULL 则代表第一次调用
if (!_rebindings_head->next) {
// 第一次调用,将 _rebind_symbols_for_image 注册为回调
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
// 先获取 dyld 镜像数量
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;
}
为了将多次绑定时的多个符号组织成一个链式结构,fishhook 自定义了一个链表结构来组织这个逻辑,其中每个节点的数据结构如下
struct rebindings_entry {
struct rebinding *rebindings; // rebinding 数组实例
size_t rebindings_nel; // 元素数量
struct rebindings_entry *next; // 链表索引
};
// 全局量,直接拿出表头
static struct rebindings_entry *_rebindings_head;
在 prepend_rebindings 方法中,fishhook 会维护这个结构:
/**
* prepend_rebindings 用于 rebindings_entry 结构的维护
* struct rebindings_entry **rebindings_head - 对应的是 static 的 _rebindings_head
* struct rebinding rebindings[] - 传入的方法符号数组
* size_t nel - 数组对应的元素数量
*/
static int prepend_rebindings(struct rebindings_entry **rebindings_head,
struct rebinding rebindings[],
size_t nel) {
// 声明 rebindings_entry 一个指针,并为其分配空间
struct rebindings_entry *new_entry = (struct rebindings_entry *) malloc(sizeof(struct rebindings_entry));
// 分配空间失败的容错处理
if (!new_entry) {
return -1;
}
// 为链表中元素的 rebindings 实例分配指定空间
new_entry->rebindings = (struct rebinding *) malloc(sizeof(struct rebinding) * nel);
// 分配空间失败的容错处理
if (!new_entry->rebindings) {
free(new_entry);
return -1;
}
// 将 rebindings 数组中 copy 到 new_entry -> rebingdings 成员中
memcpy(new_entry->rebindings, rebindings, sizeof(struct rebinding) * nel);
// 为 new_entry -> rebindings_nel 赋值
new_entry->rebindings_nel = nel;
// 为 new_entry -> newx 赋值,维护链表结构
new_entry->next = *rebindings_head;
// 移动 head 指针,指向表头
*rebindings_head = new_entry;
return 0;
}
用图说明一下上述的结构:
由于我们的 nslog 是 dyld 加载的系统库方法,所以 _rebindings_head -> next 在第一次调用的时候为空,因为这个时候只创建了第一个节点,因为没有做过替换符号,所以会调用 _dyld_register_func_for_add_image 来注册 _rebind_symbols_for_image 回调方法,之后程序每次加载动态库的时候,都会去调用该方法。如果不是第一次替换符号,则遍历已经加载的动态库。
接续分析:
_dyld_register_func_for_add_image 作用是啥?
首先它是dyld 内部的方法, _dyld_register_func_for_add_image 这个方法当镜像[动态库] 被 load 或是 unload 的时候都会由 dyld 主动调用。当该方法被触发时,会为每个镜像触发其回调方法。
之后则将其镜像与其回电函数进行绑定(但是未进行初始化)。使用 _dyld_register_func_for_add_image 注册的回调将在镜像中的 terminators 启动后被调用。
/**
* _rebind_symbols_for_image 是 rebind_symbols_for_image 的一个入口方法
* 这个入口方法存在的意义是满足 _dyld_register_func_for_add_image 传入回调方法的格式
* header - Mach-O 头
* slide - intptr_t 持有指针
*/
static void _rebind_symbols_for_image(const struct mach_header *header,
intptr_t slide) {
// 外层是一个入口函数,意在调用有效的方法只是为了满足函数参数格式
rebind_symbols_for_image
rebind_symbols_for_image(_rebindings_head, header, slide);
}
rebind_symbols_for_image 方法描述的也就是整个 fishhook 精华所在 - 重绑定符号过程
分析一下这个方法
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;
}
// 声明几个查找量:
// linkedit_segment, symtab_command, dysymtab_command
segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;
// 初始化游标
// header = 0x100000000 - 二进制文件基址默认偏移
// sizeof(mach_header_t) = 0x20 - Mach-O Header 部分
// 首先需要跳过 Mach-O Header
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
// 遍历每一个 Load Command,游标每一次偏移每个命令的 Command Size 大小
// header -> ncmds: Load Command 加载命令数量
// cur_seg_cmd -> cmdsize: Load 大小
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
// 取出当前的 Load Command
cur_seg_cmd = (segment_command_t *)cur;
// Load Command 的类型是 LC_SEGMENT
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
// 比对一下 Load Command 的 name 是否为 __LINKEDIT
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
// 检索到 __LINKEDIT
linkedit_segment = cur_seg_cmd;
}
}
// 判断当前 Load Command 是否是 LC_SYMTAB 类型
// LC_SEGMENT - 代表当前区域链接器信息
else if (cur_seg_cmd->cmd == LC_SYMTAB) {
// 检索到 LC_SYMTAB
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
}
// 判断当前 Load Command 是否是 LC_DYSYMTAB 类型
// LC_DYSYMTAB - 代表动态链接器信息区域
else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
// 检索到 LC_DYSYMTAB
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
// 容错处理
if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
!dysymtab_cmd->nindirectsyms) {
return;
}
// slide: ASLR 偏移量
// vmaddr: SEG_LINKEDIT 的虚拟地址
// fileoff: SEG_LINKEDIT 地址偏移
// 式①:base = SEG_LINKEDIT真实地址 - SEG_LINKEDIT地址偏移
// 式②:SEG_LINKEDIT真实地址 = SEG_LINKEDIT虚拟地址 + ASLR偏移量
// 将②代入①:Base = SEG_LINKEDIT虚拟地址 + ASLR偏移量 - SEG_LINKEDIT地址偏移
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
// 通过 base + symtab 的偏移量 计算 symtab 表的首地址,并获取 nlist_t 结构体实例
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// 通过 base + stroff 字符表偏移量计算字符表中的首地址,获取字符串表
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// 通过 base + indirectsymoff 偏移量来计算动态符号表的首地址
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
// 归零游标,复用
cur = (uintptr_t)header + sizeof(mach_header_t);
// 再次遍历 Load Commands
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
// Load Command 的类型是 LC_SEGMENT
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
// 查询 Segment Name 过滤出 __DATA 或者 __DATA_CONST
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
continue;
}
// 遍历 Segment 中的 Section
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
// 取出 Section
section_t *sect = (section_t *)(cur + sizeof(segment_command_t)) + j;
// flags & SECTION_TYPE 通过 SECTION_TYPE 掩码获取 flags 记录类型的 8 bit
// 如果 section 的类型为 S_LAZY_SYMBOL_POINTERS
// 这个类型代表 lazy symbol 指针 Section
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
// 进行 rebinding 重写操作
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
// 这个类型代表 non-lazy symbol 指针 Section
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
}
}
}
rebind_symbols_for_image 方法展示了冲绑定过程中,所有计算地址的流程。浏览过代码之后思考一个问题:为了完成重绑定的操作,我们需要获取哪些地址信息呢?
1. 获取 Linkedit Base Addr [ 很重要!!!]
fishhook 绝大多数重要的地址计算都要使用到或是间接使用到 Linkedit Base Addr[链接段的首地址或者Linkedit Segment 在文件中的首地址]。链接阶段: 无论是编译完成后的静态链接还是动态链接,只要调用其他文件的函数都需要链接,所以链接段的首地址很重要
为什么 Linkedit Segment 首地址信息十分重要,因为在 Load Command 中,LC_SYMTAB 和 LC_DYSYMTAB 的中所记录的 Offset 都是基于 __LINKEDIT 段的。而 LC_SYMTAB 中通过偏移量可以拿到symtab 符号表首地址、strtab 符号名称字符表首地址以及indirect_symtab 跳转表首地址。
我们拿到 Indirect Symbols 的首地址 indirect_symtab 再加上 LC_SEGMENT.__DATA 中任何一个 Section 信息的 reverved1 字段就可以获取到对应的 Indirect Address 信息。在这之后我们可以遍历每一个 Indirect Symbols,并以索引方式获取到每一个 nlist 结构的符号,从符号中获取到符号名字符串在字符表中的偏移量,进而继续获取符号名。
2. 二次遍历 Load Command 的目的
第一次遍历的目的在第一个疑问中已经解释的很清楚了,遍历目的有三个,为了找出 LC_SEGMENT(__LINKEDIT)、LC_SYMTAB 和 LC_DYSYMTAB 三个 Load Command,从而我们可以计算出 Base Address、Symbol Table、Dynamic Symbol 和 String Table 的位置。
而在第二次遍历中,我们需要获取的是 LC_SEGMENT(__DATA) 中 __nl_symbol_ptr 和 __la_symbol_ptr 这两个 Section。其目的是为了确定 lazy binding指针表 和 non lazy binding指针表 在 Dynamic Symbol 中对应的位置,方法就是获取到 reserved1 字段。这个在后面我们会做一个验证实现。
重绑定
在一切基址准备好之后,开始进行重绑定 perform_rebinding_with_section 方法:
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) {
// 在 Indirect Symbol 表中检索到对应位置
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
// 获取 _DATA.__nl_symbol_ptr(或__la_symbol_ptr) Section
// 已知其 value 是一个指针类型,整段区域用二阶指针来获取
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
// 用 size / 一阶指针来计算个数,遍历整个 Section
for (uint i = 0; i < section->size / sizeof(void *); i++) {
// 通过下标来获取每一个 Indirect Address 的 Value
// 这个 Value 也是外层寻址时需要的下标
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;
// 过滤掉符号名小于 4 位的符号
if (strnlen(symbol_name, 2) < 2) {
continue;
}
// 取出 rebindings 结构体实例数组,开始遍历链表
struct rebindings_entry *cur = rebindings;
while (cur) {
// 对于链表中每一个 rebindings 数组的每一个 rebinding 实例
// 依次在 String Table 匹配符号名
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;
// 完成后不再对当前 Indirect Symbol 处理
// 继续迭代到下一个 Indirect Symbol
goto symbol_loop;
}
}
// 链表遍历
cur = cur->next;
}
symbol_loop:;
}
}
这段方法主要描述了替换 __DATA.__la_symbol_ptr 和 __DATA.__la_symbol_ptr 的 Indirect Pointer 主要过程。从 reserved1 字段获取到 Indirect Symbols 对应的位置。从中我们可以获取到指定符号的偏移量,这个偏移量主要用来在 String Table 中检索出符号名称字符串。之后我们找到 __DATA.__la_symbol_ptr 和 __DATA.__la_symbol_ptr 这两个 Section。这两个表中,都是由 Indirect Pointer 构成的指针数组,但是其中的元素决定了我们调用的方法应该以哪个代码段的方法来执行。我们遍历这个指针数组中每一个指针,在每一层遍历中取出其符号名称,与我们的 rebindings 链表中每一个元素进行比对,当名称匹配的时候,重写其指向地址。