iOS混淆与反混淆:一场猫鼠游戏
各位同学,今天我们来聊聊iOS逆向中一个绕不开的话题——混淆与反混淆。说实话,这玩意儿就像加密和解密,永远在互相博弈。我在做安全评估的时候,经常遇到一些App,表面上看代码结构清晰,一用class-dump,类名方法名全暴露了。但有些App,你dump出来一看,全是a、b、c、d这种名字,甚至方法体里还有一堆垃圾代码,读起来简直想砸电脑。
为什么会这样?因为开发者做了混淆。说白了,混淆就是给你的代码“化妆”,让逆向工程师认不出来。但化妆技术有好有坏,有些只是涂了个口红,有些直接戴了面具。今天我就带大家看看,iOS上常见的混淆手段,以及怎么把它们“卸妆”。
核心观点:混淆不是万能的,但不混淆是万万不能的。对于核心算法库,混淆是基本操作。
Objective-C代码混淆:类名与方法名的“易容术”
Objective-C是一门动态语言,它的消息机制依赖于运行时。这意味着,类名、方法名、协议名这些符号,在编译后的二进制里是明文字符串。你想想看,class-dump为什么能还原出几乎完整的头文件?就是因为它读取了这些字符串。
所以,最基础的混淆就是改名字。把LoginManager改成a1b2c3,把- (BOOL)loginWithUsername:(NSString *)username password:(NSString *)password;改成- (BOOL)qwert:(id)arg1 asdf:(id)arg2;。嗯,就是这么粗暴。
我在项目中遇到过一种情况:某金融App的支付模块,所有类名都改成了类似NSObject的子类命名风格,比如_TtC8AppName18PaymentManager。乍一看还以为是Swift的产物,实际上就是Objective-C做了混淆。这种混淆方式,class-dump虽然能dump出来,但可读性极差。
个人经验:混淆类名时,建议保留一些系统前缀的假象,比如用UI、NS开头,让逆向工程师误以为是系统类,从而忽略掉。我曾经见过一个App,把所有业务类都改成了UIView的子类命名风格,结果我花了半小时才意识到被骗了。
字符串加密:保护敏感信息的最后防线
类名和方法名混淆了,但字符串呢?API地址、密钥、加密算法名称,这些硬编码在代码里的字符串,如果不加密,等于白混淆。因为逆向工程师可以直接用strings命令或者Hopper搜索字符串,找到关键逻辑。
字符串加密的原理很简单:在编译时把字符串加密成一段乱码,运行时再解密。常见的做法有:
- XOR加密:最简单,性能最好,但容易被猜出密钥
- AES加密:安全性高,但解密有性能开销
- 自定义算法:比如把字符串拆成多段,运行时拼接
举个例子,一个未加密的字符串是这样的:
NSString *url = @"https://api.example.com/v1/login";
加密后,代码变成这样:
// 加密后的数据
unsigned char encrypted_data[] = {0xAB, 0xCD, 0xEF, ...};
NSString *url = decryptString(encrypted_data, sizeof(encrypted_data));
你看,这样在二进制里就找不到明文字符串了。但要注意,decryptString这个函数本身不能太明显,否则逆向工程师直接hook这个函数,就能拿到所有解密后的字符串。
避坑指南:我曾经见过一个App,字符串加密做得很好,但解密函数的名称叫decryptStringForNetwork。结果逆向工程师直接在这个函数下断点,所有API地址一览无余。所以,解密函数的名字也要混淆,最好内联到调用处。
LLVM Obfuscator:编译器级别的混淆
前面说的都是代码层面的混淆,但还有更狠的——在编译器层面做混淆。LLVM Obfuscator就是一个基于LLVM的混淆工具,它可以在编译中间表示(IR)层面插入垃圾代码、控制流平坦化、虚假控制流等。
说白了,它不是在改你的源代码,而是在改编译器生成的中间代码。这样,你反编译出来的汇编代码会变得极其复杂,难以分析。
LLVM Obfuscator主要提供以下几种混淆:
| 混淆类型 | 原理 | 效果 |
|---|---|---|
| 控制流平坦化 | 把函数的所有基本块放到一个switch-case结构中 | 破坏原有的控制流图,难以理解逻辑 |
| 虚假控制流 | 插入永远不会执行的条件分支 | 增加反编译器的负担,产生大量死代码 |
| 指令替换 | 把简单指令替换成等价的复杂指令序列 | 增加汇编代码的阅读难度 |
| 垃圾代码插入 | 随机插入无意义的指令 | 膨胀代码体积,干扰分析 |
我在逆向一个使用了LLVM Obfuscator的算法库时,差点崩溃。一个原本只有50行的函数,反编译出来有2000多行,而且全是switch-case跳转。你想想看,读这种代码是什么体验?
反混淆工具:class-dump-z 与 Obfuscator-LLVM逆向
有矛就有盾。针对不同的混淆手段,社区也开发了相应的反混淆工具。
class-dump-z:对抗类名混淆
class-dump-z是class-dump的增强版,它不仅能dump出类名和方法名,还能尝试恢复一些被混淆的符号。它的原理是分析方法的调用关系,通过参数类型和返回值类型,推测出方法的原始含义。
举个例子,如果有一个方法- (id)abc:(id)arg1,它被调用的地方都传入了NSString类型的参数,并且返回值被赋值给一个NSDictionary变量,那么class-dump-z可能会把它重命名为- (NSDictionary *)dictionaryFromString:(NSString *)string。
当然,这种恢复不是100%准确的,但至少能给你一些线索。
Obfuscator-LLVM逆向:对抗控制流平坦化
对于LLVM Obfuscator的控制流平坦化,反混淆的思路是:分析switch-case结构,找出真正的执行路径,去掉虚假分支。常用的工具有:
- deflat:一个Python脚本,可以恢复被平坦化的控制流
- Unicorn Engine:模拟执行,动态跟踪真实的执行路径
- angr:符号执行,自动分析所有可能的路径
我个人习惯先用deflat做静态分析,如果效果不好,再用Unicorn做动态跟踪。为什么?因为静态分析快,但遇到复杂的混淆可能失效;动态分析准确,但需要构造输入触发所有路径。
小技巧:对于LLVM Obfuscator的虚假控制流,有一个简单的判断方法:如果某个条件分支的判断条件是一个常量(比如if (1 == 0)),那它一定是虚假分支,直接删掉就行。我在逆向一个游戏引擎时,就是用这个方法,把代码量减少了60%。
实战:还原一个混淆的算法库
光说不练假把式。我们来走一遍实战流程,看看怎么还原一个被混淆的算法库。
假设我们拿到一个iOS的Mach-O文件,里面有一个加密算法库。我们的目标是还原出它的核心算法逻辑。
第一步:初步分析
先用class-dump-z导出头文件。如果类名被混淆了,你会看到一堆a1、b2这样的名字。别慌,先看看有哪些类,大概猜一下功能。比如,如果有类名包含Encrypt、Decrypt、Key这些关键词,那基本就是加密相关。
第二步:字符串分析
用strings命令搜索二进制文件,看看有没有明文字符串。如果所有字符串都是乱码,说明做了字符串加密。这时候,你需要找到解密函数。通常,解密函数会在+load或+initialize方法中被调用,或者通过__attribute__((constructor))在加载时执行。
第三步:反编译与动态调试
用Hopper或IDA Pro打开二进制文件,找到核心函数。如果函数体被控制流平坦化了,你会看到一个巨大的switch-case结构。这时候,用deflat脚本尝试恢复。如果恢复失败,就用lldb动态调试,在关键函数下断点,单步跟踪,记录真实的执行路径。
第四步:手动还原
把动态跟踪得到的执行路径整理出来,去掉垃圾代码和虚假分支,剩下的就是核心逻辑。然后,根据参数类型和返回值类型,给变量和函数重新命名。比如,一个函数接收NSData *和NSString *,返回NSData *,那它很可能是encryptData:withKey:。
下面是一个简单的流程图,展示了整个还原过程:
你看,整个流程并不复杂,但需要耐心。我记得有一次,为了还原一个AES加密的密钥派生函数,我花了整整两天时间。最后发现,所谓的“自定义算法”其实就是标准的PBKDF2,只是参数被混淆了。嗯,有时候就是这样,绕了一大圈,发现人家用的就是标准算法。
总结一下:混淆是保护代码的第一道防线,但不是不可逾越的。对于类名和方法名混淆,class-dump-z能帮上大忙;对于字符串加密,动态调试是王道;对于LLVM Obfuscator,需要结合静态分析和动态跟踪。记住,没有绝对安全的混淆,只有相对难啃的骨头。
好了,这一章的内容就到这里。下一章我们会深入探讨iOS的运行时机制,看看怎么利用Method Swizzling做更高级的逆向分析。各位同学,回去把今天讲的工具装好,找个App练练手。实践出真知,光看不动手,永远学不会。