fishhook 源码学习

Mach-O

什么是Mach-O

Mach-OMach Object文件格式的缩写,是用于 iOS 和 macOS 的可执行文件,目标代码,动态库等等多种文件类型的的文件格式。

Mach-O文件格式

苹果官方给了一张结构图:

我们编写一个HelloWorld程序,将其编译,然后通过MachOView来打开.out文件:

可以知道Mach-O由三部分组成:

  • Header:指明了CPU架构、文件类型、Load Commands 个数等一些基本信息。
  • Load Commands:描述了怎样加载每个 Segment 的信息。在 Mach-O 文件中可以有多个 Segment,每个 Segment 可能包含零个、一个或多个 Section。
  • Data:Segment 的具体数据,包含了代码和数据等。

Header

/*
 * The 32-bit mach header appears at the very beginning of the object file for
 * 32-bit architectures.
 */
struct mach_header {
    uint32_t    magic;      /* mach magic number identifier */
    cpu_type_t  cputype;    /* cpu specifier */
    cpu_subtype_t   cpusubtype; /* machine specifier */
    uint32_t    filetype;   /* type of file */
    uint32_t    ncmds;      /* number of load commands */
    uint32_t    sizeofcmds; /* the size of all the load commands */
    uint32_t    flags;      /* flags */
};

/*
 * The 64-bit mach header appears at the very beginning of object files for
 * 64-bit architectures.
 */
struct mach_header_64 {
    uint32_t    magic;      /* mach magic number identifier */
    cpu_type_t  cputype;    /* cpu specifier */
    cpu_subtype_t   cpusubtype; /* machine specifier */
    uint32_t    filetype;   /* type of file */
    uint32_t    ncmds;      /* number of load commands */
    uint32_t    sizeofcmds; /* the size of all the load commands */
    uint32_t    flags;      /* flags */
    uint32_t    reserved;   /* reserved */
};
  • magic:魔数,0xfeedface是32位,0xcefaedfe是64位
  • cputype:CPU类型
  • cpusubtype:CPU具体类型
  • filetype:文件类型,例如可执行文件、库文件等
  • ncmds:Load Commands的数量
  • sizeofcmds:Load Commands的总大小
  • flags:标志位,用于描述该文件的详细加载信息
  • reserved:64位才有的保留字段,暂时没用

对于上面的HelloWorld程序来说,它的Header信息如下:

这里有一点值得注意:上面的定义中,flags是一个int值,但是怎么能够存储这么多类型?其实巧妙运用了宏定义值的特殊性,使得它们本身和它们和的组合具有唯一性,例如这里的flags值为00200085

#define MH_NOUNDEFS 0x1     /* the object file has no undefined
                       references */
#define MH_INCRLINK 0x2     /* the object file is the output of an
                       incremental link against a base file
                       and can't be link edited again */
#define MH_DYLDLINK 0x4     /* the object file is input for the
                       dynamic linker and can't be staticly
                       link edited again */

这里定义了0x10x20x4三个值,并且没有定义0x5flags最低位是5,那么表示同时有MH_NOUNDEFSMH_DYLDLINK两个标志位。

Load Commands

struct load_command {
    uint32_t cmd;       /* type of load command */
    uint32_t cmdsize;   /* total size of command in bytes */
};
  • cmd类型:指定command类型
  • cmdsize:表示command大小,用于计算到下一个command的偏移量
cmd 作用
LC_SEGMENT/LC_SEGMENT_64 将段内数据加载映射到内存中去
LC_SYMTAB 符号表信息
LC_DYSYMTAB 动态符号表信息
LC_DYLD_INFO_ONLY 记录地址重定向信息
LC_LOAD_DYLINKER 启动dyld
LC_UUID 唯一标识符
LC_SOURCE_VERSION 源代码版本
LC_MAIN 程序入口
LC_LOAD_DYLIB 加载动态库
LC_FUNCTION_STARTS 函数符号表
LC_DATA_IN_CODE Data注入代码地址
LC_CODE_SIGNATURE 代码签名信息
  1. 程序在构建时会指定加载的基地址,但是无法保证基地址的唯一性,也无法保证映像的地址区间不重叠。

  2. iOS采用了ASLR(Address space layout randomization)技术,使得每个程序加载时的基地址随机化。

