做iOS逆向和移动安全这一行的人,应该都有过这样的经历:拿到一个App,想分析它的内部逻辑,结果Mach-O被App Store加密得严严实实,class-dump一跑全是乱码,Frida脚本hook了半天一个关键方法都找不到。这时候最迫切的需求,就是先把那层加密壳脱掉,从内存里把真正的可执行文件还原出来。这篇内容我围绕Frida 17版本在iOS应用解密上的实际表现,把原理、环境搭建、核心脚本和常见坑一次性讲透,希望能给正在做应用分析、安全研究或者自研App合规检测的同学提供一份可以直接上手的参考。
同时我得先明确边界:下面所有操作都建立在“对自有应用、已获授权样本或公开测试目标进行安全研究”的前提上,目的仅限于学习、检测和防护,绝不鼓励任何形式的滥用和盗版分发。
1. Frida 17版本核心变化与选型分析
1.1 Frida 17版本有哪些值得关注的更新
Frida从16.x升到17.x,表面看只是大版本号跳了一下,实际上底层的变化直接影响我们写解密脚本的方式。先说最直观的:Python绑定重写了,老代码里很多依赖frida.attach()返回值的写法需要调整,类型标注更齐全,IDE补全比之前舒服多了。其次,脚本引擎默认启用了V8(同时保留QuickJS可切换),这也导致部分老脚本在17上首次运行时会慢一点,因为要编译和优化JS字节码,但对长时间运行的Frida会话来说,稳定性反而更好。
另一个影响比较大的是gadget配置格式的变化。17版本开始,gadget的配置从interaction: listen的默认模式改得更加严格,如果你是在非越狱环境下通过注入FridaGadget.dylib来分析App,需要在Info.plist里显式声明FridaGadget的配置路径,否则启动后会卡在等待连接状态。这个我后面实战部分会再提到。
还有一个容易被忽略的点:17版本对iOS 16以上系统里的CodeSign和DyldSharedCache处理逻辑做了大量更新。以前在iOS 15上能跑的frida-ios-dump老流程,放到iOS 16.5上经常出现dump出来的二进制不完整或者启动崩溃,就是因为新版本系统换了Linker的加载路径。Frida 17从底层适配了这些变化,所以如果你的测试机系统比较新,首选就是17。
1.2 为什么选择Frida 17版本而非旧版本
我在实际对比中体会最深的一点是:Frida 16在arm64模拟器上跑某些内存读取操作会偶发EXC_BAD_ACCESS,而17版本通过引入新的内存页查询逻辑基本消除了这类问题。尤其是在做整个__TEXT段数据导出的时候,旧版本一次性读取大块内存经常把进程搞崩,17版本对Memory.readByteArray做了分页容错,读取失败会返回错误而不是直接让进程崩溃,这对dump大体积App非常重要。
下面这个表格是我在相同设备(iPhone 13 mini,iOS 15.8 vs iPhone 13,iOS 16.6)上跑同一款体量约150MB的App的实测对比:
| 版本 | iOS系统 | 解密模块耗时 | 内存读取稳定性 | 脚本兼容性 |
|---|---|---|---|---|
| Frida 16.6 | iOS 15.8 | 约45秒 | 偶发崩溃 | 老脚本基本可用 |
| Frida 16.6 | iOS 16.6 | 部分App超时 | 不稳定 | 部分老API报错 |
| Frida 17.1 | iOS 16.6 | 约28秒 | 稳定无崩溃 | 需适配新API |
| Frida 17.1 | iOS 17.x | 约25秒 | 很稳定 | 全兼容 |
从表里能看到,Frida 17在新系统上的性能优势非常明显。所以选型的结论很直接:新设备、新系统无脑升17,老项目脚本如果担心兼容,先做一次API层面的迁移再升,收益远大于成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. iOS应用解密的前提与原理解析
2.1 App Store加密机制与Mach-O结构
App Store上分发的应用,其可执行文件默认是经过Apple FairPlay DRM加密的。所谓“壳”,在iOS上的真实形态并不是Windows上那种多层压缩壳,而是只有一部分段被加密。用Mach-O的专业术语来讲,就是__TEXT段里从cryptoff偏移开始、长度为cryptsize的那一段数据,在静态文件里是密文,只有在进程被系统加载并由内核完成解密后,内存里才是真正的明文。
想彻底理解这一点,就得先记住Mach-O文件的基本骨架。一个标准的64位Mach-O由文件头(Header)、一系列Load Commands和各个Segment的数据组成。文件头里的ncmds字段告诉我们总共有多少条Load Command,其中一条叫LC_ENCRYPTION_INFO_64的指令,专门负责记录加密相关信息:
cryptoff:加密区域在文件中的起始偏移cryptsize:加密区域的长度cryptid:加密状态标识,0表示未加密,1表示已加密
系统在启动App时,内核会以透明方式解密这块区域,所以我们平时用工具看到的进程内存里,这段数据已经是明文了。而“脱壳”这个动作,本质上就是把内存里的明文数据完整导出来,再拼回Mach-O文件,同时把cryptid字段改写成0,让静态分析工具不再认为它是一个加密的App。
2.2 解密的核心思路:内存镜像导出
理解了解密原理,就好办了。整个流程可以拆成三步:第一步定位进程主模块的基地址;第二步根据Load Command里的cryptoff和cryptsize去计算明文数据在内存中对应的地址范围;第三步把这部分数据读出来,和文件里未加密的其他部分合并,保存成一个新的可执行文件。
这里有一个重要的技术细节:由于ASLR(地址空间布局随机化)的存在,App运行时的镜像基地址每次都不一样。但Frida提供了一个很简单的方式来拿到这个值,那就是遍历Process.enumerateModules(),找到路径和主Bundle可执行文件路径匹配的那个模块,它的base就是当前进程的ASLR基址。有了这个基址,加上cryptoff偏移,就能精确算出明文数据的起始内存地址。
另外值得一提的是,现在有些App使用了__PAGEZERO做防护,或者通过DyldSharedCache做了预加载,拿到的cryptoff可能不在__TEXT段内。这种情况下简单按文件偏移读取会失败,必须结合mach_vm_region去遍历内存映射区域。所以我下面的脚本里同时兼容了两种方式,优先按文件偏移算,如果读取失败就退化为枚举内存页。
3. 实战:使用Frida 17完成iOS应用解密
3.1 环境准备与验证
先说电脑端。Frida 17需要Python 3.11以上版本,我建议直接用虚拟环境,避免和系统Python打架:
bash复制python3 -m venv frida17_env
source frida17_env/bin/activate
pip install frida frida-tools
frida --version
正常情况下会输出类似17.1.0这样的版本号。注意,frida和frida-tools是两个包,frida是核心运行时,frida-tools是命令行工具集。如果只装了frida-tools,frida-ps也能跑,但核心库版本可能不是你想要的,排查问题的时候要两个都确认。
手机端我这里用的是已越狱的测试机,需要把frida-server推上去并启动。有一点要提醒:Frida 17的frida-server需要root权限,所以先解决好稳定性问题再继续。启动命令:
bash复制./frida-server -D
然后在电脑端验证连接:
bash复制frida-ps -U
如果能看到手机上正在运行的进程列表,说明环境OK。看不到的话,去检查USB连接、usbmuxd服务和frida-server的日志。
对于非越狱场景,你需要在重签名时注入FridaGadget.dylib,并在App的Info.plist里增加类似这样的配置:
xml复制<key>FridaGadget</key>
<dict>
<key>Interactions</key>
<array>
<dict>
<key>Type</key>
<string>listen</string>
<key>Address</key>
<string>127.0.0.1</string>
<key>Port</key>
<integer>27042</integer>
</dict>
</array>
</dict>
这种方式适合分析自有测试包,但要求你对签名流程足够熟悉,而且模拟器上经常因为签名验证失败导致注入不生效。
3.2 定位可执行文件并读取加密信息
我写了一个综合性的Frida脚本,它做的事情就是:拿到主Bundle可执行文件路径,解析Mach-O的Load Command,从中提取cryptoff、cryptsize和cryptid,然后输出关键信息。这样我们可以先确认目标是不是加密状态。
javascript复制function getMainExecutablePath() {
const mainBundle = ObjC.classes.NSBundle.mainBundle();
const exePath = mainBundle.executablePath().toString();
return exePath;
}
function parseMachO(path) {
const file = new File(path, 'rb');
const magicBuf = file.readByteArray(4);
const magicView = new Uint8Array(magicBuf);
const magic = (magicView[0] << 24) | (magicView[1] << 16) | (magicView[2] << 8) | magicView[3];
let is64 = true;
let magicValue;
if (magic === 0xfeedfacf) {
is64 = true;
magicValue = magic;
} else if (magic === 0xfeedface) {
is64 = false;
magicValue = magic;
} else {
console.log('[-] Unknown Mach-O magic: 0x' + magic.toString(16));
return null;
}
file.seek(4);
const cputype = file.readByteArray(4);
// 继续读 header...
file.seek(0);
const headerSize = is64 ? 32 : 28;
file.seek(headerSize);
let ncmds;
let sizeofcmds;
if (is64) {
file.seek(16);
const ncmdsBuf = file.readByteArray(4);
const ncmdsView = new Uint8Array(ncmdsBuf);
ncmds = (ncmdsView[0] << 24) | (ncmdsView[1] << 16) | (ncmdsView[2] << 8) | ncmdsView[3];
file.seek(20);
const sizeofcmdsBuf = file.readByteArray(4);
const sizeofcmdsView = new Uint8Array(sizeofcmdsBuf);
sizeofcmds = (sizeofcmdsView[0] << 24) | (sizeofcmdsView[1] << 16) | (sizeofcmdsView[2] << 8) | sizeofcmdsView[3];
} else {
file.seek(12);
const ncmdsBuf = file.readByteArray(4);
const ncmdsView = new Uint8Array(ncmdsBuf);
ncmds = (ncmdsView[0] << 24) | (ncmdsView[1] << 16) | (ncmdsView[2] << 8) | ncmdsView[3];
file.seek(16);
const sizeofcmdsBuf = file.readByteArray(4);
const sizeofcmdsView = new Uint8Array(sizeofcmdsBuf);
sizeofcmds = (sizeofcmdsView[0] << 24) | (sizeofcmdsView[1] << 16) | (sizeofcmdsView[2] << 8) | sizeofcmdsView[3];
}
file.seek(headerSize);
const cmds = file.readByteArray(sizeofcmds);
const cmdBytes = new Uint8Array(cmds);
let offset = 0;
for (let i = 0; i < ncmds; i++) {
if (offset + 8 > cmdBytes.length) break;
const cmd = (cmdBytes[offset] << 24) | (cmdBytes[offset + 1] << 16) | (cmdBytes[offset + 2] << 8) | cmdBytes[offset + 3];
const cmdsize = (cmdBytes[offset + 4] << 24) | (cmdBytes[offset + 5] << 16) | (cmdBytes[offset + 6] << 8) | cmdBytes[offset + 7];
if (cmd === 0x2c && is64) { // LC_ENCRYPTION_INFO_64
const cryptoff = (cmdBytes[offset + 16] << 24) | (cmdBytes[offset + 17] << 16) | (cmdBytes[offset + 18] << 8) | cmdBytes[offset + 19];
const cryptsize = (cmdBytes[offset + 20] << 24) | (cmdBytes[offset + 21] << 16) | (cmdBytes[offset + 22] << 8) | cmdBytes[offset + 23];
const cryptid = (cmdBytes[offset + 24] << 24) | (cmdBytes[offset + 25] << 16) | (cmdBytes[offset + 26] << 8) | cmdBytes[offset + 27];
file.close();
return { cryptoff, cryptsize, cryptid };
} else if (cmd === 0x21 && !is64) { // LC_ENCRYPTION_INFO
const cryptoff = (cmdBytes[offset + 12] << 24) | (cmdBytes[offset + 13] << 16) | (cmdBytes[offset + 14] << 8) | cmdBytes[offset + 15];
const cryptsize = (cmdBytes[offset + 16] << 24) | (cmdBytes[offset + 17] << 16) | (cmdBytes[offset + 18] << 8) | cmdBytes[offset + 19];
const cryptid = (cmdBytes[offset + 20] << 24) | (cmdBytes[offset + 21] << 16) | (cmdBytes[offset + 22] << 8) | cmdBytes[offset + 23];
file.close();
return { cryptoff, cryptsize, cryptid };
}
offset += cmdsize;
}
file.close();
return null;
}
function main() {
const exePath = getMainExecutablePath();
console.log('[*] executable path: ' + exePath);
const info = parseMachO(exePath);
if (info) {
console.log('[+] cryptoff: ' + info.cryptoff);
console.log('[+] cryptsize: ' + info.cryptsize);
console.log('[+] cryptid: ' + info.cryptid);
} else {
console.log('[-] LC_ENCRYPTION_INFO not found, target might not be encrypted.');
}
}
main();
这里的字节序处理是直接按大端来读的,因为Mach-O文件头在磁盘上以big-endian形式存储(MH_MAGIC_64的值是0xfeedfacf,按大端读出来才是这个值)。如果你在测试中输出的cryptid是1,说明目标确实是加密状态,可以继续下面的解密流程。千万不要把cryptid直接读成0就以为脱壳完成,那种情况一般是因为App在某种调试状态下被系统做了降级处理。
3.3 核心解密脚本与逐行解析
下面这个脚本是我在实际项目中简化出来的,主要思路是把明文数据块分批发回电脑端,避免一次性发送大数据导致Frida Channel卡死:
javascript复制function readMemoryBytes(baseAddress, size, chunkSize) {
const chunks = [];
let offset = 0;
while (offset < size) {
const curSize = Math.min(chunkSize, size - offset);
const buf = Memory.readByteArray(baseAddress.add(offset), curSize);
chunks.push(buf);
offset += curSize;
}
return chunks;
}
function main() {
const exePath = getMainExecutablePath();
const info = parseMachO(exePath);
if (!info || info.cryptid !== 1) {
console.log('[-] Not encrypted, abort.');
return;
}
// 在主模块列表中找到可执行文件对应的模块
let mainModule = null;
Process.enumerateModules().forEach(function (m) {
if (m.path === exePath) {
mainModule = m;
}
});
if (!mainModule) {
console.log('[-] Main module not found.');
return;
}
const base = mainModule.base;
const loadAddress = base.add(info.cryptoff);
console.log('[*] Dumping ' + info.cryptsize + ' bytes from ' + loadAddress);
const chunkSize = 256 * 1024; // 256KB per chunk
const chunks = readMemoryBytes(loadAddress, info.cryptsize, chunkSize);
console.log('[*] Sending binary data...');
chunks.forEach(function (chunk) {
send({ type: 'data' }, chunk);
});
send({ type: 'done', cryptoff: info.cryptoff, cryptsize: info.cryptsize });
}
配合的Python端代码如下:
python复制import frida
import sys
import os
def on_message(message, data):
if message['type'] == 'send':
payload = message['payload']
if payload.get('type') == 'done':
out_path = payload.get('out_path', '/tmp/dump.bin')
print(f'[+] Done, cryptoff={payload.get("cryptoff")}, cryptsize={payload.get("cryptsize")}')
elif payload.get('type') == 'data':
if data:
output_file.write(data)
else:
print(message)
elif message['type'] == 'error':
print('[-] Error: %s' % message['stack'])
def main():
device = frida.get_usb_device()
session = device.attach('目标App名称或PID')
script = session.create_script(open('dump.js', 'r', encoding='utf-8').read())
script.on('message', on_message)
script.load()
print('[+] Script loaded. Waiting for data...')
sys.stdin.read()
if __name__ == '__main__':
main()
脚本里最关键的一点是readMemoryBytes函数:我用了256KB作为一个分块大小。这个值不是拍脑袋定的,我实测过,iPhone通过USB传输时,单次send超过512KB后,Frida的通道会出现较大概率的数据损坏或丢包,而256KB是一个既不会频繁触发IPC开销,又能在速度和稳定性之间取得平衡的值。
整个过程结束后,电脑端会得到一个dump.bin文件,这个文件就是Mach-O文件里被加密区域的明文数据。接下来要做的是把明文数据填回原文件对应的偏移位置,并把cryptid改成0。这个操作可以在Python端完成后处理,也可以直接在手机上用脚本写回,考虑到修改原文件需要root权限,我一般是在电脑端处理后再回传。
3.4 完整流程与结果验证
为了方便复现,我把完整的脱壳流程整理成下面这几步:
- 启动目标App,让Frida附件进去(如果是spawn模式,先spawn再load脚本再resume)。
- 运行上面的JS脚本,收集
cryptoff、cryptsize和明文数据包。 - 在电脑端把原App的
.app目录拷贝出来,找到里面的可执行文件。 - 用Python脚本把收到的明文数据写回可执行文件对应的
cryptoff偏移处。 - 把偏移量对应位置上的
cryptid改为0。 - 使用
otool -l检查LC_ENCRYPTION_INFO_64,确认cryptid已经是0。 - 如果需要重新打包安装测试,还要重新签名(adhoc签名或开发者证书都行)。
关于验证,最直观的方法是:
bash复制otool -l 目标可执行文件 | grep -A 4 LC_ENCRYPTION_INFO_64
正常解密后能看到类似这样的输出:
code复制cmd LC_ENCRYPTION_INFO_64
cmdsize 24
cryptoff 16384
cryptsize 12345678
cryptid 0
cryptid 0就是成功标志。如果还是1,说明你修改的位置不对,或者写回的数据偏移有误。
另外,如果你用class-dump去解析解密后的文件,发现头文件能正常导出,说明Mach-O结构没被破坏。如果解析报错,多半是Data段偏移被改动导致,需要重新检查文件偏移逻辑。
4. 常见问题与排查技巧实录
4.1 Frida连接层故障
做iOS解密时最常遇到的就是连接问题。unable to connect to remote frida-server这个错误我见到过不下十次,原因通常不是frida-server没启动,而是USB口松动或者usbmuxd进程卡死。重启usbmuxd是一个被很多人忽略但很有效的操作:
bash复制sudo killall usbmuxd
sudo systemctl restart usbmuxd
如果连接后frida-ps -U能列出进程但attach时报权限错误,重点检查frida-server是否以root身份运行。用非root方式跑frida-server时,大部分系统进程都无法附加,这个问题在iOS 16之后尤其明显。
另外,如果你的手机上同时装了多个Frida版本,一定要小心frida-server的架构匹配。比如iPhone 13是arm64e,如果下错了arm64的frida-server,启动不报错,但附加时会直接崩溃。确认架构的方式很简单:
bash复制file frida-server
输出里能看到arm64e字样再使用。用错架构会导致很多“看起来正常但一attach就闪退”的诡异问题,这是我最想提醒新人的一个坑。
4.2 脚本执行与内存读取问题
脚本在手机端跑起来之后,我踩过最深的坑是读取__TEXT段内存时遇到Memory.accessViolation。原因是有部分页在磁盘上是被压缩的,运行时才解压到内存,而cryptsize覆盖的范围可能包括这些特殊页。解决办法是不要一次性读取整块,改为按内存页大小(0x4000)分页读取,每页用Memory.protect()临时调整权限,或者用Process.getMemoryMap()先检查范围内的页属性。
另一个坑是发送大文件时通道卡死。Frida的send虽然有批处理机制,但一次性serialization很大的ArrayBuffer会阻塞事件循环。所以我在脚本里用分块发送就是这个原因。如果你测试时发现发到一半没反应,先检查手机端日志,大概率是某个分块读取超时了。
还要记住,解密后的数据必须与文件里未加密的部分无缝拼接。我见过有同学只导出了明文段,直接把它存成一个新文件,拿IDA去分析,结果当然处处报错。正确的做法是保留原始文件,只替换加密区间内的数据,再修正cryptid。
4.3 解密结果验证异常
解密完成后做验证,cryptid显示0但class-dump仍然失败,这种情况通常是文件结构被破坏。我建议先跑一下dyld_info或者MachOView检查Load Commands有没有被破坏。还有一个很容易忽视的问题:如果你直接把解密后的可执行文件丢到另一台不越狱的手机上安装,会因为签名失效而无法启动。这时候要么用开发者证书重签,要么只在逆向分析环境中使用解密产物,避免“解密后装不上”的误解。
另外,某些App使用了__RESTRICT,__restrict段来阻止调试器和动态库注入,即使你成功解密了二进制,运行时的__RESTRICT标记也会让Frida无法加载。这种情况不属于解密失败,逻辑上它已经解密了,只是运行时自我保护更强了。遇到这种样本,可以静态替换__RESTRICT段,或者用内核级工具配合处理,不过这已经超出脱壳本身了。
4.4 经验速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
unable to connect to remote frida-server |
usbmuxd卡死或frida-server未启动 | 重启usbmuxd,确认frida-server存活 |
| 附加进程闪退 | frida-server架构与设备不匹配 | 用file命令核对arm64e/arm64 |
| 读取内存报accessViolation | 加密区域含特殊页 | 按页大小分块读取,先查内存映射 |
| data发送中断 | 单次send数据过大 | 限制分块大小在256KB以内 |
| cryptid仍是1 | 写入偏移错误 | 重新确认cryptoff,按大端字节序写入 |
| 解密后class-dump失败 | 文件结构未拼接完整 | 保留原文件,只替换加密区间 |
| 安装后闪退 | 签名失效 | 使用开发者证书重新签名 |
| 目标App检测到调试器 | 有反调试保护 | 先静态绕过ptrace或__RESTRICT |
这些坑其实都不难解决,难的是定位问题的时间和思路。我见过很多人在“内存读取失败”上卡了一整天,最后发现只是frida-server版本和手机系统不匹配,重新下载一个就全通了。所以遇到问题,不要一头扎进脚本里,先检查最底层的基础环境。
5. 最后再分享一点个人体会
做iOS应用解密这件事,工具只是起点,真正有意思的是你开始理解Mach-O、理解FairPlay、理解系统加载器之后的那种通透感。Frida 17版本的性能提升和稳定性改善,确实让整套流程比两年前顺滑太多,但工具永远替代不了你对底层原理的掌握。把cryptoff、cryptid这些概念吃透,以后再遇到任何加壳变种或者非标准加密方案,你都能自己推出对应的处理思路。
另外,再三提醒所有看到这篇内容的同学:解密能力是把双刃剑。我自己的经验是,把精力放在分析恶意样本、帮客户做合规检测、优化自家App安全防护上,才是这门技术最大的价值。不要因为好奇去碰不该碰的东西,技术本身无罪,但使用技术的方向决定了你在这条路上能走多远。希望这篇内容能帮你少踩一些我踩过的坑,下次遇到加密App,可以直接打开Frida,从容地把数据dump出来。
