曾静的博客

但行好事,莫问前程.

嗨,我是曾静 (@devzeng),目前暂居深圳。


这是我用来记录平日学习笔记的地方,欢迎您的访问.

iOS使用optool注入动态库

年初为排查性能问题写了一个动态获取函数调用耗时的工具(Kirin)。工具主要包括两个部分:macOS应用,负责发送采集指令,格式化数据生成 Chrome Trace Viewer 用到的 Trace Event Format 格式的数据;framework动态库,嵌入到App里,负责接收指令,采集耗时数据。

使用的时候是直接将framework拖到项目里面运行项目就可以了,但是实际场景下Debug阶段可以通过Instrument更加的方便大多数时候需要定位生产版本的问题,就需要将现有的ipa包能动态将framework注入,然后将动态库拷贝到 Payload 的 Frameworks 目录下面,最后重签名后再安装到手机上进行测试。

关于动态注入framework这部分,使用的是 optool 这个工具,使用起来比较简单:

(1)下载源码

git clone https://github.com/alexzielenski/optool.git 
cd optool
git submodule update --init --recursive

(2)编译可执行文件

cd optool
xcodebuild -project optool.xcodeproj -configuration Release ARCHS="x86_64 arm64" build

build/Release/optool 拷贝出来。

(3)执行下面的命令可以注入动态库

export PATH="${pwd}/bin:$PATH"
optool install -c load -p "@rpath/Kirin.framework/Kirin" -t Payload/Demo.app/Demo

如果需要移除对应的库可以使用:

optool uninstall -p "@rpath/Kirin.framework/Kirin" -t Payload/Demo.app/Demo

命令执行完成后,可以通过下面的命令查看framework注入/移除的情况:

otool -L Payload/Demo.app/Demo

大概的结果示例如下:

Payload/Demo.app/Demo
    ....
    @rpath/Kirin.framework/Kirin (compatibility version 0.0.0, current version 0.0.0)

因为 optool 项目是开源的,而且核心实现就一个文件不到600行代码,其中 install 这部分的代码的核心实现在

BOOL insertLoadEntryIntoBinary(NSString *dylibPath, NSMutableData *binary, struct thin_header macho, uint32_t type);

对照命令的使用和实现简单分析一下背后的实现机制:

-p @rpath/Kirin.framework/Kirin 表示要加载的动态库路径

-t Payload/Demo.app/Demo 二进制文件的路径

-c load 表示的指定使用的 Load Command 类型为 LC_LOAD_DYLIB,可选的类型有:

  • (1) load (LC_LOAD_DYLIB): 表示加载 dylib

  • (2) weak (LC_LOAD_WEAK_DYLIB):工作方式与LC_LOAD_DYLIB相同,但如果未找到 dylib,执行将继续而不会出现错误。

  • (3) reexport (LC_REEXPORT_DYLIB):代理(或重新导出)来自不同库的符号

  • (4) upward (LC_LOAD_UPWARD_DYLIB):在两个库相互依赖时使用(这称为 向上依赖)

由于仅支持四种 Load Command, 所以一开始就判断是否是上面四种 Load Command, 代码如下:

if (type != LC_REEXPORT_DYLIB &&
    type != LC_LOAD_WEAK_DYLIB &&
    type != LC_LOAD_UPWARD_DYLIB &&
    type != LC_LOAD_DYLIB) {
    LOG("Invalid load command type");
    return NO;
}

接下来判断我们要添加的 Load Command 是否存在,如果存在就直接返回

// parse load commands to see if our load command is already there
uint32_t lastOffset = 0;
if (binaryHasLoadCommandForDylib(binary, dylibPath, &lastOffset, macho)) {
    // there already exists a load command for this payload so change the command type
    uint32_t originalType = *(uint32_t *)(binary.bytes + lastOffset);
    if (originalType != type) {
        LOG("A load command already exists for %s. Changing command type from %s to desired %s", dylibPath.UTF8String, LC(originalType), LC(type));
        [binary replaceBytesInRange:NSMakeRange(lastOffset, sizeof(type)) withBytes:&type];
    } else {
        LOG("Load command already exists");
    }
        
    return YES;
}

如果不存在,表示是新添加的 Load Command,目前支持的四种 Load Command ,都可以用 dylib_command 结构体来定义。

unsigned int length = (unsigned int)sizeof(struct dylib_command) + (unsigned int)dylibPath.length;
unsigned int padding = (8 - (length % 8));

