47、Mach-O文件修改实战:修改加载命令、动态库注入、fishhook使用
说实话,Mach-O文件格式是iOS逆向绕不开的坎。你想想看,无论是做越狱开发、应用加固,还是搞点“小动作”,最终都要落到对Mach-O的修改上。今天这一讲,我带你手把手过一遍三个核心操作:修改加载命令、动态库注入、以及fishhook的使用。嗯,都是实战中高频用到的东西。
Mach-O文件结构速览
先简单回顾下Mach-O的结构。它主要由三部分组成:
- Header:文件头,标识CPU类型、文件类型、加载命令数量等。
- Load Commands:加载命令区,告诉dyld怎么加载这个二进制。比如依赖哪些动态库、入口地址在哪、段信息等。
- Data:真正的代码和数据,按Segment组织(__TEXT, __DATA, __LINKEDIT等)。
我们今天要动刀的地方,主要就是Load Commands和__DATA段。
核心思路:修改Mach-O本质上就是二进制级别的“增删改查”。你不需要每次都从零写解析器,用现成的工具(比如jtool、MachOView、或者自己写Python脚本)就能搞定。
实战一:修改加载命令
为什么要改加载命令?最常见的场景是:你想让一个App加载你写的动态库,或者你想修改它的依赖库路径。举个例子,我在项目中遇到过需要把某个App的LC_LOAD_DYLIB指向的旧库换成新版本,但不想重新签名整个包——直接改Mach-O里的路径就行。
操作步骤
- 定位加载命令:用MachOView打开二进制,找到LC_LOAD_DYLIB或LC_LOAD_WEAK_DYLIB。
- 修改路径:直接改字符串内容。注意新路径长度不能超过原路径,否则会破坏后续命令的偏移。
- 更新偏移:如果路径长度变了,需要调整后续所有加载命令的偏移量。这一步容易出错,我建议用工具自动处理。
我的习惯:用Python的macholib库来修改。它封装好了偏移计算,比手撸二进制安全得多。
# 示例:修改动态库路径
from macholib.MachO import MachO
from macholib.mach_o import LC_LOAD_DYLIB
macho = MachO('YourApp')
for header in macho.headers:
for idx, (lc, cmd, data) in enumerate(header.commands):
if lc.cmd == LC_LOAD_DYLIB:
old_path = data.name.decode('utf-8')
new_path = '/usr/lib/libCustom.dylib'
# 确保新路径长度 <= 原路径长度
if len(new_path) <= len(old_path):
data.name = new_path.encode('utf-8').ljust(len(old_path), b'\x00')
print(f'替换: {old_path} -> {new_path}')
macho.write('YourApp_patched')
注意:修改后必须重新签名,否则iOS会拒绝运行。我吃过这个亏,改完直接闪退,查了半天才发现是签名问题。
实战二:动态库注入
动态库注入是逆向分析的“瑞士军刀”。你想hook某个函数、监控网络请求、或者注入调试代码,都可以通过往Mach-O里加一个LC_LOAD_DYLIB命令来实现。
注入原理
说白了,就是在加载命令区末尾追加一条LC_LOAD_DYLIB,指向你的dylib。dyld在加载主二进制时,会顺带把你的库也加载进来,然后执行它的构造函数(__attribute__((constructor)))。
手动注入步骤
- 准备好你的dylib(编译成fat binary,支持arm64)。
- 用工具(比如
insert_dylib)把LC_LOAD_DYLIB命令插入到Mach-O中。 - 把dylib放到App的Frameworks目录下,或者用install_name_tool修改路径。
- 重新签名。
# 使用insert_dylib工具(越狱环境常用)
insert_dylib --inplace --all-yes @executable_path/Frameworks/MyTweak.dylib YourApp
避坑指南:我曾经在非越狱设备上尝试注入,结果发现iOS 13+对动态库加载限制很严。非越狱环境下,你只能注入到已经加载了你的dylib的进程,或者通过Framework注入(需要修改Info.plist)。说白了,越狱环境更自由,非越狱环境需要配合其他手段。
实战三:fishhook使用
fishhook是Facebook开源的一个工具,专门用来hook Mach-O中的C函数。它的原理很巧妙:通过修改__DATA段的懒加载符号表指针,把系统函数调用重定向到你的函数。
为什么需要fishhook?
你想想看,Objective-C的method swizzling只能hook OC方法,但系统底层很多函数是C写的(比如open、write、dlopen)。fishhook就是用来干这个的。
典型用法:hook open函数
#import "fishhook.h"
// 保存原始函数指针
static int (*original_open)(const char *, int, ...);
// 替换函数
int my_open(const char *path, int flags, ...) {
NSLog(@"拦截到open调用: %s", path);
// 可以在这里做过滤或记录
return original_open(path, flags);
}
// 在初始化时绑定
__attribute__((constructor))
void setup() {
struct rebinding rebind;
rebind.name = "open";
rebind.replacement = my_open;
rebind.replaced = (void *)&original_open;
rebind_symbols((struct rebinding[1]){rebind}, 1);
}
个人经验:fishhook只能hook那些通过动态链接解析的符号。如果函数是静态链接进二进制里的,fishhook就无能为力了。我在项目中遇到过这种情况,最后只能改用inline hook。
知识体系图
下面这张图梳理了Mach-O修改的核心流程和工具链,方便你对照理解:
综合实战:注入dylib并用fishhook拦截系统调用
把上面三个技术串起来,就是一个完整的逆向工具链。我举个例子:
- 写一个dylib,里面用fishhook hook了
NSLog函数,把所有日志输出重定向到文件。 - 用
insert_dylib把这个dylib注入到目标App的Mach-O里。 - 重新签名,运行App,所有日志都被你捕获了。
// dylib 核心代码
#import "fishhook.h"
static void (*original_NSLog)(NSString *format, ...);
void my_NSLog(NSString *format, ...) {
va_list args;
va_start(args, format);
NSString *message = [[NSString alloc] initWithFormat:format arguments:args];
// 写入文件
NSData *data = [[message stringByAppendingString:@"\n"] dataUsingEncoding:NSUTF8StringEncoding];
[data writeToFile:@"/tmp/log.txt" atomically:YES];
va_end(args);
// 仍然调用原函数
original_NSLog(@"%@", message);
}
__attribute__((constructor))
void init() {
struct rebinding rb = {"NSLog", my_NSLog, (void *)&original_NSLog};
rebind_symbols(&rb, 1);
}
重要提醒:在非越狱设备上,注入dylib需要绕过代码签名和AMFI(Apple Mobile File Integrity)。常用的方法包括:利用Xcode调试器附加进程后注入、或者通过Framework注入配合修改Info.plist。越狱设备上则简单得多,直接用Cydia Substrate或libhooker就行。
总结
Mach-O修改是iOS逆向的硬功夫。我个人觉得,掌握这三个操作后,大部分动态分析需求都能覆盖了。修改加载命令让你能控制dyld的行为,动态库注入给了你代码执行的能力,fishhook则让你能拦截系统函数调用。三者配合,基本上可以“为所欲为”了。
嗯,最后说一句:动手实践比看一百遍文章都管用。找个App练练手,从修改一个加载命令开始,慢慢你就会发现Mach-O其实没那么神秘。