动态库注入

免越狱调试的方案中,常用的有 IPAPatchMonkeyDev,都会将自定义的代码打成 framework,注入到 App 中,以实现调试的功能。

环境信息
optool: commit(2898b51)
dyld: commit(3f928f3)


关于动态库注入,IPAPatch 采用的是开源的 optool,MonkeyDev 采用的是 monkeyparser。相比之下 monkeyparser 比 optool 多一些功能,但在动态库注入上,原理应该类似。除了免越狱调试这种玩法外,还可以用于 App 自动化测试、性能分析等。

在不使用 Xcode 调试的情况下,如果想要获取 premain 的耗时,可以看 Joy 的 如何精确度量 iOS App 的启动时间。其中要记录所有动态库加载时间,所以打点的库需要第一个被加载。对于内部应用,可以用 Cocoapods,对于 ipa 产物来说,可以直接将动态库注入到第一个 LoadCommand(第一个 LC_LOAD_DYLIB)。

可行性

之所以可以注入自己的动态库,得益于两点:

  • 程序启动会加载 Load Command 中的动态库
  • MachO 中 Load Command 与 Section 之间不连续(连续会影响到 Section 的 offset,猜测可以通过修改 offset 实现,只要数量不超过 4095 就行,dyld 会校验个数)

dyld

iOS 与 macOS 都需要 dyld(dynamic loader)来加载 Frameworks、Dynamic Libs 或者是程序所需的 Bundles(Plug-ins)。 这部分还在学习当中,推荐滴滴的两篇文章:XNU、dyld 源码分析 Mach-O 和动态库的加载过程()。

在 dyld 的 main 函数中,值得关注的几个逻辑有:

  1. 通过 instantiateFromLoadedImage 初始化 Main Execuable,其中对 Load Command 进行分析
  2. 判断 DYLD_INSERT_LIBRARIES 环境变量,加载了通过环境变量注入的动态库(这样也可以实现注入,但需要越狱,所以不在本文讨论范围内)
  3. 调用 link 函数进行链接

当全部动态库都被加载以后,最终返回程序 main 函数地址,进入应用主程序入口。

optool 源码浅析

optool 实现了动态库注入与移除、移除签名,移除 ASLR 等功能,主要看动态库的部分。

基础配置

拉取源码:

# 拉取代码
git clone --depth 1 -b master git@github.com:alexzielenski/optool.git
# 拉取 optool 用来做参数解析的 FSArgumentParser
cd optool
git submodule init
git submodule update --remote

打开工程,Xcode -> Product -> Scheme -> Edit Scheme… -> Run -> Arguments 添加入参。相当于控制台直接调用,方便断点调试。

# install: 动态库注入 Action
# -t: 指定目标二进制
# -p: 指定动态库
install -t /Users/saitjr/Desktop/TTTest.app/TTTest -p /Users/saitjr/Desktop/MTHawkeye.framework/MTHawkeye

关于 -p 参数,也就是动态库路径的指定,后文再谈,这里只是为了调试 optool。目前写本地绝对路径,注入后的 App 会启动闪退。

在 optool 的 main 函数中,如果断点能正确拿到入参,就说明配置没问题了。