加载命令在 Mach-O 文件加载解析时,会被内核加载器或者动态链接器调用。这些指令都采用 Type-Size-Value 这种格式,即:32 位的 cmd 值(表示类型),32 位的 cmdsize 值(32 位二级制位 4 的倍数,64 位位 8 的倍数),以及命令本身(由 cmdsize 指定的长度)。

其中 dylib_command 的定义如下:

union lc_str {
	uint32_t offset; /* offset to the string */
	char *ptr; /* pointer to the string */
};

struct dylib {
    union lc_str name; /* library's path name */
    uint32_t timestamp; /* library's build time stamp */
    uint32_t current_version; /* library's current version number */
    uint32_t compatibility_version;	/* library's compatibility vers number*/
};

struct dylib_command {
	uint32_t cmd; /* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB, LC_REEXPORT_DYLIB */
	uint32_t cmdsize; /* includes pathname string */
	struct dylib dylib; /* the library identification */
};

在 MachOView 中呈现的结构如下所示:

lc-load-dylib

offset 指动态链接库字符串在 struct dylib_command 这个结构中的偏移

接下来就是判断 Load Command 段末尾是否还有空白位置

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;
}

无论是注入还是删除动态库链接,都不可以改变整个 Mach-O 文件的大小。这是因为在 Mach-O 文件中有很多内容是靠 offset 去定位的,如果改变了大小,那么那些 offset 就会失效,这样 Mach-O 文件就损坏了。所以,我们不可以添加或删除字节,只能覆写无用字节。因为 load_command 那块区域的尾部有一些全”\0”的字节区域(空白位置),所以,添加 dylib_command 就可以通过覆写这些”\0”字节完成。

创建 dylib_command ,并替换到对应位置的空白区域

struct dylib_command command;
struct dylib dylib;
dylib.name.offset = sizeof(struct dylib_command);
dylib.timestamp = 2; // load commands I've seen use 2 for some reason
dylib.current_version = 0;
dylib.compatibility_version = 0;
command.cmd = type;
command.dylib = dylib;
command.cmdsize = length + padding;

unsigned int zeroByte = 0;
NSMutableData *commandData = [NSMutableData data];
[commandData appendBytes:&command length:sizeof(struct dylib_command)];
[commandData appendData:[dylibPath dataUsingEncoding:NSASCIIStringEncoding]];
[commandData appendBytes:&zeroByte length:padding];
    
// remove enough null bytes to account of our inserted data
[binary replaceBytesInRange:NSMakeRange(macho.offset + macho.header.sizeofcmds + macho.size, commandData.length) withBytes:0 length:0];
// insert the data
[binary replaceBytesInRange:NSMakeRange(lastOffset, 0) withBytes:commandData.bytes length:commandData.length];

修正 header 内容,因为新增了一个 Load Command,所以 ncmds、sizeofcmds 要作对应的调整

// fix the existing header
macho.header.ncmds += 1;
macho.header.sizeofcmds += command.cmdsize;
    
// this is safe to do in 32bit because the 4 bytes after the header are still being put back
[binary replaceBytesInRange:NSMakeRange(macho.offset, sizeof(macho.header)) withBytes:&macho.header];    

以上就是 optool 注入一个动态库的大概流程。

参考资料

最近的文章

macOS Sequoia剪切板获取文件路径的问题

收到一个问题,说升级到 macOS Sequoia,客户端复制文件粘贴到程序中通过代码获取的路径是 file:///.file/id=xxxx,而不是实际文件路径。之前的版本是正常的。从剪切板获取文件路径的代码:void get_file_names(void) { NSPasteboard* pasteboard = [NSPasteboard generalPasteboard]; NSArray* tempArray = [pasteboard pasteboardIte...…

Note继续阅读
更早的文章

从Xcode中提取iPhone新机型信息

2024年9月10日,iPhone 16 系列正式发布,本次主要发布了iPhone 16、iPhone 16 Plus、iPhone 16 Pro和iPhone 16 Pro Max四款机型。每年发布新机型都需要提取一下iPhone的新机型信息,方便后续查询展示使用。现有的历史的数据是从Apple_mobile_device_types.txt 文件中提取的,新的数据的话就要更新之后才能获取到。去年发布的 iPhone 15 的时候是从 Xcode 本地数据库中获取的,最近想要查数据的时候...…

Note继续阅读