Mach-O
什么是Mach-O
Mach-O
为 Mach 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 */
这里定义了0x1
、0x2
、0x4
三个值,并且没有定义0x5
,flags
最低位是5,那么表示同时有MH_NOUNDEFS
和MH_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 | 代码签名信息 |
程序在构建时会指定加载的基地址,但是无法保证基地址的唯一性,也无法保证映像的地址区间不重叠。
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
#include
#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__LINKEDIT
、LC_SYMTAB
、LC_DYSYMTAB
,为第二次遍历做好了地址计算的准备;第二次遍历找到了SECTION_TYPE
为S_LAZY_SYMBOL_POINTERS
和S_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。