fishhook--终于被我悟透了
fishhook 作为一个 hook 工具在 iOS 开发中有着高频应用,理解 fishhook 的基本原理对于一个高级开发应该是必备技能。很遗憾,在此之前虽然对 fishhook 的基本原理有过多次探索但总是一知半解,最近在整理相关知识点对 fishhook 又有了全新的认识。如果你跟我一样对 fishhook 的原理不甚了解那这篇文章会适合你。
需要强调的是本文不会从 fishhook 的使用基础讲起,也会不对照源码逐行讲解,只会着重对一些比较迷惑知识点进行重点阐述,建议先找一些相关系列文章进行阅读,补充一些基本知识再回过头来阅读本文。
注1:所有代码均以 64 位 CPU 架构为例,后文不再进行特别说明
注2:请下载 MachOView 打开任意 Mach-O 文件同步进行验证
注3:Mach-O 结构头文件地址
MachO 文件结构
0x01
Mach-O 文件结构有三部分,第一部分是 header,描述 Mach-O 文件的关键信息。其数据结构如下:
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 */
};
如上面结构体所示,Mach-O 文件 header 的关键信息包括:
cputype
:当前文件支持的 CPU 类型filetype
:当前 MachO 的文件类型ncmds
: Load Command 的数量sizeofcmds
:所有 Command 的总大小
每个 iOS 的可执行文件、动态库都会从 header 开始加载到内存中。
0x02
第二部分是 Load Commands,Load Commands 有不同的类型,有的用于描述不同类型数据结构(在文件中的位置、大小、类型、限权等),有的单纯用来记录信息,比如记录:dyld
的路径、main
函数的地址、UUID 等,用于记录信息的 Command 一般不会出现在数据区(Data)内。
不同的类型的 Load Command 对应着不同的结构体,但所有 Load Command 的前两个字段(cmd/cmdsize
)都是相同的。所以,所有的 Load Command 都可以通过类型强转为 load_command
结构体类型。
有了 load_command
就可以通过每一个 Load Command 的 cmdsize
计算出下一个 Load Command 的位置。
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize;` /* total size of command in bytes */
};
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_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 */
};
有文章说 load_command
是所有 Command 的基类,你也可以这样理解(虽然在代码语法层面不是)。
segment_command_64
作为一个 Load Command 重点类型,一般用来描述 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT
等包含实际代码数据的段(位于 Data 部分)。
因此对于 segment_command_64
类型的 Load Command 也称之为: segment。
segment
内部还包含一个重要的类型:section,section 用于描述一组相同类型的数据。例如:所有代码逻辑都位于名为 __text 的 section 内,所有 OC 类名称都位于名为 __objc_classname 的 section 内,而这两个 section 均位于 __TEXT 段(segment)。
segment_command_64
关键字段介绍:
segname
: 当前 segment 名称,可为 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT 之一vmaddr
: 当前 segment 加载到内存后的虚拟地址(实际还要加上 ALSR 偏移才是真实的虚拟地址)vmsize
: 当前 segment 占用的虚拟内存大小fileoff
: 当前 segment 在 Mach-O 文件中的偏移量,实际位置 = header 开始的地址 + fileofffilesize
: 当前 segment 在 Mach-O 文件中的实际大小,考虑到内存对齐vmsize
>=filesize
nsects
: 当前segment_command_64
下面包含的 section 个数
关于随机地址偏移(ALSR) 的相关容内可自行查找相关资料进行了解,这里不再贅述
section 只有一种类型,其结构体定义如下:
struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
uint64_t addr; /* memory address of this section */
uint64_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) */
uint32_t reserved3; /* reserved */
};
section 关键字段介绍:
sectname
: section 的名称,可以为 __text,__const,__bss 等segname
: 当前 section 所在 segment 名称addr
: 当前 section 在虚拟内存中的位置(实际还要加上 ALSR 偏移才是真实的虚拟地址)size
: 当前 section 所占据的大小(磁盘大小与内存大小)reserved1
: 不同 section 类型有不同的意义,一般代表偏移量与索引值flags
: 类型&属性标记位,fishhook 使用此标记查找懒加载表&非懒加载表
需要注意:有且仅有 segment_command_64
类型的 Command 包含 section。
0x03
最后是数据区(Data),就是 Mach-O 文件所包含的代码或者数据;所有代码或者数据都根据 Load Command 的描述进行组织、排列。其中由segment_command_64
描述的数据或代码在 Data 部分中均以 section 为最小单位进行组织,并且这部分内容占大头。segment 再加上其它类型 Load Command (其实就是 __LINKEDIT
segement)描述的数据共同组成了数据区。
注意:虽然名称为 __LINKEDIT
(类型为:segment_command_64
) 的 segment 下面所包含的 section 数量为 0,但根据其 fileoff,filesize
计算发现:
__LINKEDIT
的 segement 所指向的文件范围其实包含其它 Load Command (包含但不限于:LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE)所指向的位置范围。
推导过程如下:
如上图所示在 Load Commands 中 __LINKEDIT
在 Mach-O 文件的偏移:0x394000
大小为:0x5B510
。而 Mach-O header 的开始地址为 0x41C000
。所以 __LINKEDIT
在 Mach-O 文件中的地址范围是:{header + fileoffset, header + fileoffset + filesize}
,代入上式就是 {0x41C000+0x394000, 0x41C000+0x394000+0x5B510}
,最终得到 {0x7B0000,0x80B510}
的地址范围。
从下图看,segment 最后一个 section 结束后的第一个地址就是上面的开始的范围,文件的结束地址也是上面计算结果的结束范围(最后一个数据地址占 16 字节)。
所以可以这样理解:名称为 __LINKEDIT
Load Command 是一个虚拟 Command。它用来指示LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE 等这些 Command 描述的数据在「文件与内存」中的总范围,而这些 Command 自己本身又描述了自各的范围,从地址范围来看 __LINKEDIT
是这些 Command 在数据部分的父级,尽管它本身并没有 section。
fishhook 的四个关键表
fishhook 的实现原理涉及到四个「表」,理解这四个表之间的关系便能理解 fishhook 的原理,且保证过目不忘。
- 符号表(Symbol Table)
- 间接符号表(Indirect Symbol Table)
- 字符表(String Table)
- 懒加载和非懒加载表(__la_symbol_ptr/__non_la_symbol_ptr)
符号表&字符表
其中符号表(Symbol Table)与字符表(String Table)在 LC_SYMTAB 类型的 Load Command 中描述。
struct symtab_command {
uint32_t cmd; /* LC_SYMTAB */
uint32_t cmdsize; /* sizeof(struct symtab_command) */
uint32_t symoff; /* 符号表(Symbol Table)在文件中相对 header 的偏移 */
uint32_t nsyms; /* 符号表(Symbol Table)数量 */
uint32_t stroff; /* 字符表(String Table)在文件中相对 header 的偏移 */
uint32_t strsize; /* 字符串(String Table)表总大小*/
};
符号表(Symbol Table)内容的数据结构用 nlist_64
表示:
struct nlist_64 {
union {
uint32_t n_strx; /* index int0 the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
nlist_64
的第一个成员 n_un
代表当前符号的名称在字符表(String Table)中的相对位置,其它成员变量这里不需关注。
字符表(String Table)是一连串的字符 ASCII 码数据,每个字符串之间用 '\0' 进行分隔。
间接符号表
而间接符号表(Indirect Symbol Table)在 dysymtab_command
结构体的 Load Command(类型为LC_DYSYMTAB)中描述。
struct dysymtab_command {
uint32_t cmd; /* LC_DYSYMTAB */
uint32_t cmdsize; /* sizeof(struct dysymtab_command) */
/* 省略部分字段 */
uint32_t indirectsymoff; /* 间接符号表相对 header 的偏移 */
uint32_t nindirectsyms; /* 间接符号表中符号数量 */
/* 省略部分字段 */
};
间接符号表本质是由 int32
为元素组成的数组,元素中存储的数值代表当前符号在符号表(Symbol Table)中的相对位置。
懒加载和非懒加载表
懒加载与非懒加载表位于 __DATA/__DATA_CONST
segment 下面的 section 中。
懒加载与非懒加载表有如下特点:
- 当前可执行文件或动态库引用外部动态库符号时,调用到对应符号时会跳转到懒加载与非懒加载表指定的地址执行
- 懒加载表在符号第一次被调用时绑定,绑定之前指向桩函数,由桩函数完成符号绑定,绑定完成之后即为对应符号真实代码地址
- 非懒加载表在当前 Mach-O 被加载到内存时由 dyld 完成绑定,绑定之前非懒加载表内的值为 0x00,绑定之后同样也为对应符号的真实代码地址
敲黑板知识点:fishhook 的作用就是改变懒加载和非懒加载表内保存的函数地址
由于懒加载和非懒加载表没有包含任何符号字符信息,我们并不能直接通过懒加载表和非懒加载表找到目标函数在表中对应的位置,也就无法进行替换。因此,需要借助间接符号表(Indirect Symbol Table)、符号表(Symbol Table)、字符表(String Table)三个表之间的关系找到表中与之对应的符号名称来确认它的位置。
如何找到目标函数地址
这里借用一下 fishhook 官方给的示意图,可以先自行理解一下再往下看:
引用外部函数时需要通过符号名称来确定函数地址在懒加载和非懒加载表的位置,具体过程如下:
- 懒加载表与非懒加载表中函数地址的索引与间接符号表(Indirect Symbol Table)中的位置对应;
以表中第
i
个函数地址为例,对应关系可以用伪公式来表述:间接符号表的偏移
=
间接符号表开始地址+
懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+
i
- 间接符号表中保存的 int32 类型的数组,以上一步计算到的「间接符号表的偏移」为索引取数组内的值得到符到号中的位置
同样得到一个等效伪公式:符号表的偏移
=
间接符号表开始地址+
间接符号表的偏移 - 符号表中保存的数据是
nlist_64
类型,该第一个字段(n_un.n_strx
)的值就是当前符号名称在字符表中的偏移等效伪公式:符号名称在字符表中的偏移
= (
符号表的开始地址+
符号表的偏移).n_un.n_strx
- 按照上面得到的偏移,去字符表中取出对应字符串(以
\0
)结尾等效伪公式:懒加载表与非懒加载表中第
i
个函数名=
字符表的开始地址+
符号名称在字符表中的偏移
到这里我们从下至上进行公式代入,合并三个伪公式得到:
懒加载表或非懒加载表中第 i
个函数名 =
字符表的开始地址 +
(
符号表的开始地址 +
间接符号表开始位置 +
懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+
i)
.n_un.n_strx
。
现在,上面这个公式里还不知道的是三个开始地址:
- 字符表(String Table)的开始地址
- 符号表(Symbol)的开始地址
- 间接符号表(Indirect Symbol Table)开始地址
而懒加载表或非懒加载表中函数地址个数也可以通过对应 section 的 size
字段(详情查看上文 section_64
结构体中的描述)计算而得到,公式:(section->size / sizeof(void *)
)。
到这里 fishhook 四个表的关系应该非常清楚了,fishhook 所做的无非是通过这个公式在懒加载表与非懒加载表中找到与目标函数名匹配的外部函数,一旦发现匹配则将其地址改为自定义的函数地址。
何为 linkedit_base
如果不考虑其它因素,实际上面三个表的开始地址可以直接通过 Mach-O 的 header 地址 + 对应的偏移就可以直接得到。以符号表(Symbol Table)为例:
Mach-O header 的开始地址如上文所述为:0x41C000,计算 0x41C000 + 0x3BECD8 = 0x7DACD8;再用 MachOView 查看这个地址,确实是符号表在文件中的位置:
同时上面的的推导也证明了 symtab_command->symoff
symtab_command->stroff
是相对 Mach-O header 的偏移,并不是相对 __LINKEDIT
的偏移;
而 fishhook 源码中计算符号表开始地址的方式是:
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
导致不少博文说 linkedit_base
是 __LINKEDIT 段的基地址,symoff 是相对 __LINKEDIT segment 的偏移,这完全是错误的,在此可以明确的是:
linkedit_base
并不是__LINKEDIT
segment 在内存中的开始地址linkedit_base
并不是__LINKEDIT
segment 在内存中的开始地址linkedit_base
并不是__LINKEDIT
segment 在内存中的开始地址
fishhook 中计算 linkedit_base
的计算方式如下:
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
忽略掉随机地址偏移(ALSR)值: slide
后:
linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff
;
linkedit_segment->vmaddr
:代表 __LINKEDIT
segment 在「虚拟内存」中的相对开始位置linkedit_segment->fileoff
:代表 __LINKEDIT
segment 在「文件」中的相对开始位置
那这两个相减有什么意义呢?
要解答这个问题先来看 MachOView 给出的信息:
如上图,在 __LINKEDIT
segment 之前的几个 segment (红框标记)可以解析出几个事实:
- 每个 segment 的在「 Mach-O 文件」中的开始地址都等于上一个 segment 的
File Offset + File Size
,第一个 segment 从 0 开始 - 同理,每个 segment 在「虚拟内存」中的位置都等于上一个 segment 的
VM Address + VM Size
,第一个 segment 从 0 开始 __PAGEZERO
与_DATA
的VM Size > File Size
,而其它 segment 中这两个值相等,意味着两个 segment 加载到虚拟内存中后有一部分「空位」(因内存对齐而出现)__PAGEZERO
不占 Mach-O 文件的存储空间,但在虚拟内存在占 16K 的空间
用图形表示即为:
故而 linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff
的意义为:
- Mach-O 加载到内存后
__LINKEDIT
前面的 segment 因内存对齐后多出来的空间(空位) - Mach-O 加载到内存后
__LINKEDIT
前面的 segment 因内存对齐后多出来的空间(空位) - Mach-O 加载到内存后
__LINKEDIT
前面的 segment 因内存对齐后多出来的空间(空位)
这才是 linkedit_base
在物理上的真正意义,任何其它的定义都是错误的。
而 __LINKEDIT
本身的 VM Size == File Size 说明它包含的符号表、字符表与间接符号表三个表本身是内存对齐的,它们之间没有空位,所以它们本身在文件中的偏移 +
linkedit_base
即为在内存中的实际位置。
// 符号表在内存中的开始位置
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// 字符表在内存中的开始位置
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// 间接符号表在内存中的开始位置
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
最后
fishhook 在 APM、防逆向、性能优化等方向均有较多的应用,从本质上来看 fishhook 是对 Mach-O 文件结构的深度应用。相信在了解完原理之后再去看 Mach-O 文件的结构就比较简单了,与 Mach-O 文件结构相关的应用还有符号表的还原。下篇文章再与大家共同学习符号表还原的具体过程(虽然文件夹还没有创建 😂)。
如对本文有任何疑问,我们评论区交流 😀
来源:juejin.cn/post/7360980866796388362