Mach-O
什么Mach-O
Mach-O为Mach Object文件格式的缩写,它是一种用于可执行文件,目标代码,动态库,内核转储的文件格式。作为a.out格式的替代,Mach-O提供了更强的扩展性,并提升了符号表中信息的访问速度。Mach-O是iOS、mac系统中的可执行文件格式。
- MachO格式的常见文件
目标文件.o
库文件
.a
.dylib
Framework
可执行文件
dyld
.dsym
- 常用命令
使用lifo -info 可以查看MachO文件包含的架构
lipo -info MachO文件
使用lifo –thin 拆分某种架构
lipo MachO文件 –thin 架构 –output 输出文件路径
使用lipo -create 合并多种架构
lipo -create MachO1 MachO2 -output 输出文件路径
Mach-O文件结构
每个Mach-O文件包括一个Mach-O头,然后是一系列的载入命令,再是一个或多个块,每个块包括0到255个段。Mach-O使用REL再定位格式控制对符号的引用。Mach-O在两级命名空间中将每个符号编码成“对象-符号名”对,在查找符号时则采用线性搜索法。以下是苹果官方关于Mach-O的结构图:
通过MachOView可以查看更为详细的Mach-O的结构。如下图所示:
- Mach64 Header: 包含该二进制文件的一般信息。包括字节顺序、架构类型、加载指令的数量等。使得可以快速确认一些信息,比如当前文件用于32位还是64位,对应的处理器是什么、文件类型是什么。以下就是Header的数据结构:
struct mach_header_64 {
uint32_t magic; /* mach-o 格式的标识符 */
cpu_type_t cputype; /* cpu区分符 */
cpu_subtype_t cpusubtype; /* machine区分符 */
uint32_t filetype; /* 文件类型 */
uint32_t ncmds; /* 加载命令的个数 */
uint32_t sizeofcmds; /* 加载命令的字节数 */
uint32_t flags; /* 程序的标识位 */
uint32_t reserved; /* 保留字段 */
};
- Load Commands:是加载指令,描述的是文件的加载信息,内容包括区域的位置、符号表、动态符号表等。这个部分信息还是比较有用的,我们可以从这里获取到符号表和字符串表的偏移量等。通过MachOView可以查看Load Commands详情:
字段解析
LC_SEGMENT_64 将文件中(32位或64位)的段映射到进程地址空间中
LC_DYLD_INFO_ONLY 动态链接相关信息
LC_SYMTAB 符号地址
LC_DYSYMTAB 动态符号表地址
LC_LOAD_DYLINKER 使用谁加载,我们使用dyld
LC_UUID 文件的UUID
LC_VERSION_MIN_MACOSX 支持最低的操作系统版本
LC_SOURCE_VERSION 源代码版本
LC_MAIN 设置程序主线程的入口地址和栈大小
LC_LOAD_DYLIB 依赖库的路径,包含三方库
LC_FUNCTION_STARTS 函数起始地址表
LC_CODE_SIGNATURE 代码签名
- 数据区:除了Header和Load Commands外所有的原始数据。数据区分为很多段(Section)。
text段是代码段。它用来放程序代码(code)。它通常是只读的。
data段是数据段。它用来存放初始化了的(initailized)全局变量(global)和初始化了的静态变量(static)。它是可读可写的。
bss段是全局变量数据段。它用来存放未初始化的(uninitailized)全局变量(global)和未初始化的静态变量
接下来先介绍数据区几个比较重要的模块:
- (__TEXT,__text)
这里存放的是汇编后的代码,当我们进行编译时,每个.m文件会经过预编译->编译->汇编形成.o文件,称之为目标文件。汇编后,所有的代码会形成汇编指令存储在.o文件的(__TEXT,__text)区。链接后,所有的.o文件会合并成一个文件,所有.o文件的(__TEXT,__text)数据都会按链接顺序存放到应用文件的(__TEXT,__text)中。
- (__DATA,__data)
存储数据的section,static在进行非零赋值后会存储在这里,如果static 变量没有赋值或者赋值为0,那么它会存储在(__DATA,__bss)中。
- Symbol Table
符号表,这个是重点中的重点,符号表是将地址和符号联系起来的桥梁。符号表并不能直接存储符号,而是存储符号位于字符串表的位置。
- String Table
字符串表所有的变量名、函数名等,都以字符串的形式存储在字符串表中。
- Dynamic Symbol Table
动态符号表存储的是动态库函数位于符号表的偏移信息。(__DATA,__la_symbol_ptr) section 可以从动态符号表中获取到该section位于符号表的索引数组。动态符号表并不存储符号信息,而是存储其位于符号表的偏移信息。
- Lazy Symbol Pointers
Lazy Symbol Pointers懒加载符号表。所谓懒加载是指在程序运行时需要访问这些符号的时候再去绑定。这些符号一般来自程序依赖的动态库。
-
Non Lazy Symbol Pointers
Non Lazy Symbol Pointers 非懒加载符号表。所谓非懒加载是指在程序一加载就绑定好的。这些符号一般来自程序依赖的动态库。
-
Symbol Stubs
翻译过来就是符号桩。它与Lazy Symbol Pointers是一一对应的,每次访问外部符号时都会先访问Symbol Stubs,然后执行桩代码,最后去Lazy Symbol Pointers找到相应的符号地址执行下一步操作。
Assembly
这里面其实就是符号绑定执行的汇编代码。后面到符号绑定的时候再详解。
符号重定向
对于iOS程序来说,由于ASLR安全机制的原因,每次启动程序系统都会分配一个随机偏移值,程序启动时会根据偏移值进行符号地址修正。假设程序首地址的0x00000000, 随机偏移值是0x00008000, 那程序的首地址就变成0x00000000 + 0x00008000, 程序内某个符号的偏移地址是0x00001000,那么它的内存地址是0x00001000 + 0x00008000 = 0x00009000。符号重定向实际上就是在程序运行时,把符号的偏移地址加上启动时的随机偏移值得到符号的内存地址的一个过程。符号重定向针对的是程序内的符号。接下来我们通过下面的demo来进行验证。首先我们新建一个类MyObject, 然后定义
一个方法doSomething,然后运行:
获取编译后的可执行文件:
这个黑色icon的文件就是我们的可执行文件,把这个文件拖入MachOView中:
接下来演示一下重定向的过程。首先在运行前- (void)doSomething入口打个断点,然后在xcode->Debug-> Debug WorkFlow -> Always show disassembly 进入汇编模式,运行然后就进入如下画面:
打开刚才的MachOView查看符号表,如下:
可以看到- (void)doSomething放的的符号-[MyObject doSomething]在文件中的偏移地址为5E28。接下来我们验证一下重定向的过程:
由图中可知方法doSomething的内存地址与程序的首地址的差值是不变的,而且是等于-[MyObject doSomething]在文件中的偏移值。
符号绑定
相对于符号重定向针对的是程序内的符号,符号绑定针对的是程序外的符号,比如所以依赖的动态库等。由于编译时并没有把动态库编译到程序内,只是在连接阶段生成动态符号表。但是生成这个动态符表的地址并不是符号的真是地址,只有在访问这个符号时才会调用系统的符号绑定函数进行进行绑定,并获取符号地址更新到符号表中。这个过程就叫符号绑定。
绑定流程
当程序首次访问外部函数的时候,它首先会访问外部函数的桩Symbol Stubs,并执行桩代码(Symbol Stubs中的Data字段对应的值),而这个桩代码执行后,最后会跳转到Lazy Symbol Pointers对应符号的地址。首次访问会根据这个地址在Assembly文件中找到相应的代码执行,最后调用dyld_stub_binder函数进行符号绑定。绑定完成之后就会更新Lazy Symbol Pointers表中的值,将符号地址直接写入到表中,再次访问的时候就可以直接访问这个地址而不需要在执行Assembly中的代码。 大致流程图如下:
下面我们利用NSLog(NSLog来自系统动态库Foundation)的例来对符号绑定整个过程进行演示。我们同时通过MachOView工具以及程序运行会的汇编调试来展示这个过程。同样的,以下面的demo为例:
- 进入汇编调试
首先,在NSLog入口处打个断点,进入汇编调试,如下图所示:
红圈部分可以看到确实是访问了Simbol Stubs。
- 执行执行Symbol Stubs中的代码
通过汇编调试执行bl指令进入如下页面:
这一步可以看到实际上是执行Symbol Stubs中的Data断的代码,这个段代码最后会跳转到0x000000010015651c,这个地址是通过Lazy Symbol Pointers获取的。
- 执行Assembly中的代码
通过image list命令可以获取程序的偏移地址(首地址)为0x0000000100150000。偏移地址0x000000010015651c -0x0000000100150000 = 0x000000000000651c。然后根据这个偏移值到Assembly执行相应的代码。执行br指令,跳转到x16中的地址(实际上就是刚才0x000000010015651c),进入如下页面:
- 调用符号绑定函数dyld_stub_binder
在这里执行两行代码然后跳转到6504这个这个地方。这个地方实际上就是调用符号绑定函数dyld_stub_binder的地方。以下通过汇编调试和Assembly中查看他们的汇编指令就可以看得出来。
为了进一步验证,执行br指令跳转到x16中的地址(实际上就是dyld_stub_binder函数地址)。进入如下页面:
- Non-Lazy Symbol Pointers
这里有个疑问,就是dyld_stub_binder本身也是外部符号。那它是怎么绑定的呢?又是什么时候绑定的呢。实际上dyld_stub_binder是非懒加载符号,是在程序一开始运行就绑定的,它存储在Non-Lazy Symbol Pointers里面,所以不需要再走一遍符号绑定流程。
至此,NSlog函数第一次调用过程中的符号绑定流程就走完了。其他外部符号访问流程都是一样的。绑定成功之后会修改Lazy Symbol Pointers中的值为符号的内存地址,下次访问时不需要再次绑定,可以直接在Lazy Symbol Pointers进行访问。
第二次调用NSLog函数
查看Lazy Symbol Pointers中的编译时偏移地址:
接下来我们进行第二次访问的验证。首先在第二次调用处打个断点,进入汇编调试,进入如下页面:
继续执行bl指令,跳转一个地址,进入如下页面:
最终看到第二次进入的时候Lazy Symbol Pointers中的值就已经是NSLog函数的地址了,程序就可以直接访问了。
符号重绑定
有前面知道符号的绑定过程,我们知道符号绑定的本质就是将外部符号的地址跟新到Lazy Symbol Pointers表中。符号重绑定实际上也就是修改Lazy Symbol Pointers中的值。常用的第三方库Fishhook之所以能够hook系统代码就是利用这个原理,直接修改了Lazy Symbol Pointers对应符号的地址值实现。这里我们以Hook系统函数NSLog为例,演示Fishhook工作的大致流程如下:
- 通过字符换找到符号。去Mach-O中寻找String Table(String Table中通过“.”字符将符号分割),得到偏移值(String Table Index)
- 通过String Table Index去找Symbols(符号表),得到符号表的偏移值(Symbol Index)
- 通过Symbol Index 去找indirect Symbols,得到符号的偏移值(Indirect Symbol Index)
- 最后去修改Lazy Symbol Pointers 里面的值(因为有前面的分析可知,外部符号调用都在找桩,桩去寻找Lazy Symbol Pointers 里面的地址)
接下来通过汇编调试验证:
首先获取NSLog符号在Lazy Symbol Pointers中的偏移值:
拿到符号偏移地址为0xC000,开始进入汇编调试:
hook之后符号地址就改成了我们自定义的函数myNSlog的地址。