iOS逆向:fishhook原理分析

2,529 阅读17分钟

前言

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

大概说下:

  1. mach-O 分为三部分,第一部分是header,第三部分是数据区,就是一团一团的代码或者数据,不废话了;
  2. 第二部分是load command,存储着不同类型的 command。
  3. 不同的类型的 command 对应着不同的结构体,load_command 结构体类似于基类,其他类型的 command 结构体可以理解成继承自这个 command;
  4. 而 segment_command 只是其中一种,表示这个 command 指向数据区具体的 segment ,如 __TEXT、__DATA/__DATA_CONST 都是这种类型,而动态链接最关键的 __LINKEDIT 也是这种类型,只是在 MachOView 上没有体现出这个 segment;
  5. 这里使用到的还有类型为 LC_SYMTAB 和 LC_DYSYMTAB,对应的结构体为 symtab_command 和 dysymtab_command 。这两个 command 不指向具体的 segment,只是为动态链接器(dyld)提供一些信息,最重要的信息就是符号表和字符串表相对于 Mach-O 文件在磁盘中的文件偏移,即 fileoff(具体为symoff/stroff);

一. 添加监听

主要代码如下:

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:对应 image 在虚拟内存中的偏移;

主工程 image 对应的 vmaddr_slide 就是 ALSR 生成的 slide + __PAGEZERO 的 size(一般为一页0x100000000);

因为 mach-O 文件的起始位置就是 mach_header ,所以这个 vmaddr_slide 一般和 mach_header 相等。但是主工程除外,因为主工程有 __PAGEZERO 这个段,如图:

image.png

非主工程的 image :

image.png

注意,这里是模拟器的情况,而模拟器的 dyld 版本为 433.x.x ,还是 dyld2 的版本。其实在 dyld3 中,共享库的加载位置和懒加载符号的替换方式会稍有不同,以后再说。

另外,vmaddr_slide 和使用 image list 打印出来的所有 image 的首地址对应:

image list

Mach-O 的结构就不展开讲了,详情请看:Mach-O文件结构分析;

二、计算 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 总共有多少个 segment,遍历就是基于此;

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

这里之所以添加这种判断,是因为 segment 对应的 command 的类型为 LC_SEGMENT/ LC_SEGMENT_64,对应的结构体为 segment_command。 __LINKEDIT、__TEXT、__DATA、__DATA_CONST 这些都是 segment。

LC_SYMTAB 则为符号表的 command 对应的类型,其结构体为 symtab_commandLC_DYSYMTAB 则代表动态重定向符号表,对应的结构体为 dysymtab_command,这也是为什么代码中会对 cur_seg_cmd 进行强制类型转换的原因;

经过上述代码,拿到了三个 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;

这里的不占据空间指的是 command 指向的 segment 不占据空间。在 load command 中,__PAGEZERO 作为 command 还是会占据一个 command 结构体的空间。另外,mach_header 和 load command 都处于 __TEXT 段,也就是位于 __text 这个 section 之前;

官方文档的表述:

官方文档

来看看实例:

__LINKEDIT

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

再来看个实例:

__DATA

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

再来看个相等的情况:

__TEXT

如上图,Foundation 和 UIKit 的 __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。

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

因为对齐也只是在 Page 最后补 0,不影响当前 segment 中的位置。所以, symtab 位于 __LINKEDIT 的位置在 file 和 vm 中都是不变的,所以 ③ 和 ④ 的长度是相等的。

而我们又知道在 LC_SYMTAB 的 command 中有 symoff,这个就是 file 中符号表相对于磁盘中的mach'-O文件的起始位置的偏移,即图中的 ①,最终如图,符号表的位置计算为:

// 注意此处的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. linkedit_base 为什么这么算?
  2. linkedit_base 的意义是什么?

答:linkedit_base 去掉 slide 后的本质是处于 __LINKEDIT 之前的 segment 因为内存对齐规则而多出来的 size;又因为 section 不会因为内存对齐而改变在 segment 中的位置,所以可以依据 linkedit_base 计算出 symbol table、string table、indirect 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 段的初始位置,有时候啥也看不到。

实例如下图: 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_POINTERS 和 S_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

来算一下:

(lldb) p/x 0x24b00 + 0x64 * 4
(int) $8 = 0x00024c90

再去重定向表中找确认:

重定向表

如上图,验证成功;

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

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

十、重绑定函数

继续看代码,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,动态链接之后立马进行寻址,寻址完成后进行替换;

这里还有一点不确定,非懒加载的符号是和懒加载符号一样?真实调用代码被替换成桩函数?还是在动态链接时期直接替换成了函数地址?还是说基于 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 就没有意义?

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