iOS逆向:fishhook原理分析

  1. fishhook 的本质是遍历 image 中的懒加载和非懒加载表,将里面的函数地址替换成自定义的函数地址;
  2. 因为 objc 的方法调用走的是消息查找和转发,所以 fishhook 并不能起作用,fishhook 只能替换 C 系函数,即非消息转发的函数;
  3. 懒加载和非懒加载表是因为动态库需要依赖其他动态库中的符号而产生的,动态库内部没有公开的函数或者是被 static 修饰的函数不会被创建到懒加载表或者非懒加载表中,所以 fishhook 只能 hook 动态库中的公开函数;

前言

这里需要对 mach-o 有比较全面的理解,详情见 mach-O结构分析,不展开了。

大概说下:

  1. mach-O 分为三部分,第一部分是 header,表示 Mach-O 的一些基本信息。第三部分是数据区,就是一团一团的代码或者数据;
  2. 第二部分是 Load Command,存储着不同类型的 Command,主要用于保存一些信息。有 Load Command 的会指向数据区的某一段数据并描述这一段数据的一些信息,有的 Load Command 不指向具体的数据,单纯的用于记录一些信息,比如记录 dyld 的路径、main 函数的位置等;
  3. 不同的类型的 Command 对应着不同的结构体,load_command 结构体类似于基类,其他类型的 command 结构体可以理解成继承自这个结构体;
  4. segment_command 只是其中一种,表示这个 command 指向数据区具体的 segment ,如 __TEXT__DATA/__DATA_CONST 都是这种类型。而动态链接最关键的 __LINKEDIT 也是这种类型,只是在 MachOView 上没有直接在数据区标出这个 segment;
  5. 这里使用到的还有类型为 LC_SYMTABLC_DYSYMTAB,对应的结构体为 symtab_commanddysymtab_command 。这两个 command 不指向具体的 segment,只是为动态链接器(dyld)提供一些信息,最重要的信息就是符号表和字符串表相对于 Mach-O 文件在磁盘中的文件偏移,即 fileoff(具体为symoff/stroff);

一. 添加监听

fishhook 的第一步是调用 dyld 提供的接口来对一些事件进行监听,主要代码如下:

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));
    }
  }

调用 _dyld_register_func_for_add_image() 传入一个函数作为回调,两种情况下触发回调函数:

  1. 有新的 image 被 load;
  2. 添加监听时,已存在的 image 触发一次回调;

回调函数格式如下:

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

两个参数的意义:

  1. mach_header:第一个参数就是 image 在该进程的虚拟内存中的初始地址;
  2. vmaddr_slide:当前独立虚拟内存空间的 ALSR 偏移;

代码主要形式为主工程代码、动态库、静态库。因为静态库直接被复制到了主工程,所以不需要考虑静态库的情况。动态库又分为共享缓存库、非共享缓存库。所以,这两个地址不一定相等,大概有这么几种情况:

  1. 主工程;
  2. 动态共享缓存库;
  3. 插入的动态库;

当 image 为主工程时,slide 就是 ALSR 中的偏移。又因为主工程一般都会包含一个 __PAGEZERO,这个 segment 在 disk 中大小为 0,在虚拟内存中有固定的的大小:

__PAGEZERO

所以主工程的 mach_header = __PAGEZERO + vmaddr_slide

当 image 为共享缓存库时,这些库都是被存在 shared cache 中的,所以这些 image 的 slide 都是相同的:

共享缓存库

如上图,共享缓存库的 vmaddr_slide 都是 0xa1098000

当 image 为插入的动态库或者工程自己嵌入的动态库时,这些动态库既不是保存在共享缓存库中,也不是和主工程处在统一个虚拟内存空间,而是独立的空间。正因为空间独立,所以动态库中的代码或者符号和主工程重复时,也不会出错。这些动态库的地址和偏移如下:

嵌入的动态库

总结:

  1. 代码在虚拟内存中大概有三种形式:主工程、共享缓存库、嵌入的动态库;
  2. 主工程因为一般都包含 __PAGEZERO,需要映射 __PAGEZERO。这个 segment 主要是兼容 32 位系统,或者说更像是一个限制,因为如果访问 __PAGEZERO 内的地址,都会当做空指针处理,强行修改就会 BAD_ACCESS
  3. 共享缓存库作为一个大的容器存放着许多系统的动态库;
  4. 嵌入的动态库拥有独立的虚拟内存空间;
  5. 另外,内核代码常驻进程,当程序启动时,会被映射到该进程的虚拟空间特定的地方,这些代码一般都是一些中断指令,属于内核态代码,用户态无法访问;

