这篇文章算是对前面那篇动态注入 dylib 到 Mac 应用的一个细节补充, 主要针对使用的开源库yololib和之后要使用到的 unsign 进行源码分析, 在这个基础上对 Mach-O 进行一些初步剖析, 从代码的角度解释这两个库是怎么做的以及为什么可以这么做.
一. 准备工作:
- yololib 源代码: github 地址
- unsign 源代码: github 地址
- C 语言基本语法及文件操作
- MachOView, 搜索引擎搜一下就可以搜到
-
Mach-O 文件基本结构:
5.1 FAT 格式:
FAT 文件布局(图片源自 google 搜索)
5.2 非 FAT 格式(一般 Mach-O 格式):
二. yololib分析:
C 语言的入口函数是 main 函数, 我们就先从 main 函数开始分析.
2.1 main 函数
int main(int argc, const char * argv[])
{
NSString* binary = [NSString stringWithUTF8String:argv[1]];
NSString* dylib = [NSString stringWithUTF8String:argv[2]];
DYLIB_PATH = [NSString stringWithFormat:@"@executable_path/%@", dylib];
NSLog(@"dylib path %@", DYLIB_PATH);
inject_file(binary, DYLIB_PATH);
return 0;
}
代码比较简单, 从传入参数中获取可执行文件的地址和动态库的地址, DYLIB_PATH是一个全局NSString*变量, 这里拼接成@"@executable_path/%@", dyld 在遇到@executable_path的时候会自动替换成可执行文件当前的路径, 同时我们能知道, 注入后 dylib 要和可执行文件放在一起, 不然就无法找到动态库导致启动失败:
dyld: Library not loaded: @executable_path/libDylib.dylib
Referenced from: /Users/ryan/Library/Developer/Xcode/DerivedData/YoloTest-fiuymriohppdctfwvyeqikbxfzgo/Build/Products/Debug/./YoloTest
Reason: image not found
[1] 73837 abort ./YoloTest
接下来就是注入了, 我们看看里面的实现.
2.2 inject_file函数
因为inject_file函数比较长, 这里会直接再代码中就分析其作用, 针对一些需要特殊补充的则会断开.
void inject_file(NSString* file, NSString* _dylib)
{
char buffer[4096], binary[4096], dylib[4096];
// 把 NSString对象转换为了 C 中的字符数组
strlcpy(binary, [file UTF8String], sizeof(binary));
strlcpy(dylib, [DYLIB_PATH UTF8String], sizeof(dylib));
// 打开二进制文件
FILE *binaryFile = fopen(binary, "r+");
printf("Reading binary: %s\n\n", binary);
// 读取前面的4096个字节
fread(&buffer, sizeof(buffer), 1, binaryFile);
// buffer 强转为 fat_header, 因为 4096字节远大于 fat_header 的 size, 所以这么强转是可以达到作者获取 Mach-O 文件头部信息的目的的
struct fat_header* fh = (struct fat_header*) (buffer);
这里出现了struct fat_header, 先来看看它的内容是什么:
struct fat_header {
uint32_t magic; /* FAT_MAGIC or FAT_MAGIC_64 */
uint32_t nfat_arch; /* number of structs that follow */
};
可以看出, 注释中说第一个magic 的取值只有2个, 后面我们会知道这个说法不算太严谨. 第二个nfat_arch, 说的是有后面有多少种架构.
所以, 这里其实可以看出一点点端倪, 所谓 FAT 架构, 其实就是为了可执行文件可以在多种架构都可以运行, 程序在编译的时候会把各个架构的代码都拼接在一起, 而为了让文件正常运行, 就要加一个头部信息, 告诉系统当前架构的代码是从哪到哪, 这个头部信息就是 fat_header.
PS: 我们在开发应用的时候会选 valid architecture, 如果只选 armv7和多选几个所打出来的包大小就会不一样, 而且相差很大, 这就是拼接起来的结果.下面两张图片展示了 fat 格式 和非 fat格式 的区别:
那么这里就有一个疑问了, 如果不是fat 格式的, 那么强转为fat_header取出 magicNumber不会出问题吗?
答案是并不会, 因为非 fat 格式的头部信息第一个变量也是magicNumber, 而且都是uint32_t类型:
// 32位
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 */
};
// 64位基本一直, 只是最后多了一个uint32_t类型的reserved
所以后续的代码中, 作者这么判断是可以正确的:
switch (fh->magic) {
case FAT_CIGAM:
case FAT_MAGIC:
{
// 判定为 fat 的处理...
break;
}
case MH_CIGAM_64:
case MH_MAGIC_64:
{
// 判定为64位的处理...
break;
}
case MH_CIGAM:
case MH_MAGIC:
{
// 判定为32位的处理...
break;
}
default:
{
// 不支持的架构...
exit(1);
}
}
之前 struct fat_header里说 magic 只能取FAT_MAGIC 或者 FAT_MAGIC_64 , 但是为什么这里判断又来了一个FAT_CIGAM, 这是什么情况?
如果心细的话, 会发现CIGAM其实就是 MAGIC 反过来写了而已. 因此这里需要了解一个概念, 就是大小端.
数据在内存中存储有2种形式, 一种是高数值在低内存, 另外一种相反则是低数值在低内存, 说起来比较抽象, 直接以 0xabcdef12 来比较大小端存储情况:
// 大端:
// | 0x00000 | ab | <-- 最大的数字在低位
// | 0x00008 | cd |
// | 0x00010 | ef |
// | 0x00018 | 12 |
// 小端:
// | 0x00000 | 12 | <-- 最小的数字在低位
// | 0x00004 | ab |
// | 0x00008 | cd |
// | 0x0000c | ef |
大小端在不同的架构上是不一样的, 后续我们在分析 unsign的时候里面也会涉及到.
所以我们来看FAT_MAGIC 和 FAT_CIGAM 的宏定义:
#define FAT_MAGIC 0xcafebabe
#define FAT_CIGAM 0xbebafeca /* NXSwapLong(FAT_MAGIC) */
FAT_CIGAM就是把大端变成了小端.
我们继续看代码, 分析对 fat 要做的事情:
// fh[1]是跳过了 fat_header, 直接到后面的 fat_arch 信息部分,
// &fh[1]取到第一个 fat_arch 的地址, 强转为 struct fat_arch
struct fat_arch* arch = (struct fat_arch*) &fh[1];
NSLog(@"FAT binary!\n");
int i;
// CFSwapInt32是转换大小端的, 这里有个问题是, 如果当前架构和大小端是匹配的, 这么转换一下是否正确?
// 这里暂且按下这个问题不表, 后续找 fat 格式测试即可. 这里的意图也很明显, 找到各个架构下的可执行文件信息, 然后判断是64位还是32位, 再调用各自实际执行注入的函数进行注入
for (i = 0; i < CFSwapInt32(fh->nfat_arch); i++) {
NSLog(@"Injecting to arch %i\n", CFSwapInt32(arch->cpusubtype));
if (CFSwapInt32(arch->cputype) == CPU_TYPE_ARM64) {
NSLog(@"64bit arch wow");
// CFSwapInt32(arch->offset)是告诉注入函数从哪里开始
inject_dylib_64(binaryFile, CFSwapInt32(arch->offset));
}
else {
inject_dylib(binaryFile, CFSwapInt32(arch->offset));
}
arch++;
}
后面对非 fat 格式的处理就更加简单了, 直接调用inject_dylib_64/inject_dylib即可, 但是源码有一个问题, 应该是作者手误, 看 github 上也有人提 issue 了:
case MH_CIGAM:
case MH_MAGIC:
{
NSLog(@"Thin 32bit binary!\n");
// inject_dylib_64(binaryFile, 0); // <-- 源代码是对32位也到了inject_dylib_64, 应该要改为下面这行
inject_dylib(binaryFile, 0);
break;
}
2.3 inject_dylib_64
注入到32位和64位并没有本质的不同, 所以这里以64位为例.
// 这里叫 newFile 是因为要对可执行文件进行修改了
void inject_dylib_64(FILE* newFile, uint32_t top) {
@autoreleasepool {
// 文件指针定位到对应架构的起始位置
fseek(newFile, top, SEEK_SET);
struct mach_header_64 mach;
// 从文件中读出头信息
fread(&mach, sizeof(struct mach_header_64), 1, newFile);
这里出现了我们刚刚看到过的mach_header_64, 在这里我们需要关注的有以下两个变量:
uint32_t ncmds; /* load commands的数量 */
uint32_t sizeofcmds; /* 所有load commands的大小 */
如果我们要注入的话, 势必需要修改这2个值, 然后再写入到文件中.
下面就是开始准备写入的数据:
// 准备注入的数据, 也就是动态库地址
NSData* data = [DYLIB_PATH dataUsingEncoding:NSUTF8StringEncoding];
// dylib_size 为dylib_command+name 路径的字符串
// 获取动态库的尺寸, 对齐到8个字节, b_round就是计算对齐后的大小, 源代码只有4行, 比较简洁.
unsigned long dylib_size = sizeof(struct dylib_command) + b_round(strlen([DYLIB_PATH UTF8String]) + 1, 8); // 会有人好奇为什么+1吗?
从这里我们知道, Mach-O 文件中, 动态库信息其实就是一个地址, 这点我们也可以从 MachOView 中看出来:
dylibPath.png开始修改:
// commands数量加1
mach.ncmds += 0x1;
// 记录原来的大小, 后面要跳转文件指针
uint32_t sizeofcmds = mach.sizeofcmds;
// commands的 size 增加
mach.sizeofcmds += (dylib_size);
// 回到 mach_header 的起始位置, 准备覆盖头部信息
fseek(newFile, -sizeof(struct mach_header_64), SEEK_CUR);
// 覆盖原本的mach_header头部信息
fwrite(&mach, sizeof(struct mach_header_64), 1, newFile);
// 跳过已有的load commands 部分
fseek(newFile, sizeofcmds, SEEK_CUR);
// 从文件中读出dylib_command结构体, 按道理应该不太需要的...
struct dylib_command dyld;
fread(&dyld, sizeof(struct dylib_command), 1, newFile);
这里出现了struct dylib_command, 我们看看它的结构:
struct dylib_command {
uint32_t cmd; /* dylib 的类型, 有LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB, LC_REEXPORT_DYLIB */
uint32_t cmdsize; /* dylib_command 的 size, 包括pathName字符串的size */
struct dylib dylib; /* dylib 信息*/
};
struct dylib {
union lc_str name; /* 动态库的路径名, 是个 union, 是 offset 或者 char*指针 */
uint32_t timestamp; /* 动态库构建的时间戳 */
uint32_t current_version; /* 动态库当前版本号 */
uint32_t compatibility_version; /*兼容版本号 */
};
了解了上面的基本结构后, 下面开始组织 dylib_command:
// 开始组织注入的 dylib 信息
dyld.cmd = LC_LOAD_DYLIB;
dyld.cmdsize = (uint32_t) dylib_size;
dyld.dylib.compatibility_version = DYLIB_COMPATIBILITY_VERSION;
dyld.dylib.current_version = DYLIB_CURRENT_VER;
dyld.dylib.timestamp = 2;
dyld.dylib.name.offset = sizeof(struct dylib_command);
// 回退并覆盖
fseek(newFile, -sizeof(struct dylib_command), SEEK_CUR);
fwrite(&dyld, sizeof(struct dylib_command), 1, newFile);
// 写入 path 信息
fwrite([data bytes], [data length], 1, newFile);
// 后续输出一些结果信息
NSLog(@"size %lu", sizeof(struct dylib_command) + [data length]);
char buffer[4096];
fread(&buffer, 4096, 1, newFile);
printf("%s", buffer);
到这里 yololib 的代码就分析完毕了, 我们也对 Mach-O 的文件结构有了基本的了解, 也知道了怎么去增加一个 load command, 后续再更新 unsign 的代码, 那里会讲怎么 正确删除一个 load command.
问题: 在这里我有一个问题, 还没找到答案, 如何保证注入的时候不会抹掉旧的数据? 也就是说, 假如 Load Commands 区域和下面的 Section 挨的很近, 剩余空间并不足以注入一个 dylib信息, 这个时候难道不会抹掉下面的 section 信息, 导致整个可执行文件被破坏?
(未完待续...)
网友评论