做iOS逆向的朋友应该都听过一句话:拿到App之后,第一步是砸壳。往实际里讲,App Store下载的应用,可执行文件默认带着FairPlay DRM加密,用otool随手看一眼,cryptid那一栏几乎都是1。壳不脱,后面所有静态分析都是在看加密后的天书。这篇文章是iOS安全和逆向系列的第18篇,专门聊iOS应用脱壳——从原理到工具链选型,再到一条能落地的完整流程,最后把我在实际调试里反复踩过的坑一起列出来。适合刚接触逆向、想彻底搞懂脱壳原理的新手,也适合需要一份可复现流程做样本分析的朋友。
先说明边界:脱壳这个技术本身是安全研究、漏洞分析、样本回溯的正常手段。请只对你拥有所有权或已获得明确授权的应用做测试,不要拿脱壳后的产物做任何分发、传播或商业用途。
1. 脱壳到底在脱什么:先弄懂加密与Mach-O
1.1 为什么官方商店应用会“带壳”
很多人把“脱壳”理解成“把App外层的壳程序干掉”,这个说法在iOS生态里其实不太准确。iOS应用并没有像安卓那种第三方加固壳公司提供的独立壳程序,它用的是系统级的FairPlay DRM方案。
具体过程可以简化成几步:开发者把App上传到官方应用商店,通过审核后,用户在设备上下载安装时,系统从商店拿到的并非完整明文可执行文件,而是经过加密处理的主程序二进制。系统在启动App时,会通过安全机制将加密内容解密到内存并映射执行。也就是说,在磁盘上躺着的是密文,在内存里跑起来的是明文。
这就给脱壳留下了最核心的攻击面:只要App能正常运行,它的内存里就存在一份可以被“回收”的明文镜像。脱壳工具本质上是做一件事情——把运行时内存里的可执行镜像抓出来,再按Mach-O格式重建一份静态可分析的文件。
现实里最常见的使用场景有三个:第一,分析自研App的签名和加密状态,看看上线包是否被做了额外保护;第二,做恶意样本回溯时,很多可疑App会带加密标志,不脱壳根本看不到代码逻辑;第三,学习系统加载流程,理解动态链接器如何处理加密段。除此之外的场景,我不建议碰。
1.2 加密标志位cryptid与Mach-O关键结构
如果你第一次接触Mach-O,可能会觉得它只是一堆段和节。但脱壳这件事里,只需要盯住一个关键结构:加密信息加载命令。
64位iOS应用一般带的是LC_ENCRYPTION_INFO_64,32位老应用带LC_ENCRYPTION_INFO。这个加载命令里包含三个核心字段:
- cryptoff:加密区域在文件中的起始偏移。
- cryptsize:加密区域的字节大小。
- cryptid:加密状态。0代表未加密,1代表已加密,2代表部分加密(比较少见)。
大多数情况下,被加密的区域就是__TEXT段那一整块,很多工具用看到的cryptsize来估算加密范围,所以这个值对后续重建Mach-O很重要。
查看加密状态最常用的命令是:
otool -l /path/to/MyApp | grep -A 4 LC_ENCRYPTION_INFO_64正常未脱壳应用你会看到类似:
cmd LC_ENCRYPTION_INFO_64 cmdsize 24 cryptoff 16384 cryptsize 10158080 cryptid 1如果cryptid是0,说明这个二进制本身就是明文,不需要再走脱壳流程。如果otool输出里没找到这条加载命令,说明二进制可能被工具处理过,或者架构本身不适合这个命令格式。
有一个新手容易踩的坑:32位老工具经常只打印LC_ENCRYPTION_INFO,而64位工具看的是LC_ENCRYPTION_INFO_64。有些分析软件还停留在只看LC_ENCRYPTION_INFO的逻辑上,导致cryptid显示不一致。以后看到工具给的结果互相矛盾,先检查架构和加载命令类型。
2. 环境准备与工具链选型:别一上来就开砸
2.1 越狱环境、系统版本与注入条件
脱壳最直接的路径还是越狱设备,因为需要注入动态库或者使用调试接口,这些操作在官方封闭环境里是禁止的。
不是所有设备、所有系统版本都能顺利越狱。A8到A11设备兼容性最广,可以支持的iOS版本跨越大;A12和更新处理器要看对应芯片的越狱方案是否已经适配到目标系统版本。我自己的经验是:如果只是做脱壳和分析,尽量保留一台系统版本不要太高、且能越狱的设备,灵活性会大很多。没必要追求最新系统,很多目标App依然支持旧版本系统运行。
越狱之后还需要两个基础条件:
- 设备上能安装并运行目标App。
- 设备上有注入或调试通道,常见的是安装frida-server,或者手动加载dylib。
如果你是rootless越狱环境,路径结构会有些变化,但注入原理一致。唯一要注意的是rootless环境下很多工具默认路径是/var/jb开头,别把文件拷错位置。
2.2 主流脱壳工具怎么选:frida-ios-dump、dumpdecrypted及其他
市面上的脱壳工具其实不算多,我把用过的主流方案整理成了一个对比表:
| 工具 | 实现原理 | 适用场景 | 维护状态 | 备注 |
|---|---|---|---|---|
| frida-ios-dump | 基于Frida注入目标进程,遍历内存镜像后重建Mach-O并打包成IPA | 日常最推荐的主力工具 | 更新比较活跃 | 对现代iOS适配好,操作简单 |
| dumpdecrypted | 编译一个dylib,通过DYLD_INSERT_LIBRARIES注入启动进程 | 老版本系统兜底、手动调试场景 | 基本停止维护 | 需要自己编译和签名,过程繁琐 |
| Clutch | 命令行批量脱壳,导出IPA | 老iOS(一般iOS 13以下) | 早已不更新 | 新系统基本不可用 |
| CrackerXI+ | 图形界面封装,集成多种脱壳方式 | 偶尔应急,图形化操作 | 时好时坏 | 容易受App自身越狱检测影响 |
| bagbak | 也是基于Frida的方案 | 喜欢命令行、想自己控制流程 | 小幅更新 | 输出格式与frida-ios-dump略有差异 |
选型建议很直接:新手和日常分析,先上手frida-ios-dump;如果遇到注入失败或者目标App有较强的反调试逻辑,再手动用dumpdecrypted做兜底;Clutch这种老工具就当作历史阶段了解即可,不要浪费时间去适配新版系统。
2.3 先确认目标App的加密状态
工具安装了,第一件事不是立刻脱壳,而是先确认手里的App到底是加密状态还是明文状态。有时候别人给你的越狱市场源下载版本,可能已经是被处理过的,就直接省掉一步。
先安装目标App,然后用下面命令找到它的二进制路径:
ps -A | grep MyApp输出会类似:
501 1234 5678 ? 09:12:00 0:01.23 /var/mobile/Containers/Bundle/Application/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/MyApp.app/MyApp最后的路径就是主二进制。如果进程里找不到,也可以先用:
find /var/mobile/Containers/Bundle/Application -name "*.app" -maxdepth 3 2>/dev/null然后再进到.app目录里去定位可执行文件。
接着用otool检查加密标志:
otool -l /var/mobile/Containers/Bundle/Application/XXXX/MyApp.app/MyApp | grep -A 4 LC_ENCRYPTION_INFO_64看到cryptid等于1,就可以进入实战流程了。
3. 实战:从定位进程到得到一份干净二进制
3.1 找到目标可执行文件的准确位置
这一步看似简单,但很多失败案例都是因为路径不对。App的容器路径是一串UUID,不是固定的,每次都重新用ps找一次最稳妥。用命令行的好处是能连空格、中文路径一起处理正确。
如果App有多个插件扩展,比如WidgetExtension、ShareExtension,每个扩展都有自己的二进制,也可能带独立的加密标志位。但常规情况我们不一个个脱,主程序二进制搞定就够用了。真正需要分析第三方库或者插件逻辑时,再针对性处理对应扩展。
有一个技巧:用ps -A如果只看到进程名没看到完整路径,可以先运行一下App,再执行ps -A | grep 进程名,路径大概率就出来了。
3.2 实战A:frida-ios-dump一键脱壳
先说准备条件。
手机端需要安装frida-server。把对应设备架构的文件拷到设备上,赋予执行权限,然后启动:
./frida-server -D &电脑端装好Frida命令行工具:
pip install frida-tools验证连接:
frida-ps -U看到设备进程列表就说明通信正常。
然后准备frida-ios-dump脚本,它能自动完成“启动App、注入、遍历内存镜像、重建Mach-O、打包IPA”这一整套动作。常用命令是:
python dump.py MyApp如果显示名称和进程名不一致,可以加参数指定:
python dump.py -p MyAppProcessName执行成功之后,当前目录下会出现一个MyApp.ipa,里面就是脱壳后的可执行文件加资源文件。
这个工具的原理值得说透:Frida在目标进程里建立了一条RPC通道,脚本在被注入的进程内遍历所有内存段,拿到每一段的地址、大小、偏移和权限位,再参照磁盘上的原Mach-O头信息,重新生成一份新文件。相比粗暴地整块dump内存,这种方案能保留更完整的加载命令和段布局,所以分析起来失真更小。
使用中的几个要点:
- 手机和电脑通过USB连接时,推荐先做端口转发,让Frida走USB通道,比远程SSH更稳定。
- frida-server版本必须和电脑端frida版本匹配,差太多会出现协议不兼容,表现为连接后卡住。
- App如果在启动过程就崩溃或被终止,无法注入成功,需要先解决进程存活问题。
3.3 实战B:dumpdecrypted手动注入脱壳
frida-ios-dump是自动挡,dumpdecrypted就是手动挡。在自动化工具失灵时,这个手动流程反而能救命。
先把源码下载下来,在本地编译。Makefile里的关键项是指定iOS SDK版本和架构。现代macOS上一般用这套流程:
make编译前确认Xcode命令行工具都装好,xcrun --sdk iphoneos --show-sdk-path能输出SDK路径。如果Makefile里写死了旧的SDK版本,改成当前路径再编。
编译得到dumpdecrypted.dylib后,需要先处理签名。越狱设备上很多注入场景要求动态库有合法或一致的签名,没签名直接注入可能会被拒。常见做法是:
ldid -S dumpdecrypted.dylib把dylib拷到设备上的临时目录,比如/var/mobile/tmp。
然后在越狱终端环境变量方式启动目标App的可执行文件:
export DYLD_INSERT_LIBRARIES=/var/mobile/tmp/dumpdecrypted.dylib export DYLD_FORCE_FLAT_NAMESPACE=1 /var/mobile/Containers/Bundle/Application/XXXX/MyApp.app/MyApp注意,这里直接执行的是磁盘上的主二进制路径,不是通过点击桌面图标启动。因为要确保dylib被加载进目标进程,点击图标的方式没法可靠控制注入顺序。
当dylib进入进程后,会找到当前进程自己的Mach-O镜像,解密区内存已经展开成明文,它把这些明文写出来成为一个新的文件。终端通常会输出一句类似“Wrote decrypted binary to /当前目录/MyApp.decrypted”的话。
这个手动流程能成功的前提是:
- 系统还允许DYLD_INSERT_LIBRARIES注入。
- 应用没有做基于DYLD环境变量的拒载。
- dylib本身有可执行权限和符合要求的签名。
当遇到rootless环境时,路径方案略有区别,但命令逻辑差不多。手动挡的好处是你能清楚看到每一步到底在执行什么,排查问题时有底。
3.4 验证结果:cryptid、符号表和导出头文件
脱壳不是说拿到文件就算结束,一定要验证,否则后面基于这个文件做的分析都不可信。
第一步,重新用otool检查cryptid:
otool -l MyApp.decrypted | grep -A 4 LC_ENCRYPTION_INFO_64这时候应该看到:
cryptid 0如果还是1,说明脱壳没成功,直接跳到第四章排查。
第二步,用file确认架构:
file MyApp.decrypted确认是arm64结构,且文件类型正确。
第三步,用nm或者strings检查符号是否存在。加密态下符号表肉眼基本看不到有效内容,脱壳后应该能看到大量的Objective-C类和方法名:
nm MyApp.decrypted | head -50 strings MyApp.decrypted | grep -i "类名或关键方法"第四步,更进一步导出头文件。这是iOS逆向里很常用的下一步操作:
class-dump -H MyApp.decrypted -o /tmp/headers执行完可以看到每个类对应的.h文件。如果class-dump能顺利导出完整头文件,说明Mach-O结构保留得很完整,后续用IDA或者Ghidra分析会舒服很多。
4. 常见问题与排查套路:这些坑我基本都踩过
4.1 连接不上进程或Frida超时
这个是最常见的第一道坎。frida-ps -U能列出进程,但执行dump的时候提示超时,或者直接提示找不到进程。
排查顺序是这样的:
- 先确认App真的在运行状态,不要只在后台挂着,有些App在后台会被冻结,注入之后几秒无响应就会超时。
- 确认frida-server确实还活着。它启动后虽然看着没输出,但可能已经被系统杀掉。重新启动一遍,并用frida-ps -U看进程列表。
- 核对版本匹配。电脑端和手机端的frida版本差太多,连接握手阶段就会卡死。常见组合是手机端server版本与电脑端frida-tools版本尽量一致。
- 如果是远程SSH方式,确认转发的端口没被占用,建议直接用USB通道。
一个小技巧:用frida-ps -U检索进程时,如果列表里能看到目标App进程,但dump.py依然说不存在,检查自己传的名字是不是App显示名而不是进程名。显示名可以带空格和中文,shell解析容易出错。
4.2 脱完壳App一启动就闪退
这个坑我踩过不止一次。脱壳后的二进制本身没问题,但如果你直接替换原App或者重新安装,很可能会遇到闪退。
核心原因有两个:签名失效和权限异常。
越狱环境里不在意签名也能运行,但如果你想恢复到非越狱设备上测试,必须重新签名。最简单的方式是用个人开发者账号生成Ad Hoc描述文件,再用codesign签名。如果在越狱环境里测试,用ldid签个假签名:
ldid -S MyApp.decrypted或者更省事,用zsign工具做一次整体重签:
zsign -k 你的证书.p12 -p 证书密码 -o 输出.ipa MyApp.app第二个原因比较隐蔽:dump工具重建Mach-O时,可能没有保留一些不常用但系统仍然检查的加载命令,比如LC_CODE_SIGNATURE、LC_FUNCTION_STARTS这些。系统启动时校验失败,一闪就退。遇到这个情况,建议换一个工具重新脱壳,最理想是让新文件和原始文件的load commands基本一致。
4.3 cryptid还是1,脱壳没生效
明明已经走完流程,重新检查cryptid还是1,这个问题我见过很多新手朋友犯。排除工具缺陷之外,最常见原因是检查错了对象。
App包里的二进制往往不止一个,除了主程序还有扩展、动态库。如果你检查的是扩展或者Embedded Framework里的二进制,完全可能仍然带加密标志。先用ps或file确认你检查的是主程序路径。
另一个原因是执行环境没进到内存解密状态。脱壳脚本连接的时候,如果App进程还没真正跑起来,或者被系统终止,那么它dump出来可能只是磁盘上的那份密文文件,cryptid当然还是1。这时候需要确保App在目标进程完全启动、主界面至少出现后再执行dump。
某些特殊App会检测动态库注入环境,检测到后直接退出。这类App往往启动后进程存在但很快就自杀,dump出来的文件自然无效。处理思路是先用越狱检测绕过tweak或者调试器暂停启动流程,再在正确时机注入frida。
4.4 批量工具失效、新系统适配与工作流建议
老一代的Clutch、CrackerXI+在iOS 13往后的版本基本都失灵了,不是因为原理错了,而是系统改动导致旧的注入方式失效。frida-ios-dump所以在现代系统上还能用,是因为它不依赖系统旧接口,而是通过Frida的成熟注入通道。
遇到全新系统版本时,建议每次只做小幅更新,不要在升级系统的同时升级所有工具链,否则很难定位是哪一环出了问题。我自己的习惯是维护一个简单的记录表,包含样本哈希、App版本、iOS版本、越狱方案、脱壳工具、工具版本、成功失败状态。别小看这一步,样本多了之后这表能节省大量重复排查时间。
另外,脱壳后的样本要分目录保存:原始IPA单独放一份,解密后二进制单独放一份,靠哈希值对应起来,不混用。后续分析时用哈希校验版本,避免拿错文件导致结论错误。
几个实际操作后沉淀下来的习惯
最后分享几个我做了这么多iOS安全分析之后沉淀下来的习惯。
第一个习惯:在任何自动化工具前,先手动确认设备链路。不管是frida-ps -U还是直接ssh登录设备,先确认app进程列表和加密状态,再决定用哪个方案。链路没通就反复执行dump,只会浪费时间。
第二个习惯:遇到强检测App时,不要死磕某一个工具。frida-ios-dump不行就换dumpdecrypted,不行就用debugserver挂起后自己写dump脚本。不同工具对启动时机和注入方式的处理差别很大,组合使用往往能解决单一工具卡死的问题。
第三个习惯:永远留一份加壳前的原始包。脱壳得出的是明文二进制,但后续分析如果发现代码逻辑和原始包不一致,可能是脱壳过程发生了损坏。这时候原始包就是唯一对照基准。
第四个习惯:脱壳只是万里长征第一步,拿到明文二进制后尽快做hash固化,命名带上版本号和手机系统版本。你永远不会知道自己什么时候会再用到同一个App的不同版本,留好记录,少走很多弯路。
iOS应用脱壳这件事,原理不复杂,但实操里全是细节。希望这篇总结能帮你少踩几个坑,踏踏实实把脱壳这关过了。