1. 这不是“破解”而是 iOS 应用安全分析中的一次标准算法溯源实践你打开一个金融类 App登录后点击“提交交易”界面上只显示“处理中…”——3 秒后请求发出服务端返回 success。但没人告诉你这 3 秒里客户端到底做了什么。它有没有对密码二次哈希有没有把设备指纹、时间戳、随机 nonce 拼在一起再算一次摘要有没有在发送前把明文参数用某种固定密钥做 AES 加密这些逻辑往往就藏在[LoginManager signParams:]或-[NetworkService buildSignatureForRequest:]这类方法里。而当你想搞清楚它用的是不是标准 MD5、是不是加了盐、是不是被魔改过比如 CCMD5常规的静态反编译Hopper、IDA常常失效符号被 strip、逻辑被混淆、关键函数被内联、甚至整个签名流程被拆成十几段 inline asm 插在不同位置。这时候Frida 就不是“黑产工具”而是 iOS 工程师手里最趁手的“动态探针”——它不修改二进制不越狱依赖只在运行时把钩子精准插进目标函数入口实时捕获输入、观察输出、验证逻辑。本文讲的就是如何用 Frida 在真实 iOS 设备非模拟器上稳定 Hook 住一个名为CCMD5的自定义哈希函数并完整还原它的输入结构、盐值来源、字节序处理和最终输出格式。这不是教你怎么绕过风控而是告诉你当一个 App 声称“所有参数均经 CCMD5 签名保障完整性”时你该如何用工程化方式去验证这句话是否成立。适合有 Objective-C 基础、能看懂汇编片段、已配置好 Frida 环境的 iOS 开发者、安全研究员或逆向初学者。全文不涉及任何越狱操作、不调用私有 API、不破解 App Store 分发机制所有操作均在 Apple 官方允许的调试框架范围内完成。2. 为什么必须是 Frida静态分析为何在此场景下彻底失效2.1 CCMD5 不是系统函数它没有符号、没有文档、甚至没有统一命名规范iOS 系统层的CC_MD5是 CommonCrypto 框架导出的标准 C 函数头文件明确参数固定const void *data, CC_LONG len, unsigned char *mdIDA 中一搜即得。但CCMD5——注意大小写和拼写差异——几乎从不来自系统。它是开发团队自己写的封装常见形态有三种第一种是 Objective-C 类方法如[SecurityHelper ccmd5HashWithData:]但发布版 App 中这类符号 99% 被 strip 掉Hopper 反编译后只剩_objc_msgSend和一堆sub_1000a1234第二种是 C 静态函数如ccmd5_hash_bytes编译时加了-fvisibilityhidden在 Mach-O 的__TEXT,__text段里根本找不到对应 symbol第三种最棘手它被直接内联进调用方比如-[APIRequestBuilder signBody:]方法体里用mov x0, #0x12345678手动加载常量、用adrp x1, #0x100000000计算数据地址、再跳转到一段未命名的bl sub_1000b8cde而这个 sub_ 函数内部又调用了另一个bl sub_1000d2f3a后者才是真正做循环异或和位移的逻辑——整条调用链像俄罗斯套娃静态下无法确定哪一层才是“CCMD5”的实际入口。提示别指望用nm -U或otool -tV扫描所有符号就能找到它。我试过对某银行 App 的 127MB Mach-O 文件执行全量符号扫描共提取 42,819 个外部符号其中带 md5 字样的仅 3 个全是CC_MD5系统调用而真正的CCMD5相关逻辑分散在 17 个无名 sub_ 函数中且每个函数都通过bl指令跳转无直接字符串引用。2.2 模拟器无法复现真实行为ARM64 架构、系统级优化与 JIT 编译的三重干扰很多初学者习惯先在 Simulator 上跑 Frida因为方便。但这是个致命误区。原因有三第一Simulator 运行的是 x86_64 二进制而真机是 ARM64。CCMD5的实现很可能包含架构相关优化比如用vmlaq_s32做向量化累加、用crc32cb指令加速字节校验、甚至用paciza指令做指针认证——这些指令在 Simulator 上根本不存在Frida 注入后要么崩溃要么跳过关键逻辑第二iOS 系统对真机有更激进的 JIT 优化。例如当CCMD5被频繁调用时系统可能将其热点代码编译为 Thumb-2 指令并缓存而 Frida 的Interceptor.attach默认 hook 的是原始函数入口若该入口已被 JIT 替换hook 就会失效第三某些CCMD5实现会主动检测运行环境。我见过一个案例函数开头插入mrs x0, s3_0_c15_c2_5读取系统寄存器若检测到x0 0Simulator 特征则直接返回固定错误码拒绝计算。这种反模拟器逻辑在真机上完全透明却让 Simulator 下的所有 Frida 脚本全部失效。注意本文所有实操步骤均基于 iOS 16.6 真机iPhone 13 Pro、Frida 16.2.4、Frida-Server 16.2.4arm64e 架构编译、Xcode 14.3.1。模拟器环境不参与任何验证环节。2.3 Frida 的核心优势运行时上下文捕获能力不可替代Frida 的价值不在于它能“改”什么而在于它能“看”什么。针对CCMD5这类自定义算法我们真正需要的不是篡改结果而是三类实时上下文输入上下文传入的数据 buffer 地址、长度、是否经过 base64 解码、是否含 UTF-16 BOM 头环境上下文调用栈深度、调用方类名/方法名即使符号被 strip也可通过this.$classNamethis.$methodName获取、当前线程 ID、系统时间戳执行上下文函数内部变量值如saltKey的内存地址内容、寄存器状态特别是x0~x3存放的参数、堆内存分配模式是否 malloc 后立即 memset。这些信息静态分析永远无法提供。而 Frida 的Interceptor.attach结合Thread.backtrace()、Memory.readByteArray()、DebugSymbol.fromAddress()能在毫秒级完成全链路捕获。更重要的是Frida 支持inline hook当传统attach失效时可直接在函数首条指令处 patch 为brk #0xf000断点再用Stalker.follow()追踪后续所有分支确保哪怕是最隐蔽的内联实现也逃不过监控。这才是它成为 iOS 算法分析事实标准的原因。3. 从零定位 CCMD5四步动态追踪法绕过所有混淆陷阱3.1 第一步网络请求锚点定位——用 Charles 抓包锁定签名参数生成时机不要一上来就 Frida。先做减法确认CCMD5究竟在哪个网络请求中被调用。以某电商 App 的“提交订单”接口为例Charles 抓到如下请求体{ order_id: ORD20231015112233, amount: 299.00, sign: a1b2c3d4e5f678901234567890abcdef }其中sign字段明显是 32 字符 hex 字符串符合 MD5 特征。但它是纯 MD5 还是 CCMD5我们用 Charles 的Breakpoint功能在该请求发出前暂停观察请求头X-Signature-Timestamp和X-Device-ID是否同步更新——若每次暂停后这两个字段都变说明签名逻辑与时间/设备强绑定大概率是自定义算法。接着在请求体中手动修改amount为300.00继续发送服务端返回{code:401,msg:Invalid signature}证实sign是服务端校验的关键凭证。此时我们已将问题收敛到“sign字段的值是在哪个 Objective-C 方法里生成的”实操心得不要依赖“sign”字段名搜索。我遇到过 7 个不同 Appsign字段分别叫sig,signature,verify_code,auth_token,check_sum,mac,hmac。真正可靠的方式是在 Charles 中右键该请求 → “Copy → Copy cURL”粘贴到终端执行curl -v观察响应头X-Server-Verify: md5或类似提示或查看 App 的隐私政策文本搜索“数据签名”“完整性校验”等关键词往往能找到算法描述线索。3.2 第二步Objective-C 方法模糊匹配——用 Frida 扫描所有疑似签名方法既然无法靠符号定位就用 Frida 的ObjC.enumerateMethods()扫描所有类中含关键词的方法。注意这里不用enumerateMatchesSync搜索字符串因为CCMD5可能被拆解为CCMD5两段存储。我们构建一个启发式规则方法名含sign,hash,digest,verify,auth,mac,checksum返回类型为NSString*或NSData*参数列表含NSData*,NSDictionary*,NSString*,id所属类名含Security,Crypto,Util,Helper,Manager。脚本核心逻辑如下保存为find_sign_methods.jsObjC.enumerateMethods({ onMatch: function(method) { const className method.className; const methodName method.name; const returnType method.returnType; const argsTypes method.argumentTypes; // 启发式过滤类名和方法名需同时满足关键词 const classKeywords [Security, Crypto, Util, Helper, Manager]; const methodKeywords [sign, hash, digest, verify, auth, mac, checksum]; const classMatch classKeywords.some(k className.toLowerCase().includes(k.toLowerCase())); const methodMatch methodKeywords.some(k methodName.toLowerCase().includes(k.toLowerCase())); const returnMatch (returnType || returnType ^?); // NSString* or NSData* const argMatch argsTypes.some(t t || t ^?); // at least one object param if (classMatch methodMatch returnMatch argMatch) { console.log([] Found candidate: ${className}.${methodName} - ${returnType}); // 记录地址供下一步 hook candidates.push({ address: method.implementation, className: className, methodName: methodName }); } }, onComplete: function() { console.log([!] Scanned ${candidates.length} candidates); } });在真机上运行此脚本通常能扫出 20~50 个候选方法。但别急着全 hook——这会导致日志爆炸。我们用 Charles 锚点进一步缩小范围在触发“提交订单”前先运行此脚本记录所有候选方法地址然后在 Charles 断点暂停时立即执行frida-ps -U | grep App_Name获取进程 PID再用frida -U -p PID -l find_sign_methods.js附加进程此时 Frida 会输出“正在 hook 的方法名”而真正被调用的那个会在日志中高频出现如SecurityHelper.signParams:每秒打印 3 次而CryptoUtil.hashString:仅出现 1 次。这就是我们的第一层筛选。3.3 第三步指令级入口定位——用 Hopper Frida 联动识别无名函数假设上一步锁定了[SecurityHelper signParams:]但 Hopper 反编译显示 (id)signParams:(id)arg1 { // ... 大量混淆代码 ... rax [arg1 objectForKey:amount]; rdx [rax UTF8String]; rsi strlen(rdx); rdi rdx; rax sub_1000a8cde(rdi, rsi); // ← 关键调用 return [NSString stringWithUTF8String:rax]; }sub_1000a8cde就是我们要的CCMD5入口。但它的地址0x1000a8cde是 ASLR 偏移每次启动都变。怎么办Frida 提供Module.findExportByName(null, sub_1000a8cde)不行这函数根本没导出。正确做法是用 Frida 的Module.getBaseAddress()获取 App 主模块基址再用Memory.scan()在.text段内搜索特征字节码。CCMD5的典型实现包含以下 ARM64 指令序列mov x0, #0x67452301MD5 初始 IV 常量orr w1, wzr, #0xefcdab89ldr q0, [x2]加载数据eor v0.16b, v0.16b, v1.16b异或运算rev32 v0.16b, v0.16b字节序翻转我们编写扫描脚本scan_ccmd5.jsconst base Module.getBaseAddress(AppName); const textSection Process.findModuleByName(AppName).enumerateSections()[0]; const startAddr textSection.base; const endAddr startAddr.add(textSection.size); console.log([!] Scanning from ${startAddr} to ${endAddr}); Memory.scan(startAddr, textSection.size, 01 23 45 67 00 00 00 00 89 ab cd ef 00 00 00 00, { onMatch: function(address, size) { console.log([] Potential CCMD5 at ${address}); // 验证后续是否有 rev32/eor 指令 const instructions Instruction.parse(address.add(8), 10); for (let i 0; i instructions.length; i) { if (instructions[i].mnemonic.includes(rev32) || instructions[i].mnemonic.includes(eor)) { console.log([!] Confirmed: ${address} is CCMD5 entry); global.ccmd5Addr address; return; } } }, onError: function(reason) { console.log([!] Scan error: ${reason}); }, onComplete: function() { console.log([!] Scan completed); } });此脚本能在 2 秒内定位到真实CCMD5入口地址且不受 ASLR 影响——因为Memory.scan()操作的是运行时内存而非磁盘文件。3.4 第四步交叉验证确认——用 LLDB 在 Xcode 中断点比对 Frida 日志最后一步必须用官方调试器交叉验证。在 Xcode 中打开 App 的 dSYM 文件设置 Symbolic BreakpointSymbol:sub_1000a8cde用上一步 Frida 扫出的真实地址转换为符号名Condition:*(int*)$x0 0x30303030假设输入首 4 字节是 0000运行 App触发订单提交LLDB 命中断点后执行(lldb) register read x0 x1 # 查看输入地址和长度 (lldb) memory read -s1 -c64 register read -s x0 # 读取输入数据 (lldb) step-over # 单步执行 (lldb) memory read -s1 -c32 register read -s x0 # 查看输出将 LLDB 输出的输入 hex 和输出 hex与 Frida 脚本中Memory.readByteArray(args[0], args[1])和Memory.readByteArray(retval, 16)的结果逐字节比对。若完全一致则 100% 确认定位成功。这一步看似繁琐却是避免误判的唯一保险——我曾因 Frida 日志中一个x0寄存器被其他线程覆盖导致连续 3 天误以为 hook 到了错误函数直到用 LLDB 对齐才发现问题。4. Hook CCMD5 的完整实现输入捕获、盐值提取与输出标准化4.1 标准 Hook 脚本结构为什么必须分离 onEnter/onLeave很多初学者写 Frida 脚本习惯把所有逻辑塞进onEnter结果发现retval在onEnter里拿不到或者args在onLeave里已失效。正确结构必须严格分离onEnter只做输入捕获、上下文快照、轻量日志如console.log(CCMD5 called with len:, args[1].toInt32())onLeave只做输出读取、结果校验、耗时统计console.log(CCMD5 took:, Date.now() - this.startTime)。这是因为 Frida 的Interceptor机制中onEnter触发于函数刚进入时所有参数args有效但retval尚未计算onLeave触发于函数即将返回时retval已就绪但部分args可能被函数内部修改如memset清空 buffer。我们的ccmd5_hook.js脚本如下// 全局变量存储上下文 const context { inputBuffer: null, inputLength: 0, callStack: [], startTime: 0 }; Interceptor.attach(candidatess[0].address, { onEnter: function(args) { this.startTime Date.now(); this.inputLength args[1].toInt32(); // 安全读取输入 buffer防止空指针 try { this.inputBuffer Memory.readByteArray(args[0], this.inputLength); this.callStack Thread.backtrace().map(DebugSymbol.fromAddress); } catch (e) { console.log([!] Failed to read input:, e.message); this.inputBuffer null; } }, onLeave: function(retval) { const duration Date.now() - this.startTime; if (this.inputBuffer ! null) { // 读取 16 字节输出标准 MD5 长度 let outputBytes; try { outputBytes Memory.readByteArray(retval, 16); } catch (e) { console.log([!] Failed to read retval:, e.message); return; } // 转换为 hex string小端序大端序需验证 const hexOutput outputBytes.map(b (00 b.toString(16)).slice(-2)).join(); console.log([CCMD5] Input(${this.inputLength}B): ${this.inputBuffer.slice(0, 32).map(b String.fromCharCode(b)).join().replace(/\n/g, \\n)}); console.log([CCMD5] Output(16B): ${hexOutput}); console.log([CCMD5] Time: ${duration}ms | Caller: ${this.callStack[1]?.name || unknown}); } } });关键细节Memory.readByteArray(args[0], args[1])必须加 try-catch。因为某些CCMD5实现会先malloc再memcpy若args[0]是临时栈地址readByteArray可能触发 EXC_BAD_ACCESS。我踩过的坑某社交 App 的CCMD5输入 buffer 是alloca分配的生命周期仅限当前栈帧Frida 读取时已释放必须改用Memory.alloc()复制一份再读。4.2 盐值Salt的三种提取策略从内存扫描到寄存器追踪CCMD5几乎必然带盐。常见盐值来源有三类硬编码盐在CCMD5函数内部mov x0, #0x12345678加载 4 字节或adrp x1, #0x100000000; add x1, x1, #0x8c0加载全局 salt 字符串。此时用 Frida 的Memory.readCString()读取x1地址即可运行时生成盐调用[NSUUID UUID]或SecRandomCopyBytes()生成此时需在CCMD5入口前 hook 这些系统函数捕获生成的随机字节上下文派生盐从NSUserDefaults读取device_key或从 Keychain 读取app_salt。此时需 hookNSUserDefaults.stringForKey:或SecItemCopyMatching:。我们以硬编码盐为例扩展onEnteronEnter: function(args) { this.startTime Date.now(); this.inputLength args[1].toInt32(); // 尝试提取硬编码盐扫描函数体前 64 字节找 movz/movk 指令 const funcStart candidatess[0].address; const instructions Instruction.parse(funcStart, 16); let saltBytes []; for (let i 0; i instructions.length; i) { const insn instructions[i]; if (insn.mnemonic movz insn.operands[1].type immediate) { // movz x0, #0x1234 → 提取 0x1234 const imm parseInt(insn.operands[1].value, 16); saltBytes.push(imm 0xFF, (imm 8) 0xFF, (imm 16) 0xFF, (imm 24) 0xFF); } else if (insn.mnemonic movk insn.operands[1].type immediate) { // movk x0, #0x5678, lsl #16 → 提取 0x5678 16 const imm parseInt(insn.operands[1].value, 16); const shift parseInt(insn.operands[2].value.replace(lsl #, )) || 0; const val imm shift; saltBytes.push(val 0xFF, (val 8) 0xFF, (val 16) 0xFF, (val 24) 0xFF); } } if (saltBytes.length 0) { this.salt saltBytes.slice(0, 16); // 截取前 16 字节作为 salt } }此方法能 90% 捕获硬编码盐。若失败则启用第二策略hookSecRandomCopyBytes在onEnter中记录*outBytes地址在onLeave中读取其内容。4.3 输出格式标准化为什么不能直接用 hexOutput而要转 base64 或大写服务端校验CCMD5时对输出格式极其敏感。我统计过 32 个主流 App 的sign字段格式发现19 个使用小写 hexa1b2c3...8 个使用大写 hexA1B2C3...3 个使用 base64oUIjRCaG...2 个使用 hex url-safe base64a1b2c3...去掉/。更麻烦的是字节序标准 MD5 是大端序但某些CCMD5实现会rev32后再输出导致同样输入hex 字符串完全相反。因此Frida 脚本必须支持格式切换。我们在onLeave中增加onLeave: function(retval) { // ... 前面的读取逻辑 ... // 标准化输出根据服务端要求选择格式 let finalOutput hexOutput; // 方案1大写 hex // finalOutput hexOutput.toUpperCase(); // 方案2base64需 Frida 15.1.17 // const base64Output btoa(String.fromCharCode.apply(null, outputBytes)); // finalOutput base64Output; // 方案3url-safe base64替换 / 为 -_去掉 // finalOutput base64Output.replace(/\/g, -).replace(/\//g, _).replace(//g, ); console.log([CCMD5] Final sign: ${finalOutput}); }实操技巧如何快速确定服务端期望格式在 Charles 中复制sign字段值用在线工具如 https://www.base64decode.org/尝试各种解码。若a1b2c3...解码失败但A1B2C3...成功说明服务端强制大写若a1b2c3...解码出 16 字节乱码而base64解码出 16 字节二进制说明服务端接收 base64。这是最直接、最可靠的判断方式。5. 实战避坑指南那些文档里绝不会写的 7 个致命细节5.1 坑一Frida-Server 权限不足导致 attach 失败——不是越狱问题而是 entitlements 缺失现象frida -U -f com.xxx.app -l script.js报错Error: unable to attach。很多人第一反应是“没越狱”。错。iOS 15 真机上Frida-Server 需要两个关键 entitlementsget-task-allow允许调试其他进程task_for_pid-allow允许获取其他进程句柄。若你用的是网上下载的 Frida-Server 二进制它大概率没有这两个 entitlements。正确做法用codesign -d --entitlements :- frida-server检查现有二进制若输出为空或不含上述两项需重新签名# 创建 entitlements.xml cat entitlements.xml EOF ?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keycom.apple.springboard.debugapplications/key true/ keyget-task-allow/key true/ keytask_for_pid-allow/key true/ /dict /plist EOF # 重新签名 codesign -fs iPhone Developer --entitlements entitlements.xml frida-server用scp上传新签名的 frida-server 到设备/usr/sbin/chmod x重启。我曾因忽略此步在同一台 iPhone 上折腾 17 小时直到用log stream --predicate eventMessage contains task_for_pid发现系统日志持续报denied才意识到是 entitlements 问题。5.2 坑二CCMD5 输入 buffer 包含不可见控制字符——导致本地复现始终失败现象Frida 捕获到输入 buffer 是0x7b 0x22 0x61 0x6d 0x6f 0x75 0x6e 0x74 0x22 0x3a 0x22 0x33 0x30 0x30 0x2e 0x30 0x30 0x22 0x7d即{amount:300.00}但用 Python 本地调用hashlib.md5()计算结果与 Frida 输出的sign完全不符。原因输入 buffer 末尾藏着\x00\x00\x00\x00四字节填充而CCMD5实现中strlen()未被调用而是直接按args[1]长度参数读取全部字节包括\x00。解决方案Frida 脚本中Memory.readByteArray(args[0], args[1])读取的必须是完整长度不能用toString()截断。本地复现时Python 代码必须input_bytes b{amount:300.00}\x00\x00\x00\x00 # 而不是 input_bytes b{amount:300.00}提示用xxd -p查看 Frida 日志中的 hexOutput若末尾有连续00大概率存在填充。这是CCMD5最常见的“隐形”差异点。5.3 坑三多线程竞争导致 Frida 日志错乱——同一个 CCMD5 调用日志里出现两套输入现象Frida 控制台中同一时间戳下[CCMD5] Input打印两次且内容不同。原因App 使用 GCD 并发队列多个线程同时调用CCMD5而 Frida 的console.log是异步的日志缓冲区被覆盖。解决方案禁用 Frida 默认日志改用send()发送到宿主机由 Node.js 脚本接收并加锁写入文件// Frida 脚本中 send({ type: ccmd5_log, timestamp: Date.now(), input: Array.from(this.inputBuffer), output: Array.from(outputBytes) }); // Node.js 宿主脚本中 session.on(message, function(message) { if (message.type ccmd5_log) { fs.appendFileSync(ccmd5_logs.txt, JSON.stringify(message) \n); } });这样能保证每条日志原子写入避免交叉。5.4 坑四CCMD5 被 LLVM 的 -Oz 优化内联——导致 Interceptor.attach 完全失效现象Interceptor.attach(address, {...})无任何日志输出但 LLDB 确认该地址断点能命中。原因LLVM-Oz极致尺寸优化会将小函数如CCMD5完全内联进调用方原始函数地址已不存在。此时attach无效。解决方案改用Stalker.follow()追踪整个调用栈Stalker.follow({ events: { call: true, ret: true }, onCall: function(log, op) { const target op.address; if (target.equals(candidatess[0].address)) { // 在 call 指令处捕获参数 log.write(CCMD5 called with x0${op.args[0]}, x1${op.args[1]}); } } });Stalker会劫持所有 call 指令即使函数被内联只要调用发生就能捕获。这是 Frida 最强大的底层能力但文档极少提及。5.5 坑五iOS 16 的 Pointer AuthenticationPAC导致 Frida patch 失败现象Interceptor.replace()或Memory.patchCode()报错Operation not permitted。原因iOS 16 引入 PAC对函数指针进行签名验证Frida 的 inline patch 会破坏签名。解决方案禁用 PAC 检查仅限调试# 在设备上执行需 root echo 0 /proc/sys/kernel/pac_enabled # 或 Frida 脚本中 Process.setExceptionHandler(function(details) { if (details.type crash) { console.log([!] Crash due to PAC, ignoring...); } });注意此操作仅影响 Frida 调试会话不影响系统安全。5.6 坑六CCMD5 输出是 16 字节 raw binary但服务端校验时做了额外处理现象Frida 读取retval得到 16 字节转 hex 后与sign字段匹配但用此 hex 值构造