一个iOS程序员的自我修养(六)动态链接应用:fishhook原理

3,318 阅读7分钟

前言

有一个很经典的面试题,为什么 fishhook 只能 hook 动态库中的 c 函数?或者可以反过来问,为什么 fishhook 不能 hook OC 方法和我们自己写的 c 函数?

首先看一下 fishhook 对工作原理的一段描述:

dyld binds lazy and non-lazy symbols by updating pointers in particular sections of the __DATA segment of a Mach-O binary. fishhook re-binds these symbols by determining the locations to update for each of the symbol names passed to rebind_symbols and then writing out the corresponding replacements.

fishhook 通过动态绑定的方式来对 Mach-O 的 __DATA 段上的 lazy 和 non-lazy 符号进行 hook。一旦定位到 Mach-O 中这些符号的位置便对其进行重新绑定,然后让其调用自定义的替换函数。

这下再来回答上面的两个问题:

  1. fishhook 为什么不能 hook OC 方法?所有的 OC 方法最终都会编译为 c 方法,还记得上文动态链接例子中 testPrint 方法吗,最终它被编译为了 objc_msgSend 函数调用,存放到了 DATA 段的 Lazy Symbol Pointers 表中。由于 PLT 机制,在绑定的时候,最终它会指向 got 段中的 dyld_stub_binder 函数,这个过程在上一篇动态链接中已经分析过了。当然 hook objc_msgSend 函数是可行的,DATA 段可读写,通过修改 Mach-O 文件中的 objc_msgSend 函数指针指向可以对其进行 hook,这也是 hook 动态库的 c 函数核心原理,但对于 OC 方法,无能为力。
  2. fishhook 为什么不能 hook 我们自己写的 c 函数?还记得动态链接中介绍 PIC 时讲到的第一种情况吗,在生成 Mach-O 文件时,内部函数跳转地址通过 PC 寻址已经确定,指令都被存放在 TEXT 段,权限只读无法修改。

fishhook源码分析

fishhook 代码很少,整个 fishhook.c 仅有 257 行代码,下面结合对 Mach-O 文件结构动态链接的理解,分析下 fishhook 源码的完整流程。在分析入口函数 rebind_symbols 之前,需要先了解下 fishhook 内部的数据结构,fishhook 内部维护了一个单向链表,链表的节点结构如下:

struct rebindings_entry {
  struct rebinding *rebindings;//重新绑定的 rebindings 结构体
  size_t rebindings_nel;//重新绑定的函数数量
  struct rebindings_entry *next;//链表的下一节点
};

rebindings 结构体数组里面存储的 rebinding 结构体就是我们在使用 fishhook 时定义的,它的结构如下:

struct rebinding {
  const char *name;// 函数名称
  void *replacement;// 新的函数指针
  void **replaced;// 保存原始函数地址的变量的指针
};

然后在通过 prepend_rebindings 看下 fishhook 内对数据结构的处理过程:

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;
  }
  //申请 nel 个 rebinding 结构体的内存空间
  new_entry->rebindings = (struct rebinding *) malloc(sizeof(struct rebinding) * nel);
  if (!new_entry->rebindings) {
    free(new_entry);
    return -1;
  }
  //将 rebindings 的值拷贝到 new_entry->rebindings
  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;
}

了解了内部数据结构之后再重新回到入口函数 rebind_symbols:

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
  //将 rebindings 数组插入到链表结构中的头节点
  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
  //头节点没有下一个节点那说明当前链表只有刚插入的节点,这说明是第一次调用。
  //_dyld_register_func_for_add_image:动态库被加载会调用这个监听,同时已经加载的库也会回调。
  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_for_image:

static void _rebind_symbols_for_image(const struct mach_header *header,
                                      intptr_t slide) {
    rebind_symbols_for_image(_rebindings_head, header, slide);
}

// rebind_symbols_for_image 就是定位 Mach-O 中 __nl_symbol_ptr 和__la_symbol_ptr 所在 section 以及 符号表、动态符号表、字符串表的过程:

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;
  //header 的偏移+header 的大小。目的是跳过Mach-O中的header部分寻找Segment。
  uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
  //header->ncmds:加载命令数量
  for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
    //当前的 Load Command
    cur_seg_cmd = (segment_command_t *)cur;
    //类型是LC_SEGMENT
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
      //找到__LINKEDIT段
      //为什么要寻找__LINKEDIT?fishhook 通过 __LINKEDIT 在内存地址中的偏移 - __LINKEDIT在文件中的偏移 + slide = 文件加载的基地址。
      //那么slide是什么?因为 ASLR 的缘故,Mach-O 文件加载的时候地址随机,Mach-O 文件每次加载进内存的时候地址都不一样,这个slide就是本次加载在内存上的偏移。 
      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;
  }

  // Find base symbol/string table addresses
  //linkedit_base即前面提到的基地址,通过基地址+各种表在文件中的偏移=各种表在内存中的位置。
  uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
  //找到符号表。
  nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
  //还记得符号表的结构吗?
  /*
  struct symtab_command {
	uint32_t	cmd;		/* LC_SYMTAB */
	uint32_t	cmdsize;	/* sizeof(struct symtab_command) */
	uint32_t	symoff;		/* symbol table offset */
	uint32_t	nsyms;		/* number of symbol table entries */
	uint32_t	stroff;		/* string table offset */
	uint32_t	strsize;	/* string table size in bytes */
  };
  */
  //stroff:字符串表的偏移。
  //找到字符串表。
  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);

  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;
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
      //寻找__DATA和__DATA_CONST的section。
      if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
          strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
        continue;
      }
      //cur_seg_cmd->nsects:segment中section的数量
      //遍历所有section
      for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
        section_t *sect =
          (section_t *)(cur + sizeof(segment_command_t)) + j;
        //找到__la_symbol_ptr,需要延迟绑定的符号
        if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
          perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
        }
        //寻找__nl_symbol_ptr,非延迟绑定的符号
        if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
          perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
        }
      }
    }
  }
}

