27、移动应用逆向(iOS):IPA结构、Mach-O分析、Theos与Cycript、越狱检测绕过
iOS逆向,说实话,比Android要「封闭」得多。但封闭不代表不能搞。我这些年做iOS安全评估,最深的感受就是:你只要搞懂了IPA的皮、Mach-O的骨、以及越狱检测的套路,大部分App在你面前就是透明的。今天我们就把这几个核心点掰开揉碎讲清楚。
一、IPA结构:App的「压缩包」里到底藏了什么?
IPA本质上就是个zip包。你把它后缀改成.zip,解压就能看到内部结构。我刚开始学逆向时,以为IPA里就一个二进制文件加一堆资源,后来发现远不止这么简单。
一个典型的IPA解压后,你会看到这样的目录结构:
Payload/
└── WeChat.app/
├── WeChat # 主Mach-O可执行文件
├── Info.plist # 应用元信息
├── embedded.mobileprovision # 描述文件(签名信息)
├── Frameworks/ # 动态库(.dylib或.framework)
├── PlugIns/ # 扩展插件(如Today Widget)
├── _CodeSignature/ # 代码签名目录
└── Assets.car # 资源文件(图标、图片等)
这里我重点提几个关键文件:
- Info.plist:App的身份证。里面藏着Bundle ID、版本号、权限声明(如摄像头、定位)、以及是否允许JIT等关键开关。我曾在Info.plist里发现过硬编码的API密钥——开发者以为没人会解包看这个文件。
- embedded.mobileprovision:描述文件。它决定了这个App能在哪些设备上跑、能用哪些服务。越狱检测绕过时,有时需要修改这个文件里的设备UDID列表。
- _CodeSignature/CodeResources:签名校验文件。App启动时系统会校验这个目录里的哈希值。如果你改了Mach-O文件但没重签名,App直接闪退。
UIBackgroundModes和LSApplicationQueriesSchemes。前者能告诉你App在后台偷偷干了什么,后者能暴露它调用了哪些第三方App。
二、Mach-O分析:iOS可执行文件的「骨架」
Mach-O是iOS和macOS的可执行文件格式。说白了,它就是一堆二进制数据,但系统靠它知道「这个程序该怎么加载、代码在哪儿、依赖哪些库」。
一个Mach-O文件的结构大致如下:
+------------------+
| Mach-O Header | ← 魔数、CPU类型、文件类型
+------------------+
| Load Commands | ← 告诉加载器:代码段在哪、依赖哪些库
+------------------+
| __TEXT 段 | ← 代码段(只读,可执行)
| __text | 实际指令
| __cstring | 字符串常量
| __const | 常量数据
+------------------+
| __DATA 段 | ← 数据段(可读写)
| __data | 全局变量
| __objc_* | Objective-C运行时信息
| __cfstring | CFString常量
+------------------+
| __LINKEDIT | ← 链接信息(符号表、字符串表)
+------------------+
我个人最常用的是otool和MachOView这两个工具。举个例子,你想看一个App依赖了哪些动态库:
otool -L WeChat
# 输出示例:
WeChat:
/usr/lib/libSystem.B.dylib
/System/Library/Frameworks/UIKit.framework/UIKit
/System/Library/Frameworks/Foundation.framework/Foundation
@rpath/AFNetworking.framework/AFNetworking
...
这里有个坑:如果App用了@rpath开头的库路径,说明它加载的是Frameworks目录下的动态库。我曾在逆向一个金融App时,发现它把核心加密逻辑放在了一个自定义的动态库里,然后通过@rpath加载。你只要把这个动态库dump出来,就能直接分析它的加密算法。
__objc_* section里存着所有Objective-C的类名、方法名、协议名。这意味着——只要App是用OC写的,你就能通过class-dump或nm命令导出它的所有接口。Swift写的App虽然会混淆符号,但依然可以通过__swift_* section找到部分信息。
三、Theos与Cycript:越狱环境下的「瑞士军刀」
这两个工具,是我做iOS逆向时最离不开的。一个用来写Tweak(插件),一个用来动态调试。
3.1 Theos:写Tweak的脚手架
Theos本质上是一个Makefile系统,帮你自动处理编译、签名、打包。你只需要写一个.xm文件,里面用Logos语法hook住目标方法就行。
举个例子,我想hook住微信的-[WeChatAppDelegate application:didFinishLaunchingWithOptions:]方法,在启动时弹个窗:
%hook WeChatAppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
%log; // 打印调用日志
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Hooked!"
message:@"启动成功"
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil];
[alert show];
return %orig; // 调用原始方法
}
%end
然后执行make package install,Tweak就会被安装到越狱设备上。我当年第一次成功hook住一个系统App时,那种感觉就像打开了新世界的大门。
.deb包,需要越狱设备才能安装。如果你没有越狱设备,可以用frida-ios-dump配合Frida来做类似的事情,但Theos在写复杂逻辑时更顺手。
3.2 Cycript:运行时「手术刀」
Cycript是JavaScript和Objective-C的混合体。你可以在越狱设备上通过SSH连接,然后动态修改App的内存、调用任意方法、甚至替换实现。
我常用的几个命令:
# 附加到微信进程
cycript -p WeChat
# 查看当前UI层级
UIApp.keyWindow.recursiveDescription().toString()
# 找到某个类的所有实例
var instances = [UIApplication sharedApplication].delegate
# 动态调用方法
[instances performSelector:@selector(somePrivateMethod)]
# 替换方法实现(Hook)
var orig = MyClass.messages['- originalMethod'];
MyClass.messages['- originalMethod'] = function(self, cmd) {
console.log("Hooked!");
return orig(self, cmd);
};
我记得有一次,一个App在启动时弹了个「网络异常」的对话框,但明明网络是通的。我用Cycript hook了UIAlertView的show方法,打印出调用栈,发现是某个私有方法里写死了判断条件。改掉那个条件后,App就正常了。
frida-trace和frida-ps来做动态调试,Cycript更适合做快速验证。
四、越狱检测绕过:一场「猫鼠游戏」
越狱检测,说白了就是App检查设备上有没有越狱痕迹。常见的检测点有:
| 检测类型 | 检测方法 | 绕过思路 |
|---|---|---|
| 文件检测 | 检查/Applications/Cydia.app、/bin/bash等是否存在 |
用MSHookFunction hook住access()或stat(),返回假值 |
| 沙盒检测 | 尝试fork子进程,看是否成功 | hook fork(),返回-1并设置errno |
| 动态库检测 | 检查dlopen()是否能加载越狱相关dylib |
hook dlopen(),对特定路径返回NULL |
| 符号检测 | 通过dlsym()查找越狱工具的函数地址 |
hook dlsym(),对敏感符号返回NULL |
| 环境变量检测 | 检查DYLD_INSERT_LIBRARIES是否被设置 |
在Tweak启动时清空该环境变量 |
我见过最狠的检测方式,是在__attribute__((constructor))里做检测。这意味着App还没进入main函数,就已经开始检查了。如果你用常规的MSHookFunction去hook,可能根本来不及——因为你的Tweak加载时,检测代码已经跑完了。
怎么破?我的经验是:用fishhook在更早的阶段hook。fishhook可以重绑定Mach-O的懒加载符号表,在系统库函数被调用之前就替换掉。比如:
// 在Tweak的构造函数里,提前hook access()
static int (*orig_access)(const char *, int);
int my_access(const char *path, int amode) {
if (strstr(path, "Cydia") || strstr(path, "bash")) {
return -1; // 假装文件不存在
}
return orig_access(path, amode);
}
__attribute__((constructor))
void init() {
// 用fishhook重绑定access
rebind_symbols((struct rebinding[]){{"access", my_access, (void**)&orig_access}}, 1);
}
__attribute__((constructor))和fishhook是你的好朋友。
五、知识体系总览
下面这张图,是我自己总结的iOS逆向知识体系。你可以把它当作一个「地图」,每次遇到新App时,按这个流程走一遍:
嗯,这张图基本覆盖了iOS逆向的完整链路。你从左上角开始,拿到IPA后解包,然后分析Mach-O,接着根据需求选择静态或动态分析,最后处理越狱检测。每一步都有对应的工具和技巧。
我个人建议:新手先别急着写Tweak,先把Mach-O分析透。你只要能用otool和class-dump把App的类结构摸清楚,后面写Tweak就是水到渠成的事。