这两个原因导致程序加载到内存时,真实的基地址和构建时指定的基地址是不同的,因此需要进行地地址的重定向,LC_DYLD_INFO记录的就是相关信息。

segment

首先看看segment的定义:

struct segment_command { /* for 32-bit architectures */
    uint32_t    cmd;        /* LC_SEGMENT */
    uint32_t    cmdsize;    /* includes sizeof section structs */
    char        segname[16];    /* segment name */
    uint32_t    vmaddr;     /* memory address of this segment */
    uint32_t    vmsize;     /* memory size of this segment */
    uint32_t    fileoff;    /* file offset of this segment */
    uint32_t    filesize;   /* amount to map from the file */
    vm_prot_t   maxprot;    /* maximum VM protection */
    vm_prot_t   initprot;   /* initial VM protection */
    uint32_t    nsects;     /* number of sections in segment */
    uint32_t    flags;      /* flags */
};
  • cmd:上面提到的Load Command类型
  • cmdsize:Load Command大小
  • segname[16]:段名称
segname 含义
__PAGEZERO 可执行文件捕获空指针的段
__TEXT 代码段和只读数据
__DATA 全局变量和静态变量
__LINKEDIT 包含动态链接器所需的符号、字符串表等数据
  • vmaddr:段虚拟地址(未重定向),真实虚拟地址要加上ASLR的偏移量(随机地址防御溢出攻击)
  • vmsize:段的虚拟地址大小
  • fileoff:段在文件内的地址偏移
  • filesize:段在文件内的大小
  • nsects:段内section数量
  • flags:标志位,用于描述详细信息

将segment内容加载到内存的过程,就是从文件偏移fileoff处,将大小为filesize的段,加载到虚拟机vmaddr处。

程序在构建时的基地址,可以从第一个__TEXT代码段中的vmaddr获取。而真实的的基地址,就是header指针指向的地址。

section

section的定义:

struct section { /* for 32-bit architectures */
    char        sectname[16];   /* name of this section */
    char        segname[16];    /* segment this section goes in */
    uint32_t    addr;       /* memory address of this section */
    uint32_t    size;       /* size in bytes of this section */
    uint32_t    offset;     /* file offset of this section */
    uint32_t    align;      /* section alignment (power of 2) */
    uint32_t    reloff;     /* file offset of relocation entries */
    uint32_t    nreloc;     /* number of relocation entries */
    uint32_t    flags;      /* flags (section type and attributes)*/
    uint32_t    reserved1;  /* reserved (for offset or index) */
    uint32_t    reserved2;  /* reserved (for count or sizeof) */
};
  • sectname:section名称
  • segname:所属的segment名称
    (大写的__TEXT代表segment,小写的__text代表section
sectname 含义
__text 源代码对应的机器指令
__subs 桩代码
__stub_helper 用于动态链接,启动dyld
__cstring 硬编码的C字符串
__la_symbol_ptr 延迟加载
__data 初始化的可变的变量
  • addr:section在内存中的地址
  • size:section大小
  • offset:section在文件中的偏移
  • align:内存对齐边界
  • reloff:重定位入口在文件中的偏移(目前没有找到实际用处)
  • nreloc:重定位入口数量
  • flags:标志位,记录type(互斥)和attributes (多个共存)
  • reserved:保留位,reserved1在下面非常有用

fishhook

什么是fishhook

fishhook 是一个由Facebook开源的框架,可以动态修改链接 Mach-O 符号表。

demo

我们用这样一段C代码来演示:

#include <stdio.h>
#include <string.h>
#include "fishhook.h"

static int (*original_strlen)(const char *_s);

int new_strlen(const char *_s) {
    return 666;
}

int main(int argc, const char * argv[]) {

    struct rebinding strlen_rebinding = {
        "strlen",
        new_strlen,
        (void *)&original_strlen
    };
    rebind_symbols((struct rebinding[1]){strlen_rebinding}, 1);

    char *str = "Hello Fishhook!";
    printf("%d\n", strlen(str));
    return 0;
}

首先我们构造了一个和原函数签名相同的函数指针*original_strlen,然后重新实现了新的new_strlen函数。在main函数中,创建了一个rebinding结构体,分别传入了需要hook的函数名,新实现的函数,与原函数签名相同的函数指针。最后通过rebind_symbols进行符号的重新绑定,运行时就会输出666。

源码分析

结构体定义

首先来看一看rebinding的定义:

struct rebinding {
  const char *name; // 需要hook的函数名
  void *replacement; // 新函数的实现
  void **replaced; // 指向“原函数”的函数指针
};

这里通过函数名和一个函数签名,可以确定需要hook的是哪个函数,然后用新函数代替它,储存这些信息的数据结构就是一个rebinding

struct rebindings_entry {
  struct rebinding *rebindings; // 数组实例
  size_t rebindings_nel; // 元素数量
  struct rebindings_entry *next; // 链表索引
};

// 全局静态变量,记录表头
static struct rebindings_entry *_rebindings_head;

一个rebindings_entry可以理解为一次hook时的信息入口,它存储了一个rebinding数组、数组元素的数量和下一个节点。所以这里维护的是一个rebindings_entry链表,有多少次hook,链表就有多少个节点。

rebind_symbols

直接调用的函数实现:

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
  // prepend_rebindings方法完成了rebindings_entry链表的初始化
  int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
  // 返回-1则表示失败
  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中去,之后的每次替换都会调用该方法
    _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是使用fishhook的入口函数,它有两个作用,一是调用prepend_rebindings进行数据结构的初始化,二是注册了_rebind_symbols_for_image函数并在新映像加载时回调。如果调用_dyld_register_func_for_add_image时,系统已经加载了某些映像,则会分别调用它们注册的回调函数。也就是说,在加载、卸载映像,以及为映像注册回调函数时,回调函数都会被调用,所以这个函数通常被用来监控映像和统计系统数据。

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);
  // 分配空间失败时,释放new_entry
  if (!new_entry->rebindings) {
    free(new_entry);
    return -1;
  }
  // 将传入的rebindings数组,copy到new_entry->rebindings中
  memcpy(new_entry->rebindings, rebindings, sizeof(struct rebinding) * nel);
  // 为new_entry->rebindings_nel赋值
  new_entry->rebindings_nel = nel;
  // 为new_entry->next赋值,以维护反向链表结构
  new_entry->next = *rebindings_head;
  // 将static的*rebindings_head指针指向表头
  *rebindings_head = new_entry;
  return 0;
}

