上个月接手一个外包遗留的 iOS 项目,原团队解散后只留下一台编译机,能从里面导出的最终交付物就是一个整包 IPA。客户的要求很明确:“这个 App 连越狱检测和防篡改都没有,帮我把基础安全处理补上。”没有源码,不能重新编译,只能直接对 IPA 本身动手。这种活儿在移动安全交付里不算罕见——存量 App、外包移交、SDK 接入方只给成品包……我干脆把这套无源码场景下的安全处理流程完整拆一遍,覆盖边界判断、解包盘点、加固实施、重新签名和验证几个环节,给同样遇到这类需求的同学一条可复现的路线。1. 无源码IPA安全处理前,先把边界和基线想清楚1.1 这类需求通常出现在哪些场景先说说什么情况下会轮到你对着一个孤零零的 IPA 做安全加固。最常见的是存量项目交接,原开发团队不做了,代码仓库权限也丢了,只有正式发布前的打包产物还在。其次是外包交付,合同只约定交付可运行的 IPA 包,甲方后来想要补安全功能,又不想重新走一次开发流程。还有一种是我遇到比较多的:大公司内部的 SDK 合作方只提供脱敏后的成品 App,你需要在自己的测试环境里做合规检测,顺便补一些防护逻辑。这些场景有个共同点:你拿不到源码,但又必须交付“能过客户验收的安全改动”。你的操作对象不再是 Xcode 工程,而是 Mach-O 二进制、Info.plist、资源文件和签名信息。1.2 能做什么与不能做什么无源码加固不是万能的,首先要把预期摆正。我习惯把能做的事分成三类:一是运行时防护,通过注入一个自己写的动态库,让 App 启动时自动加载防护逻辑,比如完整性校验、越狱检测、反调试;二是静态补丁,直接改二进制的某些字节或 Mach-O 结构,比如删除不需要的动态库依赖、修改某些字符串内容;三是签名与打包层面的操作,包括提取 entitlements、重新签名、重新压缩成 IPA 并安装验证。不能做的事也要讲清楚。比如想对整包做深度的代码混淆,在没有源码的情况下只能借助商业壳方案,成本极高且稳定性难保证;想修复业务逻辑 bug,几乎不可行;想让改动之后还继续走 App Store 上架也不是不行,但证书和描述文件的限制会卡得很死,更多时候这些需求都发生在企业签或测试环境。1.3 动手前必须保存的基线信息这个步骤我强烈建议不要跳过。无源码操作最怕改坏之后找不到原始状态,一旦二进制被写乱,你又没有源码可以重新编译,那就真成了事故。拿到 IPA 后第一件事,把原始文件备份一份到安全目录,计算哈希并记录:shasum -a 256 原始App.ipa然后记录当前的签名信息:unzip -q 原始App.ipa -d ipa_unpacked codesign -d --entitlements :- ipa_unpacked/Payload/你的App.app/你的App 2>/dev/null codesign -vv ipa_unpacked/Payload/你的App.app这里能看到 Bundle ID、当前证书的 Team ID、entitlements 里有哪些权限,比如 Push、Keychain Access Groups、App Groups。这些信息在后续重签名时几乎都要原样保留,如果提前没记,重签后很可能出现推送失效、Keychain 数据读不到之类的隐蔽问题。另外还要判断这个 IPA 里的可执行文件是否是明文状态。如果是正经从 App Store 渠道下载的安装包,主二进制通常带 DRM 保护,你看到的是一个加密壳,静态分析什么都看不出来;如果是开发环境导出、企业分发或测试包,基本就是明文的。这里默认你手上的已经是明文可分析状态,如果不是,需要先做解密处理,那属于另一套流程,无源码场景下一般也很少遇到。2. 解包与盘点:先摸清楚要动的文件和已有防护2.1 解包与目录结构IPA 本质上就是 zip 压缩包,解包几乎没有成本。我常用 ditto,因为对 macOS 下的资源 fork 和符号链接处理得比 unzip 舒服:ditto -x -k 原始App.ipa ipa_unpacked cd ipa_unpacked/Payload/进去之后你看到的 xxx.app 就是整个应用包。里面通常有主可执行文件、Info.plist、签名资源 _CodeSignature 目录、各种资源文件,可能还有 Extension(PlugIns 目录下)。主可执行文件的名字和 App 名称不一定一致,但 Info.plist 里的 CFBundleExecutable 字段一定会指明,要盯准这个文件,后面所有分析都围着它转。2.2 检查可执行文件架构和依赖无源码场景下,二进制分析是判断“该怎么动手”的关键,常用的几组命令我习惯组合使用。先看架构和二进制类型:file Payload/xxx.app/xxx lipo -info Payload/xxx.app/xxx绝大多数现代真机包是单一 arm64,但如果看到 armv7 和 arm64 的胖二进制,后面注入动态库时就要注意必须每个架构都插入对应的加载命令,否则跑在老设备上直接崩。接着看动态库依赖,这一步能让你快速知道 App 用了哪些系统框架、有没有自带第三方库:otool -L Payload/xxx.app/xxx如果你看到链接了一堆非系统库,比如 xxxSDK.framework,说明它是某种组件化工程;如果看到的是 Flutter.framework、App.framework 之类的结构,大概率是 Flutter 应用。这个判断很重要,因为不同技术栈对注入防护库的兼容性不一样。再看更详细的 Load Commands,重点看 LC_LOAD_DYLIB 和 LC_CODE_SIGNATURE:otool -l Payload/xxx.app/xxx | grep -A3 -E "LC_LOAD_DYLIB|LC_CODE_SIGNATURE"注入动态库的本质就是往 LC_LOAD_DYLIB 列表里增加一条记录,让 dyld 启动加载时把你的 dylib 一起加载进来。所以在动手之前,至少要清楚现在已有的所有加载项,避免注入后的顺序和位置出问题。2.3 判断技术栈与已有安全措施静态分析工具我常用 strings、nm 和 jtool2 配合使用。strings 可以用来粗看二进制里包含的路径、关键字、URL Scheme 等:strings -a Payload/xxx.app/xxx | grep -i "jail\|frida\|substrate\|cycript"如果搜索出来的内容里已经有 Cydia.app、MobileSubstrate、frida-server 这类字符串,说明 App 本身可能已经做了越狱环境检测,只是你没有源码看不到触发点在哪。这种情况下,你的越狱检测改动就要考虑是否会与原有逻辑相互干扰,更稳妥的做法是先做一轮动态观察,看这个 App 在正常设备和越狱设备上的表现差异。同时检查一下有没有链接可疑的动态库:otool -L Payload/xxx.app/xxx | grep -i "substrate\|frida"已经带 Frida 或 Substrate 的 App 往往是主动内置了调试接口,这类包本身的安全性就很成问题,你后续的“加固”可能得先从移除这些依赖开始。技术栈判断也在这个阶段一起完成。纯 Objective-C/Swift 工程注入一个 OC 动态库问题不大;Flutter 工程表面上是 Runner 作为主程序,实际业务逻辑都在 Flutter 引擎里,此时注入 ObjC 防护库依然可用,但要注意别 Hook 到 Flutter 内部符号;Unity 游戏则更需要关注 Mono/IL2CPP 相关文件,和普通 App 的处理路径完全不同。这些判断越早做,后面翻车的概率越低。3. 不改源码也能落地的加固项:校验、越狱检测与反调试3.1 运行时防护动态库的注入思路核心思路是:自己写一个动态库,把这个动态库的加载命令插入到目标 App 的主二进制中,让 App 一启动就先执行你库里的逻辑。由于你没有源码,主流程代码不可以直接改,但动态库可以替代你完成“在 main 之前运行代码”这个目标。为什么要选 main 之前?因为安全检测逻辑越早跑越好。主程序还没来得及把页面跑起来,防护库已经把环境检查做完了,发现危险就退出,这样攻击者连调试下手的窗口都很难抓。自己在 Xcode 里建一个最低支持版本的 dylib 工程,写一个 C 函数或 Objective-C 的 +load 方法,用编译器的 constructor 属性标记:__attribute__((constructor)) static void security_init(void) { // 这里放越狱检测、反调试、完整性校验代码 }动态库编译出来后,使用 insert_dylib 工具把加载命令写进主二进制:./insert_dylib @executable_path/security.dylib Payload/xxx.app/xxx --all-overwrite@executable_path 是相对路径,它指向可执行文件所在的目录,也就是 xxx.app 里。这样做的好处是 dylib 和主程序放同一个目录下即可,避免绝对路径在换机器后失效。注入完成后可以用 otool -L 验证,你会发现列表中多了一条 security.dylib。这里有个容易混淆的点:在 macOS 上做动态库注入可以靠环境变量 DYLD_INSERT_LIBRARIES,但在 iOS 上这套基本走不通,因为 iOS 的 dyld 对受限二进制会忽略 DYLD_* 环境变量,这也是系统安全机制的一部分。所以我们必须走修改 Load Commands 的路线,直接改变二进制的启动加载列表。3.2 完整性校验:让篡改后的包一启动就暴露免源码加固里最能出成果的一项,就是完整性校验。原理很简单:App 启动时计算主二进制和关键资源的哈希,和事前存好的白名单比对,对不上就说明包被动过手脚,直接拒绝继续运行。难点在于“事前存好的白名单放哪”。没有源码,你不能改主程序逻辑,但你的防护库里有硬编码空间,可以把原始哈希直接写进防护库的代码里。比如先从原始包里算出主二进制和 Info.plist 的 SHA256:shasum -a 256 Payload/xxx.app/xxx shasum -a 256 Payload/xxx.app/Info.plist然后把这两个十六进制字符串写进防护库。运行时用 CommonCrypto 的 CC_SHA256 重新计算目标文件,再逐字节比对。核心代码大致是这样:#include <CommonCrypto/CommonDigest.h> #include <stdio.h> static int check_file_hash(const char *path, const char *expected) { FILE *fp = fopen(path, "rb"); if (!fp) return 0; unsigned char buf[4096]; unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256_CTX ctx; CC_SHA256_Init(&ctx); size_t n; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { CC_SHA256_Update(&ctx, buf, (CC_LONG)n); } fclose(fp); CC_SHA256_Final(digest, &ctx); char hex[CC_SHA256_DIGEST_LENGTH * 2 + 1]; for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { sprintf(hex + i * 2, "%02x", digest[i]); } return strcmp(hex, expected) == 0; }校验失败的处理方式不建议用太激进的逻辑,比如直接卡在启动界面,很容易因为误判把正常用户也挡在外面。我一般选择弹窗提示并退出,或者标记一个全局状态,在 App 后续运行的关键位置再中断。注意,这里的“退出”最好调用 exit(0) 而不是 abort,前者是主动退出,表现上没那么像崩溃,运营侧更好解释。还有一类更细的校验是针对资源文件的。很多改包者只替换图片素材或配置文件来达到换皮效果,这时你可以在防护库中遍历 Resources 目录下的几个关键文件,做批量哈希计算。文件数量多的时候要注意性能,实测中算几十个大资源文件会多花一两百毫秒启动时间,可以接受,但如果包非常大,建议只挑关键文件算。补充一点:如果你有后端服务,更可靠的方案是本地计算哈希后上报,由服务端做最终判定。本地硬编码哈希的弱点是,攻击者可以用静态分析工具找到防护库里的哈希字符串,替换成篡改后文件的哈希,所以本地校验的价值主要在防小白改包,防专业逆向是不够的。这一点在交付时要跟客户讲清楚,避免过度承诺。3.3 越狱检测与反调试实现越狱检测是这类需求里最常被点名的功能。检测思路分几类:一是文件路径检测,越狱设备上常见工具和运行时文件会残留在特定位置;二是 URL Scheme 检测,比如尝试打开 cydia://;三是沙盒逃逸检测,尝试写入系统目录来判断权限是否被放大;四是环境变量检测,检查进程环境里是否有被注入的异常变量。我的防护库内通常把这些检测统一封装成一个函数,返回危险等级,超过阈值才采取行动。路径检测的核心代码大概这样:int check_jailbreak_paths(void) { const char *paths[] = { "/Applications/Cydia.app", "/Library/MobileSubstrate", "/usr/lib/libsubstrate.dylib", "/usr/lib/libcycript.dylib", "/private/var/lib/apt", "/usr/sbin/frida-server", }; for (int i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { if (access(paths[i], F_OK) == 0) { return 1; } } return 0; }这里有个很容易踩的坑:误报。企业设备上如果部署了合规的 MDM 或安全软件,可能在 /private/var/lib 下创建类似目录结构,导致正常设备被判定为越狱。所以我实际交付时都会做一个设备特征白名单机制,把检测结果上报后,由人工确认哪些是误报。反调试的实现,经典做法是在 constructor 里调用 ptrace,设置 PT_DENY_ATTACH。不过现在 iOS 上 ptrace 的行为有变化,单独的 PT_DENY_ATTACH 在部分新系统版本上已经不够彻底,我一般配合 sysctl 检查调试标记:#include <sys/ptrace.h> #include <sys/types.h> #include <sys/sysctl.h> static int is_debugged(void) { int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()}; struct kinfo_proc info; size_t size = sizeof(info); if (sysctl(mib, 4, &info, &size, NULL, 0) == -1) return 0; return (info.kp_proc.p_flag & P_TRACED) != 0; }除了这些,还需要在防护库里做启动时间校验。正常 App 从冷启动到 main 执行的过程非常短,如果攻击者在调试器里单步跟踪,启动耗时会被拉长,检测到明显的启动时间异常时可以主动退出。需要强调的是,越狱检测和反调试是防御手段,不是攻击手段。做这些功能时我们始终是在保护自己的应用不被非法篡改和调试,交付时给客户写清楚触发条件和判定逻辑,避免引起产品侧的误解。4. 重新签名和重打包:让改造后的IPA活下来4.1 签名机制简析你一旦修改了主二进制,原有的代码签名必然失效,这是 iOS 系统的硬性校验。理解签名机制能省掉大量瞎试的时间。iOS 签名体系的核心是把可执行文件、所有资源文件、Info.plist 的哈希放在一起,生成 CodeResources 文件,再由证书对这些哈希做签名。系统启动 App 时,会用内置的证书链验证这个签名集合,任何文件被改动,签名就失效。所以我们做加固的最后一个环节必须重新做一次完整签名,而且要保证 entitlements、Bundle ID 和你手里的证书互相匹配。有一次我给一个 App 补完防护逻辑后,执行签名时漏掉了原包里的某个扩展插件,装到手机上系统直接提示无法安装,因为插件和主程序不是同一套签名。这种问题就是典型的基础机制没吃透导致的。4.2 提取entitlements、移除旧签名、重签与打包重签名的标准流程,我按顺序做一遍:第一步,提取原始 entitlements。之前的基线信息里已经提过,这里直接写文件:codesign -d --entitlements :- Payload/xxx.app/xxx 2>/dev/null > entitlements.plist如果没有输出,说明原包可能用了比较特殊的签名方式,可以检查一下是不是企业签分发包。entitlements 文件一定要留着,推送、Keychain、App Groups 这些功能都靠它。第二步,移除旧签名:codesign --remove-signature Payload/xxx.app/xxx如果包内有 Extension,也要对整个插件目录做同样处理:codesign --remove-signature Payload/xxx.app/PlugIns/*.appex第三步,把防护 dylib 放进 app 包,并确保它没有被单独落下一个旧的签名文件。自己编译出来的 dylib 不签名也没关系,后面统一由 codesign 对整包签名时会一并处理,但不要留下一个孤立签名,常见错误是边角文件带了个过期证书。第四步,重签整个 .app 包:codesign -f -s "你的证书名称" --entitlements entitlements.plist Payload/xxx.app这里的“你的证书名称”是钥匙串里显示的名称,可以用 security find-identity -v -p codesigning 查看。第五步,验证签名是否成功:codesign -v --strict Payload/xxx.app没有输出才是正常的,有输出说明哪一步漏了。验证通过后重新打包:ditto -c -k --sequesterRsrc --keepParent Payload/ security_output.ipa打包时用 ditto 会比 zip 更稳,因为 zip 在某些情况下会破坏符号链接和文件权限,而 ditto 能保留这些细节。4.3 真机安装验证重签完的 IPA 一定要真机装一遍。模拟器环境和真机的签名校验完全不同,很多问题只有真机才会暴露。安装方式我用 Apple Configurator 2 比较多,直接把 IPA 拖进设备窗口等它装完;如果设备已经连接 Xcode,也可以通过 Devices 窗口直接安装。第一次安装如果报出“无法安装”之类的问题,先看设备日志,常见原因是描述文件不包含当前设备的 UDID,或者证书已过期,和你的加固代码本身其实没关系。还有一种情况是安装成功但启动后闪退,这时候可以通过 Xcode 的 Devices 面板查看崩溃日志,定位崩溃发生在加载 dylib 阶段还是主程序初始化阶段。前者多半是签名或路径问题,后者才需要回到你的防护库代码里找原因。5. 实测中容易翻车的细节与验证手段5.1 注入后启动崩溃的几个原因“我按流程走完了,就是装不上/装上也闪退”是这类操作里最常见的反馈。我遇到过的启动崩溃原因,按频率排大概是这几个。签名不完整排在第一位。尤其是包内有 Extension 或 Watch App 时,漏签专项插件非常常见。其次是主二进制被 insert_dylib 改写后,原有的 LC_CODE_SIGNATURE 失效且新旧 Load Commands 结构变化导致 dyld 解析异常,这种情况多见于比较老的 App,它们在启动过程中对二进制地址有强依赖。遇到之后最简单的回退就是重新从原始包来一遍,别在原基础上反复补丁。然后是路径问题。注入动态库时如果你写的是 @executable_path/security.dylib,但实际文件没放进 xxx.app 目录,dyld 会直接报“Library not loaded”并终止进程。排查时用 otool -L 再确认一遍加载路径,并且确认 dylib 确实存在于 Payload/xxx.app/ 下。还有架构问题。如果原始包是 armv7 + arm64 的胖二进制,而你只对 arm64 插入了动态库加载命令,那么在 armv7 设备上启动就会崩。遇到这种包,要么两个架构都注入并确保 dylib 也包含两种架构,要么明确只支持 arm64 设备并在描述文件里限制。5.2 越狱检测和目标环境适配越狱检测最容易翻车的不是检测不出来,而是误伤正常用户。我曾在一次交付中发现,某款企业定制设备因为内置了安全容器组件,在 /private/var/lib/apt 和 /usr/bin/ 下出现了类似越狱特征的路径,导致 App 在正常环境下被自己的越狱检测拦截。适应这类问题,我的做法是把检测逻辑做成可热配置的。防护库启动时先尝试从一份加密配置里读取检测开关和路径白名单,本地的默认配置负责兜底,服务端如果判定当前设备属于合规环境,就下发临时白名单。没有服务端的场景,就退一步,只把最不容易误判的路径列入硬检测,比如 Cydia.app 和 frida-server 的典型位置。另外提醒一句:越狱环境本身也是分析者常用的测试环境,你在做验证时,最好能准备一台越狱设备和一台纯净设备做对照,这样才知道到底是“检测太敏感误报”还是“确实没检出”。我一般会在测试机上手动创建几个模拟越狱特征文件,然后用掊包后的 App 验证是否有告警弹窗。5.3 反调试和静态分析边界的取舍反调试做得太过头,会影响正常测试和问题排查。尤其是客户后续要接 APM 监控或性能分析工具时,这类工具通常也依赖调试接口,你如果把 ptrace 和 sysctl 检测做得太死,自己的技术人员都连不上设备。我的取舍原则是:检测逻辑做成灰度开关,正式发布包默认开启,但测试包可以通过特定启动参数或配置注入自动关闭;日志里只记录风险事件,不上弹窗,除非客户明确要求阻断式响应。毕竟安全处理的最终目标是降低风险,不是把产品变成一团无法维护的黑盒。5.4 验证手段与结果记录做完一轮加固后,我习惯用一张表记录测试结果,方便交付时和客户对进度,也方便后续维护的人照着做回归。检测项操作方式预期结果实测记录启动完整性修改包内任意资源文件后重装启动出现告警并退出正常拦截越狱检测在测试设备创建典型越狱特征文件启动产生风险事件日志正常记录反调试用 Xcode 或 lldb 附加进程进程被拦截或退出正常拦截重签名有效性codesign -v --strict返回无错误通过正常设备回归纯净设备安装运行启动流畅,无拦截通过这一套验证做完,再交给客户真机体验,基本就能交差了。我在实际项目中还会把原始包、修改后的包、签名信息、基线哈希一起打包给客户留档,防止后续说不清改动范围。最后说一点我自己的体会。无源码 IPA 安全处理的核心价值是提高攻击门槛,而不是做出理论上的绝对防御。一个愿意花时间逆向你的人,总有办法绕过本地校验;这些加固手段真正拦住的是“批量抓包改包”“脚本小子尝鲜”这类低成本攻击。所以我会在交付报告里明确哪些风险已覆盖、哪些只能靠服务端策略弥补,同时建议客户尽快恢复可维护的源码构建流程。另一个从实际操作里得到的强烈建议是:动手前一定要保存原始包和基线哈希,我见过不止一次因为补丁打多了找不回原状态的情况,最后只能靠那一份备份挽回局面。
上个月接手一个外包遗留的 iOS 项目,原团队解散后只留下一台编译机,能从里面导出的最终交付物就是一个整包 IPA。客户的要求很明确:“这个 App 连越狱检测和防篡改都没有,帮我把基础安全处理补上。”没有源码,不能重新编译,只能直接对 IPA 本身动手。这种活儿在移动安全交付里不算罕见——存量 App、外包移交、SDK 接入方只给成品包……我干脆把这套无源码场景下的安全处理流程完整拆一遍,覆盖边界判断、解包盘点、加固实施、重新签名和验证几个环节,给同样遇到这类需求的同学一条可复现的路线。1. 无源码IPA安全处理前,先把边界和基线想清楚1.1 这类需求通常出现在哪些场景先说说什么情况下会轮到你对着一个孤零零的 IPA 做安全加固。最常见的是存量项目交接,原开发团队不做了,代码仓库权限也丢了,只有正式发布前的打包产物还在。其次是外包交付,合同只约定交付可运行的 IPA 包,甲方后来想要补安全功能,又不想重新走一次开发流程。还有一种是我遇到比较多的:大公司内部的 SDK 合作方只提供脱敏后的成品 App,你需要在自己的测试环境里做合规检测,顺便补一些防护逻辑。这些场景有个共同点:你拿不到源码,但又必须交付“能过客户验收的安全改动”。你的操作对象不再是 Xcode 工程,而是 Mach-O 二进制、Info.plist、资源文件和签名信息。1.2 能做什么与不能做什么无源码加固不是万能的,首先要把预期摆正。我习惯把能做的事分成三类:一是运行时防护,通过注入一个自己写的动态库,让 App 启动时自动加载防护逻辑,比如完整性校验、越狱检测、反调试;二是静态补丁,直接改二进制的某些字节或 Mach-O 结构,比如删除不需要的动态库依赖、修改某些字符串内容;三是签名与打包层面的操作,包括提取 entitlements、重新签名、重新压缩成 IPA 并安装验证。不能做的事也要讲清楚。比如想对整包做深度的代码混淆,在没有源码的情况下只能借助商业壳方案,成本极高且稳定性难保证;想修复业务逻辑 bug,几乎不可行;想让改动之后还继续走 App Store 上架也不是不行,但证书和描述文件的限制会卡得很死,更多时候这些需求都发生在企业签或测试环境。1.3 动手前必须保存的基线信息这个步骤我强烈建议不要跳过。无源码操作最怕改坏之后找不到原始状态,一旦二进制被写乱,你又没有源码可以重新编译,那就真成了事故。拿到 IPA 后第一件事,把原始文件备份一份到安全目录,计算哈希并记录:shasum -a 256 原始App.ipa然后记录当前的签名信息:unzip -q 原始App.ipa -d ipa_unpacked codesign -d --entitlements :- ipa_unpacked/Payload/你的App.app/你的App 2>/dev/null codesign -vv ipa_unpacked/Payload/你的App.app这里能看到 Bundle ID、当前证书的 Team ID、entitlements 里有哪些权限,比如 Push、Keychain Access Groups、App Groups。这些信息在后续重签名时几乎都要原样保留,如果提前没记,重签后很可能出现推送失效、Keychain 数据读不到之类的隐蔽问题。另外还要判断这个 IPA 里的可执行文件是否是明文状态。如果是正经从 App Store 渠道下载的安装包,主二进制通常带 DRM 保护,你看到的是一个加密壳,静态分析什么都看不出来;如果是开发环境导出、企业分发或测试包,基本就是明文的。这里默认你手上的已经是明文可分析状态,如果不是,需要先做解密处理,那属于另一套流程,无源码场景下一般也很少遇到。2. 解包与盘点:先摸清楚要动的文件和已有防护2.1 解包与目录结构IPA 本质上就是 zip 压缩包,解包几乎没有成本。我常用 ditto,因为对 macOS 下的资源 fork 和符号链接处理得比 unzip 舒服:ditto -x -k 原始App.ipa ipa_unpacked cd ipa_unpacked/Payload/进去之后你看到的 xxx.app 就是整个应用包。里面通常有主可执行文件、Info.plist、签名资源 _CodeSignature 目录、各种资源文件,可能还有 Extension(PlugIns 目录下)。主可执行文件的名字和 App 名称不一定一致,但 Info.plist 里的 CFBundleExecutable 字段一定会指明,要盯准这个文件,后面所有分析都围着它转。2.2 检查可执行文件架构和依赖无源码场景下,二进制分析是判断“该怎么动手”的关键,常用的几组命令我习惯组合使用。先看架构和二进制类型:file Payload/xxx.app/xxx lipo -info Payload/xxx.app/xxx绝大多数现代真机包是单一 arm64,但如果看到 armv7 和 arm64 的胖二进制,后面注入动态库时就要注意必须每个架构都插入对应的加载命令,否则跑在老设备上直接崩。接着看动态库依赖,这一步能让你快速知道 App 用了哪些系统框架、有没有自带第三方库:otool -L Payload/xxx.app/xxx如果你看到链接了一堆非系统库,比如 xxxSDK.framework,说明它是某种组件化工程;如果看到的是 Flutter.framework、App.framework 之类的结构,大概率是 Flutter 应用。这个判断很重要,因为不同技术栈对注入防护库的兼容性不一样。再看更详细的 Load Commands,重点看 LC_LOAD_DYLIB 和 LC_CODE_SIGNATURE:otool -l Payload/xxx.app/xxx | grep -A3 -E "LC_LOAD_DYLIB|LC_CODE_SIGNATURE"注入动态库的本质就是往 LC_LOAD_DYLIB 列表里增加一条记录,让 dyld 启动加载时把你的 dylib 一起加载进来。所以在动手之前,至少要清楚现在已有的所有加载项,避免注入后的顺序和位置出问题。2.3 判断技术栈与已有安全措施静态分析工具我常用 strings、nm 和 jtool2 配合使用。strings 可以用来粗看二进制里包含的路径、关键字、URL Scheme 等:strings -a Payload/xxx.app/xxx | grep -i "jail\|frida\|substrate\|cycript"如果搜索出来的内容里已经有 Cydia.app、MobileSubstrate、frida-server 这类字符串,说明 App 本身可能已经做了越狱环境检测,只是你没有源码看不到触发点在哪。这种情况下,你的越狱检测改动就要考虑是否会与原有逻辑相互干扰,更稳妥的做法是先做一轮动态观察,看这个 App 在正常设备和越狱设备上的表现差异。同时检查一下有没有链接可疑的动态库:otool -L Payload/xxx.app/xxx | grep -i "substrate\|frida"已经带 Frida 或 Substrate 的 App 往往是主动内置了调试接口,这类包本身的安全性就很成问题,你后续的“加固”可能得先从移除这些依赖开始。技术栈判断也在这个阶段一起完成。纯 Objective-C/Swift 工程注入一个 OC 动态库问题不大;Flutter 工程表面上是 Runner 作为主程序,实际业务逻辑都在 Flutter 引擎里,此时注入 ObjC 防护库依然可用,但要注意别 Hook 到 Flutter 内部符号;Unity 游戏则更需要关注 Mono/IL2CPP 相关文件,和普通 App 的处理路径完全不同。这些判断越早做,后面翻车的概率越低。3. 不改源码也能落地的加固项:校验、越狱检测与反调试3.1 运行时防护动态库的注入思路核心思路是:自己写一个动态库,把这个动态库的加载命令插入到目标 App 的主二进制中,让 App 一启动就先执行你库里的逻辑。由于你没有源码,主流程代码不可以直接改,但动态库可以替代你完成“在 main 之前运行代码”这个目标。为什么要选 main 之前?因为安全检测逻辑越早跑越好。主程序还没来得及把页面跑起来,防护库已经把环境检查做完了,发现危险就退出,这样攻击者连调试下手的窗口都很难抓。自己在 Xcode 里建一个最低支持版本的 dylib 工程,写一个 C 函数或 Objective-C 的 +load 方法,用编译器的 constructor 属性标记:__attribute__((constructor)) static void security_init(void) { // 这里放越狱检测、反调试、完整性校验代码 }动态库编译出来后,使用 insert_dylib 工具把加载命令写进主二进制:./insert_dylib @executable_path/security.dylib Payload/xxx.app/xxx --all-overwrite@executable_path 是相对路径,它指向可执行文件所在的目录,也就是 xxx.app 里。这样做的好处是 dylib 和主程序放同一个目录下即可,避免绝对路径在换机器后失效。注入完成后可以用 otool -L 验证,你会发现列表中多了一条 security.dylib。这里有个容易混淆的点:在 macOS 上做动态库注入可以靠环境变量 DYLD_INSERT_LIBRARIES,但在 iOS 上这套基本走不通,因为 iOS 的 dyld 对受限二进制会忽略 DYLD_* 环境变量,这也是系统安全机制的一部分。所以我们必须走修改 Load Commands 的路线,直接改变二进制的启动加载列表。3.2 完整性校验:让篡改后的包一启动就暴露免源码加固里最能出成果的一项,就是完整性校验。原理很简单:App 启动时计算主二进制和关键资源的哈希,和事前存好的白名单比对,对不上就说明包被动过手脚,直接拒绝继续运行。难点在于“事前存好的白名单放哪”。没有源码,你不能改主程序逻辑,但你的防护库里有硬编码空间,可以把原始哈希直接写进防护库的代码里。比如先从原始包里算出主二进制和 Info.plist 的 SHA256:shasum -a 256 Payload/xxx.app/xxx shasum -a 256 Payload/xxx.app/Info.plist然后把这两个十六进制字符串写进防护库。运行时用 CommonCrypto 的 CC_SHA256 重新计算目标文件,再逐字节比对。核心代码大致是这样:#include <CommonCrypto/CommonDigest.h> #include <stdio.h> static int check_file_hash(const char *path, const char *expected) { FILE *fp = fopen(path, "rb"); if (!fp) return 0; unsigned char buf[4096]; unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256_CTX ctx; CC_SHA256_Init(&ctx); size_t n; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { CC_SHA256_Update(&ctx, buf, (CC_LONG)n); } fclose(fp); CC_SHA256_Final(digest, &ctx); char hex[CC_SHA256_DIGEST_LENGTH * 2 + 1]; for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { sprintf(hex + i * 2, "%02x", digest[i]); } return strcmp(hex, expected) == 0; }校验失败的处理方式不建议用太激进的逻辑,比如直接卡在启动界面,很容易因为误判把正常用户也挡在外面。我一般选择弹窗提示并退出,或者标记一个全局状态,在 App 后续运行的关键位置再中断。注意,这里的“退出”最好调用 exit(0) 而不是 abort,前者是主动退出,表现上没那么像崩溃,运营侧更好解释。还有一类更细的校验是针对资源文件的。很多改包者只替换图片素材或配置文件来达到换皮效果,这时你可以在防护库中遍历 Resources 目录下的几个关键文件,做批量哈希计算。文件数量多的时候要注意性能,实测中算几十个大资源文件会多花一两百毫秒启动时间,可以接受,但如果包非常大,建议只挑关键文件算。补充一点:如果你有后端服务,更可靠的方案是本地计算哈希后上报,由服务端做最终判定。本地硬编码哈希的弱点是,攻击者可以用静态分析工具找到防护库里的哈希字符串,替换成篡改后文件的哈希,所以本地校验的价值主要在防小白改包,防专业逆向是不够的。这一点在交付时要跟客户讲清楚,避免过度承诺。3.3 越狱检测与反调试实现越狱检测是这类需求里最常被点名的功能。检测思路分几类:一是文件路径检测,越狱设备上常见工具和运行时文件会残留在特定位置;二是 URL Scheme 检测,比如尝试打开 cydia://;三是沙盒逃逸检测,尝试写入系统目录来判断权限是否被放大;四是环境变量检测,检查进程环境里是否有被注入的异常变量。我的防护库内通常把这些检测统一封装成一个函数,返回危险等级,超过阈值才采取行动。路径检测的核心代码大概这样:int check_jailbreak_paths(void) { const char *paths[] = { "/Applications/Cydia.app", "/Library/MobileSubstrate", "/usr/lib/libsubstrate.dylib", "/usr/lib/libcycript.dylib", "/private/var/lib/apt", "/usr/sbin/frida-server", }; for (int i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { if (access(paths[i], F_OK) == 0) { return 1; } } return 0; }这里有个很容易踩的坑:误报。企业设备上如果部署了合规的 MDM 或安全软件,可能在 /private/var/lib 下创建类似目录结构,导致正常设备被判定为越狱。所以我实际交付时都会做一个设备特征白名单机制,把检测结果上报后,由人工确认哪些是误报。反调试的实现,经典做法是在 constructor 里调用 ptrace,设置 PT_DENY_ATTACH。不过现在 iOS 上 ptrace 的行为有变化,单独的 PT_DENY_ATTACH 在部分新系统版本上已经不够彻底,我一般配合 sysctl 检查调试标记:#include <sys/ptrace.h> #include <sys/types.h> #include <sys/sysctl.h> static int is_debugged(void) { int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()}; struct kinfo_proc info; size_t size = sizeof(info); if (sysctl(mib, 4, &info, &size, NULL, 0) == -1) return 0; return (info.kp_proc.p_flag & P_TRACED) != 0; }除了这些,还需要在防护库里做启动时间校验。正常 App 从冷启动到 main 执行的过程非常短,如果攻击者在调试器里单步跟踪,启动耗时会被拉长,检测到明显的启动时间异常时可以主动退出。需要强调的是,越狱检测和反调试是防御手段,不是攻击手段。做这些功能时我们始终是在保护自己的应用不被非法篡改和调试,交付时给客户写清楚触发条件和判定逻辑,避免引起产品侧的误解。4. 重新签名和重打包:让改造后的IPA活下来4.1 签名机制简析你一旦修改了主二进制,原有的代码签名必然失效,这是 iOS 系统的硬性校验。理解签名机制能省掉大量瞎试的时间。iOS 签名体系的核心是把可执行文件、所有资源文件、Info.plist 的哈希放在一起,生成 CodeResources 文件,再由证书对这些哈希做签名。系统启动 App 时,会用内置的证书链验证这个签名集合,任何文件被改动,签名就失效。所以我们做加固的最后一个环节必须重新做一次完整签名,而且要保证 entitlements、Bundle ID 和你手里的证书互相匹配。有一次我给一个 App 补完防护逻辑后,执行签名时漏掉了原包里的某个扩展插件,装到手机上系统直接提示无法安装,因为插件和主程序不是同一套签名。这种问题就是典型的基础机制没吃透导致的。4.2 提取entitlements、移除旧签名、重签与打包重签名的标准流程,我按顺序做一遍:第一步,提取原始 entitlements。之前的基线信息里已经提过,这里直接写文件:codesign -d --entitlements :- Payload/xxx.app/xxx 2>/dev/null > entitlements.plist如果没有输出,说明原包可能用了比较特殊的签名方式,可以检查一下是不是企业签分发包。entitlements 文件一定要留着,推送、Keychain、App Groups 这些功能都靠它。第二步,移除旧签名:codesign --remove-signature Payload/xxx.app/xxx如果包内有 Extension,也要对整个插件目录做同样处理:codesign --remove-signature Payload/xxx.app/PlugIns/*.appex第三步,把防护 dylib 放进 app 包,并确保它没有被单独落下一个旧的签名文件。自己编译出来的 dylib 不签名也没关系,后面统一由 codesign 对整包签名时会一并处理,但不要留下一个孤立签名,常见错误是边角文件带了个过期证书。第四步,重签整个 .app 包:codesign -f -s "你的证书名称" --entitlements entitlements.plist Payload/xxx.app这里的“你的证书名称”是钥匙串里显示的名称,可以用 security find-identity -v -p codesigning 查看。第五步,验证签名是否成功:codesign -v --strict Payload/xxx.app没有输出才是正常的,有输出说明哪一步漏了。验证通过后重新打包:ditto -c -k --sequesterRsrc --keepParent Payload/ security_output.ipa打包时用 ditto 会比 zip 更稳,因为 zip 在某些情况下会破坏符号链接和文件权限,而 ditto 能保留这些细节。4.3 真机安装验证重签完的 IPA 一定要真机装一遍。模拟器环境和真机的签名校验完全不同,很多问题只有真机才会暴露。安装方式我用 Apple Configurator 2 比较多,直接把 IPA 拖进设备窗口等它装完;如果设备已经连接 Xcode,也可以通过 Devices 窗口直接安装。第一次安装如果报出“无法安装”之类的问题,先看设备日志,常见原因是描述文件不包含当前设备的 UDID,或者证书已过期,和你的加固代码本身其实没关系。还有一种情况是安装成功但启动后闪退,这时候可以通过 Xcode 的 Devices 面板查看崩溃日志,定位崩溃发生在加载 dylib 阶段还是主程序初始化阶段。前者多半是签名或路径问题,后者才需要回到你的防护库代码里找原因。5. 实测中容易翻车的细节与验证手段5.1 注入后启动崩溃的几个原因“我按流程走完了,就是装不上/装上也闪退”是这类操作里最常见的反馈。我遇到过的启动崩溃原因,按频率排大概是这几个。签名不完整排在第一位。尤其是包内有 Extension 或 Watch App 时,漏签专项插件非常常见。其次是主二进制被 insert_dylib 改写后,原有的 LC_CODE_SIGNATURE 失效且新旧 Load Commands 结构变化导致 dyld 解析异常,这种情况多见于比较老的 App,它们在启动过程中对二进制地址有强依赖。遇到之后最简单的回退就是重新从原始包来一遍,别在原基础上反复补丁。然后是路径问题。注入动态库时如果你写的是 @executable_path/security.dylib,但实际文件没放进 xxx.app 目录,dyld 会直接报“Library not loaded”并终止进程。排查时用 otool -L 再确认一遍加载路径,并且确认 dylib 确实存在于 Payload/xxx.app/ 下。还有架构问题。如果原始包是 armv7 + arm64 的胖二进制,而你只对 arm64 插入了动态库加载命令,那么在 armv7 设备上启动就会崩。遇到这种包,要么两个架构都注入并确保 dylib 也包含两种架构,要么明确只支持 arm64 设备并在描述文件里限制。5.2 越狱检测和目标环境适配越狱检测最容易翻车的不是检测不出来,而是误伤正常用户。我曾在一次交付中发现,某款企业定制设备因为内置了安全容器组件,在 /private/var/lib/apt 和 /usr/bin/ 下出现了类似越狱特征的路径,导致 App 在正常环境下被自己的越狱检测拦截。适应这类问题,我的做法是把检测逻辑做成可热配置的。防护库启动时先尝试从一份加密配置里读取检测开关和路径白名单,本地的默认配置负责兜底,服务端如果判定当前设备属于合规环境,就下发临时白名单。没有服务端的场景,就退一步,只把最不容易误判的路径列入硬检测,比如 Cydia.app 和 frida-server 的典型位置。另外提醒一句:越狱环境本身也是分析者常用的测试环境,你在做验证时,最好能准备一台越狱设备和一台纯净设备做对照,这样才知道到底是“检测太敏感误报”还是“确实没检出”。我一般会在测试机上手动创建几个模拟越狱特征文件,然后用掊包后的 App 验证是否有告警弹窗。5.3 反调试和静态分析边界的取舍反调试做得太过头,会影响正常测试和问题排查。尤其是客户后续要接 APM 监控或性能分析工具时,这类工具通常也依赖调试接口,你如果把 ptrace 和 sysctl 检测做得太死,自己的技术人员都连不上设备。我的取舍原则是:检测逻辑做成灰度开关,正式发布包默认开启,但测试包可以通过特定启动参数或配置注入自动关闭;日志里只记录风险事件,不上弹窗,除非客户明确要求阻断式响应。毕竟安全处理的最终目标是降低风险,不是把产品变成一团无法维护的黑盒。5.4 验证手段与结果记录做完一轮加固后,我习惯用一张表记录测试结果,方便交付时和客户对进度,也方便后续维护的人照着做回归。检测项操作方式预期结果实测记录启动完整性修改包内任意资源文件后重装启动出现告警并退出正常拦截越狱检测在测试设备创建典型越狱特征文件启动产生风险事件日志正常记录反调试用 Xcode 或 lldb 附加进程进程被拦截或退出正常拦截重签名有效性codesign -v --strict返回无错误通过正常设备回归纯净设备安装运行启动流畅,无拦截通过这一套验证做完,再交给客户真机体验,基本就能交差了。我在实际项目中还会把原始包、修改后的包、签名信息、基线哈希一起打包给客户留档,防止后续说不清改动范围。最后说一点我自己的体会。无源码 IPA 安全处理的核心价值是提高攻击门槛,而不是做出理论上的绝对防御。一个愿意花时间逆向你的人,总有办法绕过本地校验;这些加固手段真正拦住的是“批量抓包改包”“脚本小子尝鲜”这类低成本攻击。所以我会在交付报告里明确哪些风险已覆盖、哪些只能靠服务端策略弥补,同时建议客户尽快恢复可维护的源码构建流程。另一个从实际操作里得到的强烈建议是:动手前一定要保存原始包和基线哈希,我见过不止一次因为补丁打多了找不回原状态的情况,最后只能靠那一份备份挽回局面。