二、计算 Load Command 地址

监听完成后会触发回调进而进入下一步,主要逻辑在 rebind_symbols_for_image() 中;

PS:可以在这个函数的开始部分打断点获取当前 image 相关的信息:

Dl_info

header 其实在 fishhook 中没怎么发挥作用,只是用来计算出 Load Command 的地址,代码如下:

 // header指针指向__TEXT初始地址
// _TEXT头部是一个Header(mach_header_t结构体),紧接着是Loac Command
  uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);

因为 Load Command 紧跟在 Header 之后,所以代码很简单,就是首地址 + header 的 size;

三、 获取三个 command 的地址

这个阶段就是遍历 load command,获取三个 command :linkedit、symtab、dysymtab;

这一步主要代码如下:

  // header->ncmds为loadcommand总共包含的段数
  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
        linkedit_segment = cur_seg_cmd;
      }
        
    } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
       // symbol table
      symtab_cmd = (struct symtab_command*)cur_seg_cmd;
    } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
        // indrect symbol table
      dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
    }
  }

源码分析:

ncmds 是 header 结构体的属性,表示 Load Command 的个数,遍历就是基于此;

LC_SEGMENT_ARCH_DEPENDENT 是经过 fishhook 二次封装的宏定义,表示 LC_SEGMENT / LC_SEGMENT_64 这种类型的结构体。__LINKEDIT 就是这种类型:

__LINKEDIT

LC_SYMTABLC_DYSYMTAB 的类型分别为表示符号表和动态符号表的 Load Command 类型:

LC_SYMTAB
LC_DYSYMTAB

经过上述代码,拿到了三个 command 的地址,接下来就要看看怎么使用这三个 command 了;

四、计算linkedit_base

先看这一句代码:

uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;

linkedit_segment 就是上一步中获取到的三个 command 中的一个。但是这么一句简单的代码其实包含很多问题:

  1. vmaddr 和 fileoff 是什么?
  2. linkedit_base 为什么这么算?
  3. linkedit_base 的意义是什么?

五、vmaddr 和 fileoff

首先解决第一个问题:

  1. vmaddr 和 fileoff 是什么?

先说结论:
vmaddr:该 segment 在虚拟内存中相对于文件起始位置的偏移;
fileoff:该 segment 在硬盘存储中相对于文件起始位置的偏移;

解释:

首先,参考 《Mach-O Runtime Architecture》可以知道 mach-O 文件是一种文件格式,用于存储 macos 相关架构上的可执行文件。

另外,在 iOS/MacOS 中采用的是进程级别的虚拟缓存。对于 iOS 而言,每个 App 在启动之前都会新生成一个进程,且为其分配和物理内存大小一样的虚拟内存,并和物理内存建立联系,当然这个映射关系是操作系统来控制。

再者,在程序运行时,mach-O 文件会被加载到虚拟内存中。但是 segment 会按照一定的方式进行内存对齐。文档上写的是按页对齐,但是实际上感觉不止如此,这里暂时不深究,需要知道的是:

  • segment 因为内存对齐的原因导致:虚拟内存中的 size >= 磁盘存储中的 size;

最典型的例子就是 __PAGEZERO 段,在磁盘中不占据空间,被加载进入内存后占据 0x100000000 的空间,即一页。所以主工程的起始地址一般为:slide + pagezero

官方文档的表述:

官方文档

来看看实例:

__LINKEDIT

如上图,Foundation 和 UIKit 的 __LINKEDIT 段的 VMSize 都比 FileSize 要大,而且就上图而言,看上去像是以 0x2000 对齐(0x181490->0x183000);

再来看个实例:

__DATA

如上图 __DATAvmsize 都是大于 filesize 的,但是一个感觉是按照 0x10000对齐(0xC5000 -> 0xD0000),而另一个感觉像是按照 0x3000对齐(0x363000 -> 0x369000)。这就是为啥感觉对齐规则不确定的原因,文档上也没找到说明,暂不深究吧~~~

再来看个相等的情况:

__TEXT

如上图,FoundationUIKit__TEXT 段在虚拟内存和磁盘存储中的大小都是一致的;

至此,总结一下吧,我们知道了:

  • segment 加载进入虚拟缓存后会按照一定规则对齐,导致虚拟缓存中的大小大于等于磁盘中的大小;

那么继续,vmaddr 和 fileoff 表示什么?