prepend_rebindings初始化了一个新的rebindings_entry节点并插入链表头部。如果在一个程序中多次调用rebind_symbols来hook函数,就有多个rebinding数组需要维护,rebindings_entry维护的是一个反向链表,每个节点都维护一个rebinding数组,通过链表可以判断是否是第一次hook。

_rebind_symbols_for_image 准备基址

// 入口方法,目的是满足回调函数的签名格式;intprt_t是符合平台标准字长的整型指针
static void _rebind_symbols_for_image(const struct mach_header *header,
                                      intptr_t slide) {
    // 真正调用的函数
    rebind_symbols_for_image(_rebindings_head, header, slide);
}

调用_dyld_register_func_for_add_image进行注册时,需要满足特定的回调函数签名格式。

// 准备基址
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 = 0x100000000 -> 二进制文件基址默认偏移
  // sizeof(mach_header_t) = 0x20 -> Mach-O Header 部分
  // 初始化游标,跳过Mach-O Header
  uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
  // 第一次遍历Load Command,目的是找到上面的查找量,cur每次偏移Load Command的大小
  // 可以计算出 Base Address、Symbol Table、Dynamic Symbol 和 String Table
  for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
    // 获得当前的Load Command
    cur_seg_cmd = (segment_command_t *)cur;
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
        // Load Command的类型是LC_SEGMENT
      if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
        // 找到SEG_LINKEDIT,即Load Command的name为"__LINKEDIT"
        linkedit_segment = cur_seg_cmd;
      }
    } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
      // Load Command的类型是LC_SYMTAB
      symtab_cmd = (struct symtab_command*)cur_seg_cmd;
    } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
      // Load Command的类型是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
  // base = segment真实地址(已计算ASLR偏移) - segment偏移地址
  // segment真实地址(已计算ASLR偏移) = segment虚拟地址(未计算ASLR偏移) + ASLR偏移量
  // base = ALSR偏移量 + segment虚拟地址 - segment偏移地址
  uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
  // symtab首地址 = base + symtab_cmd的symoff偏移地址
  // 注意:LC_SYMTAB和LC_DYSYMTAB的中所记录的Offset都是基于__LINKEDIT段的
  nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
  // strtab首地址 = base + symtab_cmd的stroff偏移地址
  char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);

  // Get indirect symbol table (array of uint32_t indices into symbol table)
  // indirect_symtab首地址 = base + dysymtab_cmd的indirectsymoff偏移地址
  uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

  // 游标归零复用
  cur = (uintptr_t)header + sizeof(mach_header_t);
  // 第二次遍历,目的是找到LC_SEGMENT(__DATA)中 __nl_symbol_ptr和__la_symbol_ptr这两个section
  // 可以确定lazy binding指针表和non lazy binding指针表在Dynamic Symbol中对应的位置
  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) {
      // Load Command的类型是LC_SEGMENT
      if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
          strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
        // 过滤name不是SEG_DATA或者SEG_DATA_CONST的segment
        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;
        // 和SECTION_TYPE取与,只保留后两位,可判断TYPE
        if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
          // 如果section类型为S_LAZY_SYMBOL_POINTERS,则进行rebingding重写操作
          perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
        }
        if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
          // 如果section类型为S_NON_LAZY_SYMBOL_POINTERS
          perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
        }
      }
    }
  }
}

