本文主要介绍一些 iOS / Mac OS X 操作系统的东西,比如 DYLD,Mach-O,ARM 汇编。内容很枯燥。
逆向分析别人的应用时,因为我们肯定不可能直接拿到别人的源代码,所以只能从可执行文件,也就是机器代码下手。先用工具将其反汇编为汇编代码,然后通过分析汇编代码来了解程序逻辑。因此需要掌握分析的前提——ARM 汇编。
在 iOS 设备和几乎所有的移动设备上,使用的都是基于 arm 架构的处理器,含 arm/thumb 指令集。根据 arm 架构的不同版本,分为:armv6,armv7,armv7s,armv8(arm64)。在模拟器上,由于模拟器是运行在 Intel 处理器的电脑上,使用的是 x86 的指令集,所以底层库一般都会为不同架构写兼容代码。而普通开发者发布 App 时,只支持 arm64 架构就够了,因为现在支持最高 armv7s 的 iPhone 5 早已淘汰了。arm64 架构的处理器为兼容旧程序,提供 64 位运行状态 AArch64 和 32 位运行状态 AArch32,分别使用 64/32 位的地址、寄存器、指令集。下面也只介绍 AArch64 下的内容。
逆向分析中常见的寄存器包括:
R0~R30 是 31 个 64 位通用寄存器,使用时,通过 X0-X30 访问;通过 W0-W30 只访问其低 32 位,即当作 32 位寄存器使用。
SP,Stack Pointer,保存栈顶地址,WSP 可访问低 32 位。
PC,Program Counter,保存下一条指令地址。
LR = X30,Link Register,保存函数调用的返回地址。
FP = X29,Frame Pointer,保存栈底地址,用 X29 访栈。
X0-X7 用于函数调用时参数传递;X0 用于传递返回值。
X8 用于间接寻址。
这里主要介绍逆向分析中经常见到的指令。不常见的指令可以随时查阅 ARM 手册。
数据传输指令:
MOV X1,X0 ; X1 = X0
(MOVZ,MOVN,MOVK?)
加载储存指令:
LDR X5,[X6,#0x08] ; 把地址 X6 + 0x08 上的内容传送到 X5
STR X0, [SP, #0x8] ; 把 X0 传送到地址为 SP + 0x8 的内存上
注意:栈向低地址扩展。
STP x29, x30, [SP, #0xA0-0x10] ; 常用于入栈备份寄存器,相当于两次 STR
LDP x29, x30, [SP, #0xA0-0x10] ; 常用于出栈还原寄存器,相当于两次 LDR
算术指令:
ADD X0,X1,X2 ; X0 = X1 + X2
SUB X0,X1,X2 ; X0 = X1 - X2
CMP X0,#0x1 ; X0 和 1 相减,结果影响状态位;CMN 是相加
ADDS/SUBS 相当于 ADD/SUB,结果影响状态位
逻辑指令:
AND X0,X0,#0xF ; X0 = X0 & 0xF;有 ANDS
ORR X0,X0,#9 ; X0 = X0 | 0x9
EOR X0,X0,#0xF ; X0 = X0 ^ 0xF
TST X0,#0x8 ; X0 & 0x8 结果影响状态位,常用于测试第 4 位是否为 0
条件跳转指令:
CBZ X0,label ; 如果 X0 = 0,则跳转到 label;CBNZ
TBZ X0,#0,label ; 如果 X0[0] = 0,则跳转到 label;TBNZ
无条件跳转指令:
B label ; 跳转到 label
BL label ; 将下一条指令地址写入 X30,跳转到 label
BR X0 ;
BLR X0 ;
RET ; 跳转到 X30
地址偏移指令:
ADR X1,0x1234 ; 计算当前指令偏移,X1 = PC + 0x1234
ADR 只能计算小范围内的偏移,ADRP 计算以页为单位的偏移(操作系统中一页内存是 4KB):
ADRP X1,0xa ; 以页为单位,计算当前指令所在的页偏移 0xa 页的地址。
X1 = ((PC >> 12) + 0xa) << 12
。经常使用 ADRP 页偏移指令配合 LDR,例如:
ADRP X8,#symbol@PAGE
LDR X0,[X8,#symbol@PAGEOFF]
其中 @PAGE 是符号的页地址偏移 PC 的页地址的值,因此 X8 最终保存的是 symbol 这个符号所在的页的基地址。@PAGEOFF 是 symbol 在页内的偏移量,最终实现用两条指令把 64 位的符号放入 X0。
移位指令:
ASR
LSL
LSR
ROR
看汇编看多了,就会发现函数内对栈的操作有一个共同的套路:
“申请”一块栈空间
备份寄存器
设置 FP(X29),用 X29 代替 SP 访栈
函数功能实现
还原寄存器
“释放”栈空间
函数返回
关于结构体做返回值,实际上是调用者分配结构体的内存,把结构体地址作为参数调用函数,函数最后拷贝内存到目标地址达到“返回结构体”的效果。此外需要再次提醒,OC 中由于前面提到过的消息机制的存在,方法调用实际上会转换为 objc_msgSend 族汇编函数的调用,有隐藏的默认参数 X0、X1,分别代表 self、SEL。
每种操作系统都有自己的可执行文件格式。源代码按照目标操作系统支持的格式进行编译,加载程序时按照规则读取程序内容。在 Windows 平台上是 PE 格式,在 Linux 平台上是 ELF 格式,在 Apple 平台上是 Mach-O 格式。只有对 Mach-O 格式的完全了解,才能理解一部分逆向工具、程序加载的原理。
各种操作系统的可执行文件类型虽然不同,但是设计思想都差不多,核心内容可以分为三部分:
第一部分是文件头,简单描述了可执行文件的基础信息。
现代操作系统设计中,程序普遍分代码段、数据段等,通过段基址加偏移量访问目标内存,因此文件中需要写明有哪些段及其相关的详细信息。段内分节,即 section,段有读写属性可以限制访问权限,而 section 更像是给程序员使用,利于编程。文件中也需要描述有哪些节及其详细信息。这些构成了第二部分的主要内容。
第三部分就是每个段内各个节包含的真正的数据。
在上述的三个核心部分基础上,一般操作系统都增加了自己独特的功能以及细节上的优化。下面详细解读 Mach-O 格式文件内容。
苹果设计了一种 Mach-O Universal Binary 格式文件,它打包了多种不同架构的 Mach-O 格式文件,在自己的文件头中描述了包含的各个 Mach-O 文件支持的 CPU 架构及位置、大小等信息。在苹果平台上,一个可执行文件可以是包含多个架构的 Mach-O 通用二进制格式文件,也可以直接是 Mach-O 格式文件。使用 lipo 命令可以合并、拆分多个架构,例如从 xxx.dylib 中拆分出 64 位架构:lipo xxx.dylib -thin arm64 -output xxx64.dylib
。
对于 Mach-O 格式文件,其整体结构如图:
可以通过软件 MachOView 方便地查看 Mach-O 文件内容,其各部分详细介绍如下。
文件头包含以下信息:
1 魔数,标识 Mach-O 文件
2 支持的 CPU 架构,由主类型和子类型组成,例如 arm + armv7
3 文件类型,是可执行文件、动态库还是可重定位文件
4 加载命令个数、所有命令占据的空间大小
5 标识位,flags,例如标记启用地址空间布局随机化
Load Commands 描述了 Mach-O 文件中到底有哪些内容,包括程序中各个段和节的信息、要链接的动态库信息、动态加载信息以及代码签名等,程序加载过程中根据加载命令执行相应的签名验证、动态链接、加载程序等操作。下面解读几个重要的加载命令并展开介绍相关内容。
描述一个段。操作系统在加载程序时,根据这条命令内容,将可执行文件内 fileoff 偏移处的 filesize 大小的内容加载到虚拟地址 vmaddr 上。苹果给 Mach-O 文件格式设计了四种段:
段内可以分节(Section),段的加载命令中包含段内所有节的描述信息。我觉得节的作用就是让程序员在逻辑上将程序划分为几个部分,让结构更清晰。 代码段中的节包含以下几个:可执行代码,符号桩,桩辅助函数,方法名字符串,类名字符串,方法签名字符串,其他只读字符串。在数据段中包含:懒加载/非懒加载符号指针表,类指针列表,Protocol 指针列表,Category 指针列表等等。
懒加载符号的出现是为了加快系统启动速度。在 DYLD 加载时,非懒加载符号会直接绑定真实的地址,而懒加载符号会在第一次被调用时才绑定真实的地址,之后的调用不需要再次绑定。
前面提到过,代码段中包含一个 section:符号桩,或者说函数桩 stub。懒加载符号运行中被调用时实际上是在调用这个函数桩,桩内指令非常简单,LDR 后 BR。这样怎么就能实现懒加载的呢?
BR 时寄存器中的地址,就是懒加载符号表中目标符号的地址,这个地址上的值,默认是桩辅助函数,到这里后续流程应该大致可以猜出来了。Mach-O 格式文件中有一块懒加载信息,里面描述了各个懒加载符号在哪个动态库中、符号在当前模块的哪个段及在段中的偏移量是多少等信息。调用桩辅助函数,该函数取出懒加载信息后调用 dyld_stub_binder。有 Load Commands 描述了所有要链接的动态库的信息。dyld_stub_binder 调用 _dyld_fast_stub_entry 执行真正的绑定:根据目标符号所在的动态库,在动态库中找到目标符号的真实地址;将找到的真实地址写入当前模块中该符号所在的段的该符号的偏移量处,这个位置也就是懒加载符号表中该符号的地址。
第二次调用目标符号时,虽然仍要调用到函数桩,但是这次懒加载符号表中目标符号位置上的地址已经被修改为真正目标符号的地址了,函数桩内会直接调用到真实的目标符号。
描述了签名数据在文件内的偏移量和大小。签名数据由多个子条目组成,包括 Code Directory、Requirement、Entitlements、Certificate 等,可以用 brew 安装 jtool,然后用 jtool 读取 Mach-O 格式文件的签名信息:jtool -arch arm64 -v --sig xxx
。
Load Commands 描述了 Mach-O 文件包含的全部内容,就像是 Mach-O 文件有文件头一样,文件内各个部分也需要有 Header,这些头组成了 Load Commands,而各部分的具体数据共同组成了文件体,也就是本部分。
在编写应用程序时,程序员看到的入口都是 main 函数,然后编译、链接生成可执行文件,写入到文件系统中。在最早的 Linux 系统中,用户打开某个可执行文件时,通过系统调用让操作系统先 fork 出一个子进程,然后调用 exec 族系统调用,根据文件头加载文件内容到内存,加载完成后跳转到程序的 main 函数或指定的其他地址。
iOS 系统整体上仍然应用这种思想,并添加了更复杂的功能。程序在运行时会依赖很多动态库,当操作系统准备好启动程序后,会跳转到 _dyld_start,由 dyld 加载可执行文件、链接动态库、符号绑定及完成后调用程序内真正的 _main 符号。因此,dyld(dynamic link editor) 就是介于操作系统加载与程序 main 函数之间的一段程序,源码下载。
由于系统动态库很多程序都会用到,为了优化程序加载速度,苹果设计了动态库共享缓存技术。dyld 的缓存可以在手机的 /System/Library/Caches/com.apple.dyld 目录下找到,因为旧应用都是 32 位,所以在最初的 64 位设备中可能有两个缓存文件,分别对应不同架构的动态库缓存。如果我们想分析系统库原始的二进制文件,就需要从缓存文件中提取,有四种常见方法:
clang++ -o extractor dsc_extractor.cpp dsc_iterator.cpp
生成提取工具,然后执行 extractor dyld_share_cach_arm64 result
即可输出原始的动态库文件。实际使用 Xcode 打开 dyld 源码项目就可以看到有一个名为 dsc_extractor 的 target,可以快速构建提取工具。jtool -extract UIKit dyld_share_cach_arm64
可以导出指定的模块。模拟器运行在 x86_64 CPU 的电脑上,因此动态库和缓存都是 x86_64 架构的,其中动态库缓存在 ~/Library/Developer/CoreSimulator/Caches/dyld 目录下,所有的动态库在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/ 目录下。
操作系统将“接力棒”传给 dyld,首先从 dyldStartup.s 文件中的 __dyld_start 函数开始,里面调用 dyldbootstrap::start,内部又调用 dyld::_main,这个函数内完成了全部加载任务,主要包括以下内容。
如果 App 的 entitlements 中设置了 get_task_allow 权限或处于 Debug 模式下,则允许 dyld 使用环境变量(不受限模式);否则会忽略环境变量或只允许与打印有关的环境变量(受限模式或仅打印模式)。前面文章提到过的也是目前最熟悉的环境变量就是 DYLD_INSERT_LIBRARIES,给 App 注入动态库。
根据上一步设置的环境模式,读取环境变量。如果当前是受限模式则直接忽略所有环境变量;如果是仅打印模式,则只处理 DYLD_PRINT_ 开头的环境变量;如果是不受限模式,则处理所有环境变量。Xcode 可以在 Edit Scheme 页面里添加环境变量。
iOS 系统必须使用共享缓存。如果当前进程是开机后第一个使用缓存的进程,则需要先把缓存映射到共享区域中。之后的进程发现缓存已映射,只需要检查缓存区域,验证缓存文件即可。
可执行文件也就是主程序,先检查可执行文件的架构是否与当前设备兼容,并初步构造 ImageLoader 实例。然后读取加载命令并校验;根据加载命令内容做一些初始化工作,如把段映射到内存;完成构造。最后将实例添加到 Image 列表中。
遍历 DYLD_INSERT_LIBRARIES 指向的动态库数组,对每个动态库执行加载操作。
因为动态库可以存放在多个位置,所以对于给定的动态库名,先扩展为一个位置列表,并遍历列表中的每个位置来搜索目标动态库。必须保证两个相同的动态库绝对不可能同时被加载。搜索的路径包括 DYLD_ROOT_PATH、LD_LIBRARY_PATH、DYLD_FRAMEWORK_PATH、文件本身路径、DYLD_FALLBACK_LIBRARY_PATH,此外,还会去掉文件后缀搜索。如果没有在缓存中找到目标动态库,就需要打开该动态库并加载。构造动态库的 ImageLoader 实例时,也是读取动态库的加载命令并校验。根据加载命令,执行一些准备工作,例如,如果该动态库有代码签名加载命令,则验证代码签名。如果动态库处于被加密状态,则调用 mremap_encrypted 让内核解密。完成构造。遍历 image 列表再次确认没有重复加载后才将实例加入 Image 列表。
根据加载命令中描述的动态库的依赖关系,递归地加载动态库,被依赖的排在前面。然后递归地执行重定位(rebase)操作,也就是确定所谓的“模块的基地址”(符号的虚拟地址 = 模块被加载的基地址 + 符号在模块内的偏移地址)。每个动态库被链接时都会递归执行非懒加载符号的绑定,更新非懒加载符号表。
递归调用了各个动态库和主程序的初始化方法,方法中比较重要的部分是调用 objc runtime 的回调:在 dyld 接手之前,操作系统调用了 _objc_init,向 dyld 注册了 objc runtime 的回调函数。这个回调函数就是 runtime 源码中的 load_images,函数内部调用了各个类、Category 的 +load 方法。初始化方法后面还会调用模块源码中 __attribute__((constructor))
修饰的函数,这些符号被加载命令记录在数据段模块初始化节中。
主程序入口地址以前由加载命令 LC_UNIXTHREAD 描述,新的源码中会先从加载命令 LC_MAIN 中读取,没有时才读取 LC_UNIXTHREAD,然后跳转到入口地址,也就是我们最熟悉的 main 函数。