定位 __nl_symbol_ptr 和 __la_symbol_ptr 以及对其中的具体函数进行重绑定工作:

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) {
  const bool isDataConst = strcmp(section->segname, SEG_DATA_CONST) == 0;
  //还记得 reserved1 吗,nl_symbol_ptr和la_symbol_ptrsection中的reserved1表示在动态符号表中的起始index。
  uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
  //找到__nl_symbol_ptr和__la_symbol_ptr里面的函数指针存放的地方。
  void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
  vm_prot_t oldProtection = VM_PROT_READ;
  if (isDataConst) {
    oldProtection = get_protection(rebindings);
    mprotect(indirect_symbol_bindings, section->size, PROT_READ | PROT_WRITE);
  }
  //遍历section中的符号。
  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;
    }
    //symtab[symtab_index]:对应符号表中的符号,
    //找到该符号在字符串表中的偏移。
    uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
    //找到符号的名字。
    char *symbol_name = strtab + strtab_offset;
    //判断符号大于两个字符,为什么是两个?因为符号前带有“_”下划线。
    bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
    //fishhook 内部做了单向链表来存储所有的勾子结构体。
    struct rebindings_entry *cur = rebindings;
    while (cur) {
    //遍历所有的节点。
      for (uint j = 0; j < cur->rebindings_nel; j++) {
      //判断符号表中取出来的和外部传入要hook的符号名称是否一致。
        if (symbol_name_longer_than_1 &&
            strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
          if (cur->rebindings[j].replaced != NULL &&
              indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
            //indirect_symbol_bindings[i]:__nl_symbol_ptr和__la_symbol_ptr里面的函数指针之一。
            //用 cur->rebindings[j].replaced 保存原指针指向。
            *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
          }
          //将原函数指针替换为 cur->rebindings[j].replacement。这样调用原函数就变成了调用我们指定的替换函数。
          indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
          goto symbol_loop;
        }
      }
      cur = cur->next;
    }
  symbol_loop:;
  }
  if (isDataConst) {
    int protection = 0;
    if (oldProtection & VM_PROT_READ) {
      protection |= PROT_READ;
    }
    if (oldProtection & VM_PROT_WRITE) {
      protection |= PROT_WRITE;
    }
    if (oldProtection & VM_PROT_EXECUTE) {
      protection |= PROT_EXEC;
    }
    mprotect(indirect_symbol_bindings, section->size, protection);
  }
}

到这里函数的 hook 工作就完成了,下面通过实际的例子再验证一下 fishhook 的 hook 结果。

fishhook过程分析

新建 testfishhook 工程代码如下,通过 lldb 在运行时下进一步分析符号的替换过程:

@implementation ViewController

static void(*sys_nslog)(NSString * format,...);

//替换 NSLog 的函数
void newNslog(NSString * format,...){
    format = [format stringByAppendingString:@"我被勾上了!\n"];
    //调用原 NSLog
    sys_nslog(format);
}

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"我是NSLog");
    // Do any additional setup after loading the view.

    struct rebinding nslog;
    nslog.name = "NSLog";// 要替换的函数名称
    nslog.replacement = newNslog; // 新函数指针
    nslog.replaced = (void *)&sys_nslog;// 指向原始函数地址的指针
    //rebinding结构体数组
    struct rebinding rebs[1] = {nslog};
    rebind_symbols(rebs, 1);
    NSLog(@"我是NSLog");
}
@end

在 Xcode 中执行 command+b 后得到 Products 目录下的 Mach-O 文件:

通过 MachOView 反汇编: Lazy Symbol Pointers 中的 NSLog 符号在文件中的偏移为 0x5000。

通过 image list 命令可以查看所有的 image 文件,list 中第一个即 testfishhook 的 Mach-O 文件,在内存中真实的偏移地址为 0x000000010d59d000。分析源码的时候提到过,因为 ASLR 的缘故,Mach-O 文件加载的时候地址随机,Mach-O 文件每次加载进内存的时候地址都不一样。 Lazy Symbol Pointers 中的 NSLog 符号真实偏移:0x000000010d59d000+0x5000,通过 x 0x000000010d59d000+0x5000 可查看地址内存储的指令。通过 dis -s 命令反汇编可以看到该指令正是 NSLog 函数。

然后将断点下到第二个 NSLog: 通过反汇编的结果看到,NSLog 跳转的指令已经是 newNslog 了。

总结

fishhook 的代码称得上是短小精悍,通过对 fishhook 原理的探究,能够让我们更全面的理解动态链接的过程以及 Mach-O 文件内部的结构,利用这个非常经典的库可以做很多黑魔法的工作。