先看 vmaddr:

首先将 fishhook 的代码断点打在本章的那一行代码,然后计算:

断点

如上图,可知:

linkedit_segment->vmaddr+ slide = __LINKEDIT 段在虚拟内存中的起始位置;

所以:

  • vmaddr 就是 segment 初始位置在虚拟内存中相对于 image 初始位置的偏移;

先不要关注 linkedit_base 是什么,后文会讲;

再来看看 fileoff:

先看看 Foundation 中 __LINKEDIT 的 command 信息:

fileoff

因为 Foundation 是 fat 模式,包含两个架构,所以 x86_64 的架构文件起始位置并不是 0:

起始位置

我们把上面的两个位置相加:

0x4BF000 + 0x3A5000 = 0x864000

接下来见证奇迹的时刻,来看看 mach-O 文件中 __LINKEDIT :

__LINKEDIT

这不是巧合,也就是说:

  • fileoff 就是对应的 segment 的起始位置相对于文件起始位置的偏移;

至此,第一个问题解决,总结一下:

  1. segment 加载进入虚拟缓存后会按照一定规则对齐,导致虚拟缓存中的大小大于等于磁盘中的大小;
  2. vmaddr 表示 segment 在虚拟缓存中的相对于 image 的初始地址的偏移;
  3. fileoff 指对应数据在磁盘文件中,相对于初始位置的偏移;

六、三个表的初始地址计算原理

上文中值分析了 linkedit_base 的那一句代码,接下来要和后面的代码结合来看了:

// Find base symbol/string table addresses
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    
// symbol table
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// string table
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// dynamic symbol table
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

后面的三个计算都是基于 linkedit_base 计算符号表、字符串表、重定向表的位置;

先看一张图:

计算原理

如上图:

① = LC_SYMTAB.symoff;
② = __LINKEDIT.vmaddr;
⑤ = __LINKEDIT.fileoff;
④ = ②-⑤(vmaddr-fileoff)
③ = ④

暂且不深究 segment 的对齐原则,现在可以确定的是对于 __LINKEDIT 段而言, vmaddr - fileoff 得到的值就是 __TEXT 段和 __DATA 段因为对齐而相对于磁盘存储中多出来的空间,加上 slide 就成了 linkedit_base

不关注 slide ,上述代码中的第一句代码就是在做一件事:计算 排在 __LINKEDIT 前面的 Segment 在被映射到虚拟内存时,因为内存对齐而多出来的 size;

主要是 __DATA 多出来的 size,因为 _TEXT_DATA_CONST 基本在静态时期已经做好了内存对齐,所以 VMSize 和 fileSize 是一样的;

另外,因为 symtabdysymtab 本身就属于 __LINKEDIT 这个 segment,而内存对齐也只是在 Segment 后面补 0。所以这两部分的数据不会因为 __LINKEDIT 的内存对齐而改变位置。也就是说 symtab->fileoff 仍然是正确的,所以只需要加上虚拟内存中多出来的 size 就可以计算出 symtab 在虚拟内存中的位置。

上句话是计算原理的关键所在,值得多理解理解!!!

因此 ③ 和 ④ 的长度是相等的。而我们又知道 symtab -> symoff 就是图中的 ①,最终如图,符号表的位置计算为:

// 注意此处的linkedit_base未添加偏移哦~~~
linkedit_base = __LINKEDIT.vmaddr - __LINKEDIT.fileoff (②-⑤);
vm中符号表的位置 = linkedit_base + slide + symoff;
string表的位置 = linkedit_base + slide +stroff;

这样就验证了代码的计算原理;

在 MachOView 中可以直观的看到:

  1. 符号表 command:
LC_SYMTAB

LC_SYMTAB 指的是 symbol table,也就是符号表,不是桩函数表(__stubs)。从上图可以看出,LC_SYMTAB 记录了 symbol table 和 string table 的 offset 以及 size,其中两个 offset 很重要;

  1. 重定向表的 command:
LC_DYSYMTAB

重定向表中记录的 offset 就是用来基于 linkedit_base 进行寻址的;

  1. symtab 属于 __LINKEDIT

header 的 file 地址如下:

mach_header

__LINKEDIT 的信息如下:

__LINKEDIT

所以可以得出,在 file 中 __LINKEDIT 的结束地址为:

0x0005a620 + 0x58000 = 0x000b2620

而 symtab 的地址是:

symtab

可以看到,0x44200 < 0xb2620,所以 symtab 属于 __LINKEDIT