int main(int argc, const char * argv[]) {
@autoreleasepool {
BOOL showHelp = NO;

NSLog(@"%s", argv[1]); // 输出 install
...
...
}

读取 MachO Header

参数校验的部分直接跳过,从读取二进制,解析 header 开始:

// 读取可执行文件
NSData *originalData = [NSData dataWithContentsOfFile:executablePath];
// mutableCopy 一下,方便编辑
NSMutableData *binary = originalData.mutableCopy;
// 初始化 thin header 数组,数组长度为 4,也就是最多能包含四个架构
struct thin_header headers[4];
// 初始化 thin header 个数,即可执行文件中的架构数
uint32_t numHeaders = 0;
// 读取 header
headersFromBinary(headers, binary, &numHeaders);

headersFromBinary 中,对 header 进行读取。

可以看到 MachO 的结构中,offset 是一个重要的值。如果是胖文件,也就是包含多架构,就根据 nfat_arch 数量循环进行遍历。

struct fat_header fat = *(struct fat_header *)binary.bytes;
fat.nfat_arch = SWAP(fat.nfat_arch);
int offset = sizeof(struct fat_header);

遍历的过程中,能获取每个架构在 MachO 中的偏移量,从而读取每个架构的 header mach_header

源码中是 thin_header ,这个结构体是自定义的,里面直接用的系统的 mach_header,虽然还有一个是 mach_header_64,但是 64 里面多的 reserved 只是保留字段,没有具体含义,所以不影响解析。

到此,MachO 的 header 就读取完了。

MachO 是 Fat 还是 Thin,以及大端还是小端,都可以通过 Magic Number 判断。如果是 Fat 就遍历读取,如果是 Thin 就读第一个。

查找 Load Command

header 读取完后,注入需要用到的信息有:

  • 架构数
  • 每个架构的偏移量
  • 每个架构的 Load Command 数量
  • 每个架构的 Load Command 大小

然后遍历全部架构,执行 install,也就是 insertLoadEntryIntoBinary 方法。在真正执行插入之前,会先检查当前 Load Command 是否已经存在,所以有个查找的操作,方法是 binaryHasLoadCommandForDylib

根据读取的「每个架构的 Load Command 数量」进行遍历,然后读取每个 Load Command 的类型,如果是动态库,则判断是否和要注入的动态库加载路径一样。

for (int i = 0; i < macho.header.ncmds; i++) {
// 校验偏移量的合法性
if (binary.currentOffset >= binary.length ||
binary.currentOffset > macho.offset + macho.size + macho.header.sizeofcmds)
break;

// 因为不确定 Load Command 类型,所以不能直接解析成某种结构
// 需要通过偏移量来读取类型 => cmd
uint32_t cmd = [binary intAtOffset:binary.currentOffset];
uint32_t size = [binary intAtOffset:binary.currentOffset + 4];

switch (cmd) {
// 仅处理 DYLIB
case LC_REEXPORT_DYLIB:
case LC_LOAD_UPWARD_DYLIB:
case LC_LOAD_WEAK_DYLIB:
case LC_LOAD_DYLIB: {
// lastOffset 为最后一个 DYLIB 的标记位,用于在这之后插入 Load Command
*lastOffset = (unsigned int)binary.currentOffset;
struct dylib_command command = *(struct dylib_command *)(binary.bytes + binary.currentOffset);
// name 表示动态库的加载路径
char *name = (char *)[[binary subdataWithRange:NSMakeRange(binary.currentOffset + command.dylib.name.offset, command.cmdsize - command.dylib.name.offset)] bytes];

// 找到
if ([@(name) isEqualToString:dylib]) {
*lastOffset = (unsigned int)binary.currentOffset;
return YES;
}

// 未找到
// 偏移量递增
binary.currentOffset += size;
loadOffset = (unsigned int)binary.currentOffset;
break;
}
default:
binary.currentOffset += size;
break;
}
}

这里关于加载路径的读取比较有意思,来看看读取加载路径时给的 range:

  • index: binary.currentOffset + command.dylib.name.offset
  • length: command.cmdsize - command.dylib.name.offset

结合 command.dylib.name 类型 lc_str 的注释,可以知道 indexlength 的原因:

The strings are stored just after the load command structure and the offset is from the start of the load command structure. The size of the string is reflected in the cmdsize field of the load command.
Name String 即加载路径是紧跟在 Load Command 结构后面的;offset 表示的是 String 的起始点距离当前 Load Commnad 起始点的偏移量;String 的长度体现在了 cmdsize 中。

从注释和图可知:Name Size = cmdsize - offset - 内存对齐部分,因为对齐都是 \0 占位,所以读取的时候可以不用管,直接作为字符串读取的结束符。

插入 Load Command

在查找过之后,如果已存在同名 Load Command,则替换类型或不处理。如果不存在,则执行插入操作。主要分为两个步骤:

  1. 创建新的 Load Command
  2. 插入 Load Command(插入的方式是:置换 Load Command 与 Section 之间的保留内存,防止插入 Load Command 对后续 offset 造成影响)
  3. 更新 Load Commnad 数量、大小等
// Load Command 大小:sizeof(struct dylib_command) + 加载路径长度(上文叫 name)
unsigned int length = (unsigned int)sizeof(struct dylib_command) + (unsigned int)dylibPath.length;
// 内存对齐
unsigned int padding = (8 - (length % 8));

// 读取 Load Command 与 Section 之间的部分,用于判断 Load Command 与 Section 之间还有没有剩余的空间可以置换
NSData *occupant = [binary subdataWithRange:NSMakeRange(macho.header.sizeofcmds + macho.offset + macho.size, length + padding)];

// 如果没有空间,则退出
if (strcmp([occupant bytes], "\0")) {
NSLog(@"cannot inject payload into %s because there is no room", dylibPath.fileSystemRepresentation);
return NO;
}

// 如果有足够的空间,则开始构建 command
struct dylib_command command;
struct dylib dylib;
dylib.name.offset = sizeof(struct dylib_command); // 加载地址的偏移量
dylib.timestamp = 2; // Load Command 均赋值的 2
dylib.current_version = 0;
dylib.compatibility_version = 0;

command.cmd = type; // Load Command 类型
command.dylib = dylib; // 将动态库 struct 赋值给 Load Command 的 dylib
command.cmdsize = length + padding; // Load Command 大小(注意内存对齐)

// 将 struct 数据构造为 NSMutalbeData
unsigned int zeroByte = 0;
NSMutableData *commandData = [NSMutableData data];
[commandData appendBytes:&command length:sizeof(struct dylib_command)];
// 将加载地址拼接到 Load Command 后面
[commandData appendData:[dylibPath dataUsingEncoding:NSASCIIStringEncoding]];
// 对齐的部分初始化为 0
[commandData appendBytes:&zeroByte length:padding];

// 移除 Load Commnad 与 Section 之间与 commandData 等同的长度
[binary replaceBytesInRange:NSMakeRange(macho.offset + macho.header.sizeofcmds + macho.size, commandData.length) withBytes:0 length:0];
// 将 commandData 插入到最后一个 DYLIB 后面
// lastOffset 是查找时返回的最后一个 DYLIB 结束的位置
[binary replaceBytesInRange:NSMakeRange(lastOffset, 0) withBytes:commandData.bytes length:commandData.length];

// 更新 mach header 中 Load Command 相关的数据
macho.header.ncmds += 1;
macho.header.sizeofcmds += command.cmdsize;

// 替换掉以前的 header 数据
[binary replaceBytesInRange:NSMakeRange(macho.offset, sizeof(macho.header)) withBytes:&macho.header];

数据移动的示意图大致如下,在注入前后,保持 Section 部分的 offset 不变:

加载路径

然后再来谈谈 Name 的赋值。可以看到 Fundation 的加载路径为 /System/Library/Frameworks/Fundation.framework/Fundationlibobjc.A.dylib 的加载路径为 /usr/bin/libobjc.A.dylib,即都是库在设备本地的路径。

对于自定义的动态库来说,一般将动态库放在 XXX.app/Frameworks 文件夹下面。应用可执行文件目录为 @executalbe_path,所以动态库目录可表示为:@executable_path/Frameworks/。除此之外,Build Settings 当中,默认 Runpath Search Path 为 @executable_path/Frameworks,所以目录也可以直接写为 @rpath

如果注入的库叫做 MyFramework.framework,那么注入流程应该是:

  1. 拷贝 MyFramework.frameworkXXX.app/Frameworks 目录下
  2. 执行注入,设置加载路径为 @executalbe_path/Frameworks/MyFramework.framework/MyFramework@rpath 同理)

参考资料