这里对Load Commands进行了两次遍历,第一次遍历根据cmd找到了三个需要的segment:LC_SEGMENT__LINKEDITLC_SYMTABLC_DYSYMTAB,为第二次遍历做好了地址计算的准备;第二次遍历找到了SECTION_TYPES_LAZY_SYMBOL_POINTERSS_NON_LAZY_SYMBOL_POINTERS的section,并调用perform_rebinding_with_section对section中的符号进行处理。

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 Table中获取符号表数组,利用了reserved1
  uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
  // 获取函数指针列表 __DATA.__nl_symbol_ptr(或__la_symbol_ptr) section
  void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
  // 遍历section
  for (uint i = 0; i < section->size / sizeof(void *); i++) {
    // 通过偏移“索引”获取Indirect Address的Value
    uint32_t symtab_index = indirect_symbol_indices[i];
    // 过滤INDIRECT_SYMBOL_ABS和INDIRECT_SYMBOL_LOCAL
    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;
    // 符号名长度小于1时过滤
    bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
    // 取出rebindings_entry链表头部
    struct rebindings_entry *cur = rebindings;
    while (cur) {
      // 遍历rebindings_entry中的rebindings链表,匹配符号名和方法名
      for (uint j = 0; j < cur->rebindings_nel; j++) {
        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) {
            // 记录跳转地址
            *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
          }
          // 重写跳转地址
          indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
          goto symbol_loop;
        }
      }
      cur = cur->next;
    }
  symbol_loop:;
  }
}

这个查找匹配的过程描述起来比较绕口,首先通过符号在__la_symbol_prt的index,加上Load Command中__la_symbol_prt的保留信息reserved1,得到了Indirect Symbols中位于index + reversed1 的数据index2,然后在Symbol Table中index2的位置拿到偏移地址offset,最后拿到String Table中offset处的数据,这个数据就是函数名。如果函数名匹配,则更改__la_symbol_ptr表中的函数地址,完成hook。

总结

程序启动时,会链接很多动态库,函数的调用就是通过指令跳转到函数对应的内存地址。因为动态库是运行后开始链接,所以程序并不知道函数在哪里,所以这些函数放在__DATA,__la_symbol_prt表中。

例如我们现在要调用printf函数,表中相应内容并不会直接指向printf,而是指向了dyld_stub_binder,它的作用就是计算真正的printf地址,并且将表中的指针指向修改,这样下次就可以直接调用printf了,这就是懒加载。

而fishhook做的工作,就是在dyld绑定了地址之后再次做一个重绑定,200行左右的代码,只有一行是在修改函数指针,最复杂的逻辑主要是在计算地址和匹配字符串。hook的局限性,就是只能修改__la_symbol_ptr表中的函数指向,也就是不能hook静态库中的函数和自定义的函数。安全方面,我们可以通过替换函数的地址是否在映像内,来判断是否是恶意程序注入的hook。

推荐阅读更多精彩内容