看开源库了解 Mach-O文件

这篇文章算是对前面那篇动态注入 dylib 到 Mac 应用的一个细节补充, 主要针对使用的开源库yololib和之后要使用到的 unsign 进行源码分析, 在这个基础上对 Mach-O 进行一些初步剖析, 从代码的角度解释这两个库是怎么做的以及为什么可以这么做.

一. 准备工作:

  1. yololib 源代码: github 地址
  2. unsign 源代码: github 地址
  3. C 语言基本语法及文件操作
  4. MachOView, 搜索引擎搜一下就可以搜到
  5. Mach-O 文件基本结构:
    5.1 FAT 格式:


    FAT 文件布局(图片源自 google 搜索)

    5.2 非 FAT 格式(一般 Mach-O 格式):

一般 Mach-O 文件布局(图片源自 google 搜索)

二. 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 格式和64位格式的可执行文件

那么这里就有一个疑问了, 如果不是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 信息, 导致整个可执行文件被破坏?

(未完待续...)

推荐阅读更多精彩内容