其实,除了 __TEXT__DATA,包括签名等信息都属于 __LINKEDIT

__LINKEDIT

至此,可以知道后面两个问题的答案了:

  1. linkedit_base 为什么这么算?
  2. linkedit_base 的意义是什么?

答:linkedit_base 去掉 slide 后的本质是处于 __LINKEDIT 之前的 segment 因为内存对齐规则而多出来的 size。又因为内存对齐是在 Segment 后面补 0, section 不会因为内存对齐而改变在 segment 中的位置,所以可以依据 linkedit_base 计算出 symbol tablestring tableindirect table 在虚拟内存中的初始位置;

七、几点补充

第一点要补充的是:

fishhook 中三个表的计算方式和 dyld 源码略有不同,以 symtab 举例:

//dyld源码中的写法
//uint8_t为char,&ptr[symtab_cmd->symoff] 等价于 linkedit_base + symtab_cmd->symoff,其意义是(*prt + sizeof(uint8_t) * symtab_cmd->symoff)
uint8_t *ptr = (uint8_t *)linkedit_base;
uint8_t *p2 = (uint8_t *)&ptr[symtab_cmd->symoff];
    
// symbol table(fishhook)
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);

dyld 中写法的核心在于 &pointer[adress],有点类似于数组指针的 +1 ,其含义是:

向后取 &{*pointer + adress * sizeof(pointer)}

此处不需要过于纠结,只是看 dyld 源码时看到不同,稍微研究了一下;

第二点要补充的是:

linkedit_base 的本质是因为虚拟内存对齐多出来的 size,所以如果虚拟内存和磁盘缓存一样大,那么 linkedit_base = 0 + slide = slide ,也就等于 image 的起始位置。这也是为什么使用 image lookup 查看内存时,有时候会看到该地址为 __TEXT 段的初始位置,有时候啥也看不到。因为这个值本身不代表内存地址,能看到只是因为 slide 碰巧为 0,此时这个 linkedit_base 正好表示 __LINKEDIT 段在内存中的地址。

实例如下图:


image lookup

有时候却啥也查不到或者结果比较懵逼:

乱内存

所以,不要去直接查看 linkedit_segment->vmaddr - linkedit_segment->fileoff;,这个值不代表 mach-O 的某部分在内存中的位置,而是单纯的表示 vm 和 file 中 size 的差值;

八、寻找懒加载和非懒加载的setion

接下来,看下这句代码:

cur = (uintptr_t)header + sizeof(mach_header_t);

这句代码让位置回到了 Load Command 的初始位置,后面又开始遍历了:

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) {
              // 不是SEG_DATA/SEG_DATA_CONST则退出,即只在这两个段中查找
              continue;
      }
        
      // 遍历 segment 中的 section
      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);
        }
      }
}

代码分为几步:

  1. 遍历 Load Command;
  2. 只查找 segment 类型的 command;
  3. 只在 __DATA或者 __DATA_CONST 的 segment_command 中查找;
  4. 遍历 segment 的 section,找出 S_NON_LAZY_SYMBOL_POINTERSS_LAZY_SYMBOL_POINTERS 的 section;
  5. 两个 section 都调用 perform_rebinding_with_section 方法;

总结:这一步中,找到了__DATA/__DATA_CONST 段中的懒加载和非懒加载的 section;

这里的 section 之和不一定是 2,要看 TYPE 决定,只要是这两种类型,都会调用 perform_rebinding_with_section 方法,即该方法的调用次数为懒加载和非懒加载表之和。比如 __got__nl_symbol_ptr的 TYPE 都是非懒加载类型,如下图:

__got

估计和编译器设置有关,暂不深究;这一步的代码代码不复杂,这里就略过了,好好看看 perform_rebinding_with_section 方法;

九、reserved1字段的意义

perform_rebinding_with_section 这个方法的重点比较多,一个一个看:

首先是:

  uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;

这里的 section 只有两种:懒加载 __la_symbol_ptr 或者非懒加载__nl_symbol_ptr/__got,对于 section 这个结构体中,reserved1 的解释如下:

reserved1

即:

  • 当前表第一个符号在重定向表中的起始 index;

来看个实例:

reserved1

来算一下:

// 其实位置 + size * index
(lldb) p/x 0x24b00 + 0x64 * 4
(int) $8 = 0x00024c90

再去重定向表中找确认:

重定向表

如上图,验证成功;

所以,上述代码的意义是:

  • 找到当前入参 section 中第一个符号在重定向表中的位置;

那为什么要找到这个 index 呢?因为符号是按照类型一块一块整体存储在符号表中的。符号的信息只有一份,保存在 symtab 中。符号表、重定向表、字符串表的关系如下:

  1. symtab 存储符号,符号的 name 指向 strtable
  2. 重定向表是 symtab 的子集,存储 index,该 index 和 symtab 中的 index 对应;

十、重绑定函数

继续看代码,perform_rebinding_with_section 代码太多,先看外部的 for 循环:

  // 指向指针的指针,表中存储的都是指针
  void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);

  for (uint i = 0; i < section->size / sizeof(void *); i++) {
      // 取出重定向表中的index
    uint32_t symtab_index = indirect_symbol_indices[i];

    //使用symtab_index在symbol table中取出符号对象(结构体)
    // 再取出该符号在string table 中的偏移
    uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
    // 取出该符号的name
    char *symbol_name = strtab + strtab_offset;

    bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
    struct rebindings_entry *cur = rebindings;

   // while......
   // ......

  symbol_loop:;
  }

这个 for 循环就是遍历 section 中的所有符号,并取出了两个重要信息:

  1. 取出重定向表中的 index;
  2. 取出符号的 name;

具体流程就是:indirect.index -> symbol table -> string table

再来看第二个 循环:

    //遍历rebindings中的符号,即需要被替换的符号  
    while (cur) {
      for (uint j = 0; j < cur->rebindings_nel; j++) {
        if (symbol_name_longer_than_1 &&
            strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
            // name命中
          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;
    }

提取这个 while 循环,其实就两句关键代码:

// 保留原函数到replaced
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
// 修改表中的指针为自定义的函数
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;

这个两句代码对应着两个关键步骤:

  1. 将重定向表中的指针赋值到 replaced 中,replaced 是我们自定义的一个函数指针,用于保存原函数;
  2. 将重定向表中的指针修改成了我要要替换的函数地址,replacement 就是我们用于替换原函数的函数地址;

十一、懒加载和非懒加载表的补充

上一章节中其实有一句代码也比较关键:

uint32_t symtab_index = indirect_symbol_indices[i];

这里直接按照 i++ 顺序取出了重定向表中的数据,这里有几个知识点:

  1. 重定向表只存储该符号位于符号表中的 index;
  2. 重定向表中的数据按类型分组,顺序存储;
  3. 重定向表、懒加载表、非懒加载表,各个类型的符号在这几个表中排列顺序都是一样的;

这里有点拗口,按照个人的理解,这里跟编译链接的过程有关;大概的过程应该是静态编译时期生成重定向表。这一步是将符号表中的外部符号按照类型取出存放到重定向表中,且只存储 index,看下实例:

image.png

上图中可以理解成重定向表中进行了分组排序,例如 __stub 中的符号不会和 __got 中的符号位置互串。

紧接着,静态编译器根据重定向表在对应的 section 中生成符号指针,这里就要区分两种情况了:

懒加载:生成符号对应的桩函数,桩函数会去懒加载符号表中取出指针跳转到对应函数位置,懒加载表中的初始指针指向 stub_helper 函数,进而指向 binder 函数;
非懒加载:不生成也不需要生成桩函数,但是因为依赖的动态库在动态链接时才 load,所以非懒加载符号表中的函数指针为 0;

如下图:

非懒加载
懒加载

总结:

  1. 符号表中记录了所有的符号,静态依赖库的符号会被直接拷贝进入到主工程,生成最终的 mach-O 文件;
  2. 而依赖的动态库源码不会被拷贝到主工程中,之所以叫做动态库,是因为程序被加载时才进行链接,准确来说在 dyld2 中,是在链接主程序时才加载依赖的动态库;
  3. 符号表中存储全量符号,而动态库的符号额外存储一份在重定向表中,为了节约内存,表中只存储该符号在符号表中的 index;
  4. 重定向表分组排序,依次印射到 __stub、懒加载表(__la_symbol_ptr)、非懒加载表(__nl_symbol_ptr、__got),这一步在静态编译时期就完成了;
  5. 懒加载符号的调用在静态编译时期就被替换成了桩函数,桩函数只管取出懒加载符号表中的函数指针进行跳转;
  6. 懒加载符号表中的函数指针初始化(静态编译时期)时指向 __stub_helper 进而指向 binder 函数;
  7. binder 函数在符号于运行时第一次被调用时进行寻址,然后替换懒加载符号表中的函数指针为真实的函数地址;
  8. 非懒加载符号表中的指针初始化时值为 0,动态链接之后立马进行寻址,寻址完成后进行替换;
  9. fishhook 的原理总结起来就是一句话:将懒加载和非懒加载表中符号的指向替换为自己的符号地址。因此,fishhook 没办法 hook 系统库的内部函数。因为这些符号表压根就没有暴露,在其他库中也就没有调用,自然无法进行符号替换。

这里还有一点不确定,非懒加载的符号是和懒加载符号一样?真实调用代码被替换成桩函数?还是在动态链接时期直接替换成了函数地址?还是说基于 PIC (-fpic)技术,在静态链接时期已经将调用代码替换成了去非懒加载符号表中取出指针进行跳转的代码?感觉更像第三种~~后面再深入~~

十二、fishhook中的replaced 最终保存的函数

replaced 是保存原函数,如果是懒加载,懒加载表中一开始存储的是 stub_helper 函数,如果在调用该函数之前调用了 rebind 方法,replaced 中会被替换成 stub_helper 而不是原函数?如果是这样,那么每次调用 replaced 函数,都会去进行一次重复绑定?

验证:

  1. iphone7(10.3)

模拟器中 dyld 实际使用的是 dyld_sim,其 dyld 的版本是:

dyld

源码如下:

// 指向函数的指针
static void (*sys_NSLog)(NSString *format, ...);

void xk_NSLog(NSString *format, ...) {
    
//    format = [format stringByAppendingString:@"(我被hook了)"];
    printf("hook succ\n");
    sys_NSLog(format);
    
}

void rebind(void) {
    // 定义结构体
    struct rebinding xkNSLogBind;

    // 需要hook的函数的名称
    xkNSLogBind.name = "NSLog";

    // 新函数的地址
    xkNSLogBind.replacement = xk_NSLog;

    // 保存被替换掉函数的指针
    xkNSLogBind.replaced = (void *)&sys_NSLog;

    // 创建需要hook的结构体数组
    struct rebinding rebind[1] = {xkNSLogBind};
    // hook
    rebind_symbols(rebind, 1);
}

int main(int argc, char * argv[]) {
    rebind();
    sys_NSLog(@"---");
    sys_NSLog(@"---");
}

断点之后的汇编:

image.png
image.png
image.png

其实上面的问题就是 fishhook 源码必定会导致的现象。 fishhook 按照依赖库的加载顺序对每个库中的懒加载和非懒加载符号表进行了替换,以此达到全局替换的目的;

正因为这样的逻辑,fishhook会找到最后一个包含该函数(需要被替换的原函数)的库,或者说的更准确一点,依赖库中所有包含该符号的库都会进行一次替换。所以最后一个 stub_helper 函数保存到 replaced 中;

即:

  • placed 中保存的是最后一个包含被替换函数的依赖库中,函数的 stub_helper 函数;

其实这个问题在 dyld2 和 dyld3 中不一样,在 iOS14 中运行。然后使用一个奇技淫巧:

watchpoint set v sys_NSLog

或者直接在源码中添加:

image.png

这样就可以看到打印了:

image.png

直接查看这个内存:

image.png

如上图,在 rebind 操作完成之后,就已经指向 Foundation 中真实的 NSLog 函数了。

其实上面可以看到还有一个0x101b6defe,这个依赖库估计不是共享库,这个指针指向的应该是 stub_helper 函数,可以自行验证;所以最后一个包含该函数的库如果不是共享库,或者共享缓存是第一次加载的,那么rebind之后就可能是 stub_helper,具体现象和原因暂时不探讨;

再来看看 dyld 版本:


image.png

很明显,iOS14 的模拟器中的 dyld 已经是 dyld3 的版本了;

关于这个现象查阅到以下资料:

dyld3

即:dyld3 中使用了 lauch closure 机制,会导致流程不一样;

至于 dyld3 中的具体流程,以后再分析,不是本文重点;

十三、留个疑问

非懒加载符号主动调用

有个有意思的现象:

非懒加载符号主动调用之后就变成懒加载了,比如 objc_msgSend :

image.png

这里留个疑问:

  1. 非懒加载符号在运行时是直接被替换成了函数指针,还是和懒加载符号一样使用 stub 函数来调用?
  2. 如果是被替换成了真实的函数地址,那么 fishhook 中替换 __nl_symbol_ptr 就没有意义?

感觉这里肯定有个知识点自己还不知道,暂时存疑吧~~

你可能感兴趣的:(iOS逆向:fishhook原理分析)