ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

iOS IPA资源与文件安全加固实战:防篡改、防二次打包

iOS IPA资源与文件安全加固实战:防篡改、防二次打包 先讲个真实的场景。上个月我们有个内部工具类 App 发新版本结果隔了两天就有渠道方反馈说市场里出现了一个“同名同图标”的包安装后行为明显不对一查就是被人把原版 IPA 里头的资源文件替换过又重签的。这种事在 iOS 分发链路里并不少见尤其是通过企业分发、TestFlight 或第三方渠道安装的包拿到的其实就是一个能直接解压的 zip 容器IPA 里头的图片、配置文件、脚本、甚至 Mach-O 二进制都暴露在攻击者面前。很多人以为“苹果签名很安全”但签名解决的是“谁签的”根本不管“签完之后里面的东西有没有被改”。这一年多我陆续做了几个 App 的加固改造踩过的坑还挺多。把资源文件加密、二进制混淆、重签检测、运行时的证书校验这些东西连成一套才算是把 IPA 从“裸奔”状态拉到“至少不是软柿子”的水平。这篇文章不聊理论框架就讲我在实际项目里怎么做 iOS IPA 资源与文件安全加固每一步的取舍、理由和踩坑记录都会写清楚。1. 先说清楚IPA 加固到底在防谁1.1 理解 IPA 这个东西的本质IPA 的底层就是一个 zip 压缩包用 unzip 解开之后典型的目录结构是这样Payload/ └── MyApp.app/ ├── MyApp # Mach-O 可执行文件 ├── Info.plist # 应用配置 ├── embedded.mobileprovision # 描述文件 ├── _CodeSignature/ │ └── CodeResources # 签名资源清单 ├── Assets.car # 资源打包文件 ├── .mobileprovision 相关文件 ├── 各类 .png/.jpg/.mp3/.json/.sqlite └── Frameworks/ # 动态库结构摆在这里就意味着任何人都能拿 unzip、7-Zip 或者 mac 上的 Archive Utility 直接解包把里面的图片、音效、JSON 配置、数据库文件原样拿走。更麻烦的是解包完再往里面塞点东西比如换掉启动图、注入一段脚本、改一下网络请求地址然后用另一套证书重新签名一个“换皮”App 就出来了。对于非越狱环境下的大多数普通用户iOS 的签名校验只在安装和启动时做一次系统级验证只要签名合法系统不会关心二进制和资源是否被人动过手脚。所以在做加固之前必须形成一个基本认知IPA 是一个静态分发物系统能保证的是“合法证书”不是“内容没被篡改”。加固的目标就是在系统校验之外再补上自己的那一层完整性保护。1.2 威胁分层与加固目标我不是一上来就堆各种防护而是先把“要防谁”和“防到什么程度”想清楚。按实际操作能力威胁可以分三个层次威胁等级攻击者画像常用手段加固侧重点低普通用户、轻度折腾者解压改图片、改 plist 再重装资源加密、完整性校验、安装包自检中有一定工具能力的开发者重签名、注入动态库、替换字体/皮肤、改配置接口签名校验、反注入、代码混淆、关键逻辑混淆高专业逆向人员越狱环境、动态调试、砸壳脱壳、Frida 注入反调试、反注入、服务端联动、商业级混淆实际项目里不可能三个层级全部硬顶成本也扛不住。我的思路是低层级用资源加密和完整性校验挡掉一大半中层级用签名校验和二进制混淆提高改造门槛高层级则主要靠服务端的行为风控和业务逻辑保护兜底。客户端加固的意义是提高攻击成本而不是做到绝对不可破解。这里还要多说一句很多团队把加固精力全放在“防别人提取资源”上其实更值钱的是防“二次打包”和“篡改后运行”。因为一个被换过图标的 App 对你的品牌伤害甚至大于资源泄露本身。所以完整性校验和服务端核验一定要优先做。2. 资源文件防护从静态资产入手2.1 别把资源明文扔在 IPA 里我经手的大多数项目资源无外乎图片、音频、JSON、SQLite、WebView 用的 HTML/JS。做加固时第一件事不是写加密代码而是先梳理哪些资源是“敏感”的哪些其实无所谓。启动图、App 图标、通用 UI 背景图这类资源加密的价值很低因为攻击者运行 App 截个屏就能得到视觉效果而且强行加密往往会影响启动性能和内存占用。真正值得保护的是这些运营配置、接口地址、功能开关之类的 JSON/plist内置地图数据、词库、题库、离线包需要授权的音视频素材和业务逻辑强相关的本地脚本、规则文件对值得保护的文件我采用的做法是“改后缀 自定义加密容器”。这里的核心不是为了防专业逆向而是让攻击者即使解压出来也辨认不出文件用途增加“找到目标文件”的检索成本。具体步骤可以参考下面这套在构建阶段用脚本对需要保护的文件做 AES-256 加密加密后统一丢进一个自定义格式的资源包里例如assets.dat。程序里用一个工具类负责解密解密后的数据不落盘直接在内存里交给上层使用。解密密钥不写死在一个明文常量里而是拆成两到三段分散在多处运行时拼接。说下选型的理由。用统一资源包而不是逐个加密散文件主要是为了方便校验和减少文件数量操作系统对 App 内部文件数量太多时安装速度和解压时间都会有明显影响。密钥拆段比较麻烦但能挡住“搜索字符串定位密钥”的最初级手段。实际中我见过很多加固失败的例子就是直接把 key 写成一个常量字符串放在代码里逆向人员用strings一搜就出来了。密钥分散存放至少能让大多数工具党卡在第二步。2.2 资源完整性校验不能只算一次把资源换成加密容器只解决了“看不懂”还没解决“能不能被整体替换”。攻击者完全可以把你整个加密资源包换成一个空壳绕过资源加载。所以必须做完整性校验。我在实践中使用的方案是启动时对资源包进行分块哈希校验而不是等整个包加载完再校验。流程大致是这样1. 在构建期生成资源包时计算每个分块每块 256KB的 SHA-256写入校验清单。 2. 校验清单本身参与签名随 Mach-O 一起发布。 3. App 启动后在后台队列里对资源包做分块哈希比对。 4. 校验失败时不直接 crash而是进入“受限模式”关闭高级功能、上报异常、下次启动要求联网验证。这里有个很关键的细节校验失败的处理不能做成“弹窗提示然后退出”因为攻击者会直接 hook 掉弹窗。我的做法是让失败状态进入一个全局的integrityStatus变量所有核心业务模块取这个状态决定是否运行完整逻辑这样即便攻击者跳过弹窗不完整的功能链路仍然会暴露问题。另外不要把完整校验放在启动路径上尤其是大型离线包几百 MB 的包做全量校验会导致启动时间肉眼可见地变慢。我测试过 300MB 的离线地图包全量 SHA-256 在 A15 芯片设备上大约耗时 1.8 秒左右这个开销放在启动流程里不可接受。分块校验配合后台调度可以把单帧耗时压到几十毫秒内对用户无感。2.3 容易被忽略的 Info.plist 和配置类文件资源加固里最容易翻车的其实是 Info.plist。很多团队把敏感配置写进了 Info.plist比如 Firebase 配置、Bugly 的 App ID甚至内测的接口域名。攻击者解包后直接就能看到这些信息根本不需要逆向二进制。我的结论是真正敏感的信息一概不写入 Info.plist。放到加密资源包里运行时解密后动态注入到相应 SDK。同时要在代码里校验 Info.plist 的关键键值比如CFBundleIdentifier、CFBundleVersion因为重签名者往往会改动这些字段。重打包的人为了区分原版和修改版经常会在 plist 里加CFBundleDisplayName、改版本号一旦跑了这种改过的包服务端也能通过上报的包信息识别出来。顺带提一个测试技巧在 macOS 上可以直接用 PlistBuddy 修改 Info.plist 再重打包成本极低。所以凡是 plist 里出现的字段都要假设攻击者一定看得到、改得了不要把任何安全判断建立在“plist 里写了什么”之上。3. Mach-O 二进制层的代码保护3.1 发布构建时把该扔的信息扔干净进入二进制加固之前先检查一遍工程的编译设置。这里是最容易拿到即时收益、却常常被忽略的一步。关闭Symbols的 Debug 符号生成用strip去掉本地符号表和调试符号开启Compile for Thumb视情况而定不一定有安全收益把Optimization Level设为Fastest, Smallest [-Os]不仅能减小体积也能稍微提高逆向难度语言模式开启Whole-Module OptimizationSwift 混编项目里会把函数之间的边界打散对汇编阅读者不太友好做完这些之后可以用nm和strings快速验证效果。一个没 strip 过的二进制nm能把所有函数名列出来相当于给逆向人员送了一份代码地图。strip 之后至少能挡掉一部分“先找函数名再定位代码”的快速分析路径。同样需要检查的是第三方 SDK 的符号导出。有些 SDK 会在二进制里留下可读的 Objective-C 类名、方法名逆向人员只要class-dump一下App 的类结构就一览无余了。如果项目是纯 Objective-C可以考虑对核心代码启用-fobjc-weak和-fvisibilityhidden减少类信息的可见度但要小心苹果审核对私有 API 的扫描逻辑不是隐藏得越彻底越好埋的坑后面再细说。3.2 字符串加密和代码混淆给分析者设点障碍纯编译层面的保护远不够因为strings一把梭就能看到很多明文常量比如 URL、密钥片段、日志文案。这些字符串往往是逆向的第一个突破口。我现在维护的项目里有一套基于 LLVM 插桩的字符串加密方案原理不复杂写一个构建脚本扫描源代码中的敏感字符串常量通过正则和关键字列表圈定范围运行时解密后再使用。实际工程中我倾向于只加密下面几类字符串网络接口路径和 baseURL加密密钥、盐值、初始化向量SQL 语句和数据库表名未公开的错误码文案不建议把全部字符串都加密一是性能开销二是会给日常日志排查带来很大麻烦三是有时候 Apple 的审核机器在扫描二进制时对大量加密字符串会有“意外”反应。我踩过一次坑把一大段普通业务文案也加密了结果审核被拒理由是“二进制中包含无法读取的大段暗文数据”后来把范围缩小才过审。这种操作要控制好比例。代码混淆方面如果团队预算够可以直接上商业加固方案预算有限的可以自己搞一套 OC 类名混淆的脚本。原理是把interface里的类名、方法名替换成无意义的随机字符串。但这里的坑很多KVC、NSClassFromString、respondsToSelector、Storyboard 里的类名映射都会挂。我自己实现的脚本只混淆内部纯代码类并且通过编译期生成映射表来处理NSClassFromString场景。用个不精确但好理解的类比字符串加密是给保险柜换了个复杂的锁代码混淆则是把整栋楼的房间号全撕了让人找不到哪个房间放着保险柜。两者配合分析者定位目标的成本才是真正意义上的上升。3.3 调试器检测与反注入越狱环境下最常见的分析手段是挂上 LLDB 调试、用 Frida 做动态注入。对应的客户端防护通常有两块反调试和反注入。反调试最基础的做法是调用ptrace的PT_DENY_ATTACH原理是阻止调试器附加到进程。代码风格大概是#import sys/types.h #import sys/ptrace.h void disable_ptrace() { ptrace(PT_DENY_ATTACH, 0, 0, 0); }不过这个方法已经比较老了很多调试工具做了对应的绕过。我在实际项目里的做法是在多个位置调用并且配合一个后台线程周期性地检测状态一旦发现被附加就触发自毁逻辑。自毁不是清空用户数据而是主动让 App 进入异常状态比如直接exit(0)或者更柔和一点静默切换到演示数据模式避免攻击者观察到明显的“崩溃后重来”信号。反注入则主要盯DYLD_INSERT_LIBRARIES环境变量和动态库列表。正常的 iOS App 一般不会依赖任意注入的动态库所以在主程序启动后检查加载的动态库列表把和自身代码无关的库标记出来。这里需要维护一份白名单把系统和自家 SDK 的动态库路径加进去再对名单外的情况做异常上报。说句实在话这一层只能防住“非主动对抗”的攻击者。专业的逆向工程师手上都有绕过ptrace的工具但大部分往你 App 里塞东西做二次分发的人水平没到那一步。反调试的价值在于过滤掉工具党而不是硬抗顶尖专家。3.4 越狱环境检测的度和量越狱检测也绕不开。市面上有很多开源检测工具原理基本都是查常见越狱痕迹Cydia/Sileo 是否存在、/Applications/Cydia.app路径是否存在、fork()能否正常执行、环境变量里有没有异常项。我的建议是不要把越狱检测做成“检测到就拒绝启动”的一刀切逻辑。这样做的后果是一部分普通用户的白名单误杀率很高而且攻击者只要定位到这个调用点hook 返回结果就完事了。更合理的姿势是把检测结果作为一条行为信号上报到服务端由风控系统结合其他维度判断是否下发正常数据。具体到我做的项目越狱检测的结果会打上不同权重发现 Cydia 是强信号发现可疑环境变量是弱信号。服务端拿到这些信号之后再结合设备指纹和请求频率做综合判断。客户端只负责采集和上报不直接执行惩罚策略这样即便检测逻辑被绕过服务端仍然保留一道防线。4. 签名与重签名的攻防细节4.1 先弄清 iOS 签名机制是怎么工作的很多人分不清“开发者证书”和“描述文件”的关系导致在做重签检测时找错了地方。简单梳理一下开发者证书Certificate证明“你是某个开发者账号下的合法身份”描述文件Provisioning Profile把“开发/分发账号、App ID、设备列表、权限声明”绑定到一起IPA 里的_CodeSignature/CodeResources是对整个 App 包资源的签名清单覆盖了所有文件当攻击者拿到一个 IPA 后要让它能在另一台设备上安装就必须把自己拥有的证书和描述文件“替换”进去这个过程就是重签名。重签名之后embedded.mobileprovision会变、_CodeSignature/CodeResources会重建、签名者 Team ID 也会不同。这些字段正是我们做检测的抓手。4.2 重签名之后的检测点怎么找我最开始做的重签检测非常简单启动时读取embedded.mobileprovision解析出 Team ID 和 App ID跟硬编码的合法值比对。这个方法能挡住一批“不会改描述文件内容”的初级打包手但很快就被绕过了——对方直接用一个同样 App ID 的企业证书重签Team ID 看起来还是同一团队的。后面我把检测点扩展成了下面这套组合检测点方案绕过难度签名证书链用SecTrustEvaluate校验信任链中等需要伪造证书链Team ID从描述文件解析出 Team ID 后比对低换证书就变Bundle ID运行时校验CFBundleIdentifier和预期值是否一致低改 plist 即可绕过文件哈希指纹对关键文件主二进制、资源包生成指纹并随服务端下发高必须同步改服务端启动时间戳记录安装后首启时间异常缩短可能疑似重打包低仅辅助判断真正能形成有效防线的是服务端下发的指纹校验和服务端行为校验。客户端的代码再怎么检测都能被 hook但只要校验逻辑的一部分搬到了服务端攻击者就不能只改本地代码过关。4.3 防重签名的服务端联动方案我在一个日活不算大的 App 上实现过一套相对完整的防重签方案思路可以给大家参考。App 启动时向服务端请求integrity/config接口带上设备 ID、包名、版本号、签名指纹。服务端比对签名指纹和包名是否匹配不匹配则视为“高危包”。对高危包返回一个受限配置比如只开放基本浏览功能核心内容、支付入口全部关闭。客户端在收到受限配置后写入内存状态后续所有业务请求都携带该状态服务端据此做差异化响应。这个方案有几个隐形好处攻击者要绕过不仅需要改客户端还得伪造服务端接口难度陡增。服务端可以随时更新指纹数据不用发新版本。如果发现有某个“换皮包”在大量分发可以后台针对那个包名做封禁也不需要客户端配合。缺点也很明显服务端接口一旦被摸底攻防就进入下一轮。所以通信本身还要搭配 HTTPS 证书锁定避免中间人把请求原样转发到真服务器上做重放。这点放在下一节展开。4.4 合法分发场景下的签名兼容问题加固手段不是越多越好过于激进会影响正常分发。我犯过一个挺典型的错误在企业分发场景下有些企业内部使用的包用了企业证书Team ID 和 App Store 证书不同但我早期把 Team ID 写死成了线上 App Store 包的 Team ID导致内部测试包全部触发安全逻辑产品同学直接没法用。后来我把签名校验分成两个模式Release模式只校验固定的 Team ID 和 Bundle ID用于 App Store 分发Enterprise/AdHoc模式校验描述文件里是否包含内部测试设备 UDID以及证书是否在合法列表中在开发阶段我还默认允许 Xcode 真机调试的 Debug 包放行因为开发签名证书不稳定容易变。记住一个原则签名校验不能把开发者和测试人员锁在外面否则下一次发版之前内部抱怨声会先把你淹没。5. 运行时防护与服务端联动5.1 HTTPS 只是起点证书锁定才是关键很多开发者的认知停留在“我的 API 是 HTTPS所以安全”。但移动端场景下中间人攻击有更轻便的路径不破解你的证书而是直接往系统信任链里塞一个自己的 CA 证书这在很多调试工具里都是一键操作。App 默认的 HTTPS 会信任系统 CA于是流量整个被截走。要对抗这类抓包和中间人需要做证书锁定也就是 SSL Pinning。实现上通常有两个层次锁定公钥把服务端证书的公钥哈希内置在客户端校验证书时只比对公钥。锁定整个 CA 链校验完整的证书链指纹。我的经验是优先选公钥锁定因为证书会有轮换周期锁定整张证书会导致证书更新后 App 直接断网。公钥相对稳定一般证书更换不会动公钥。如果服务端证书是外包团队管着的最好先约定好“换证书必须提前通知客户端”不然线上事故等着你。写代码的时候直接用URLSession的delegate实现URLSession:didReceiveChallenge:completionHandler:在回调里比对SecTrustCopyPinning涉及到的证书。第三方网络库像 AFNetworking 也提供AFSecurityPolicy里面有个SSLPinningMode可选项就是SSLPinningModePublicKey这个原理和自实现一致。5.2 设备信息、包信息与风控上报服务端联动不仅要做签名指纹设备信息和包信息的组合拳更实用。我在实际项目里采集了这些字段设备型号、系统版本、IDFV、IDFA注意合规Bundle ID、版本号、渠道标识屏幕尺寸、机型参数App 首次安装时间、更新次数聚合这些信息后服务端可以识别三类异常同一设备短时间安装大量不同“渠道包”、同一账号在极短时间内切换异常多的设备、包信息与真实发行版本不符。这里用的是非常朴素的风控思路不追求识别所有攻击者只追求能低成本地识别出那些用改包工具的“急性子”。举个例子改包的人为了快速分发通常会替换掉 Bundle ID而一换 Bundle ID如果 SDK 里面也读到了这个 ID 就会导致统计异常。服务端只要做一次“上报包名 vs 请求签名中的 bundle ID vs 注册设备时的 bundle ID”的一致性校验就能把这类请求标记出来。这比客户端里写 10 个检测点都稳。5.3 自动化操作与调试痕迹识别攻击者不仅会静态改包还会用自动化工具批量操作你的 App。最常见的场景是用抓包工具改了请求参数再配合脚本刷接口。这类问题不在“文件加固”范畴却是一个完整加固方案必须考虑的部分。我的做法是让客户端在关键业务请求里附加一次性签名把时间戳、随机数和核心参数一起做 HMAC-SHA256服务端收到后校验签名和时效性。这样攻击者即使看到了请求内容也没法简单重放。还加了一层轻量的行为检测比如某个设备在 1 秒内发起了 20 次相同接口请求、点击路径异常、频繁获取验证码等服务端直接触发风控策略。自动化操作识别这块不用做太重我见过一些团队给自己上“模拟点击轨迹分析”成本和误报都吃不消。除非业务是签到、抽奖等高价值场景否则用频率和签名两个维度已经能拦住绝大多数批量脚本。5.4 异常事件上报的参数陷阱服务端联动必不可少的一步是客户端把异常事件上报上来。但这里有个很常见的陷阱上报的接口本身就是攻击者重点关注的对象如果上报逻辑写得过于简单检测代码反而成了“信号灯”——攻击者会先观察你在检测什么再针对性地绕过。我的处理方式是上报接口路径采用和正常业务路径相似的结构避免一眼看出是/report/security上报数据不用明文 JSON直接复用业务加密通道的格式上报时机随机化不在检测到异常后立刻上报而是延迟几个请求之后混在正常日志里带出这套做法不能说完全有效但它能给反制带来不小的麻烦。你的检测代码一旦变成了“明文信号灯”对方只要照着灯的位置按图索骥就能逐步定位到所有加固点。隐藏上报通道本身也是在保护前面做的那些加固工作。6. 常见问题排查与经验笔记6.1 加固后常见问题速查表这部分把我在项目中实测踩过、也帮同行排查过的问题整理成一张速查表方便大家按症状定位。症状可能原因排查方向加固后启动崩溃字符串解密时机过早key 还没拼接完成用 Xcode 的 Address Sanitizer 检查延迟解密调用到使用点附近越狱检测误杀正常用户检测项过宽比如检测到了 AFC 目录调低强信号判定改成弱信号上报资源包校验偶发失败分块大小和构建脚本里的块大小不一致校验块大小配置构建产物哈希是否一致App Store 审核被拒二进制中包含大量不可读暗文控制字符串加密范围敏感字符串加密业务字符串保留明文企业分发包无法启动Team ID 校验写死成了线上证书区分 Release 和 Enterprise 模式单独维护合法证书列表证书锁定后接口访问失败服务端证书轮换客户端公钥未同步内置多把备用公钥或提前发布版本覆盖重打包后行为无异常但服务端统计奇异攻击者只改了图标和 plist核心包未动结合设备指纹和频率做风控别依赖单一检测点加固后性能下降明显全量哈希校验放在主线程把哈希分块调度到后台队列或改启动时只校验头部块6.2 工具链小抄做这类工作手边工具要熟。不需要每个都用得很深但每个负责什么事情得有数。unzip/zipinfo快速解包查看 IPA 内部结构file识别二进制架构和类型strings扫二进制里的可打印字符串验证字符串加密是否遗漏nm查看符号表验证 strip 是否生效otool -L查看动态库依赖codesign -d --entitlements :-查看签名权限security cms -D -i embedded.mobileprovision解析描述文件内容plutil -p Info.plist查看 plistlibimobiledevice系列工具在测试机上安装、查看描述文件这些工具在 macOS 命令行里都能直接装属于最常规的操作集。写自动化 CI 脚本的时候把codesign、unzip、zip串起来做一个“重打包自检流水线”每次发版前自动验证一次加固效果非常值得。6.3 我的几个实测结论最后记几条用在实操里的结论都是花了不少时间试出来的。第一资源加密和完整性校验对整个包体积影响通常小于 5%但对启动时间的影响要单独测。加密容器如果采用全量解密内存占用会突然多出一截低端机上很容易触发 jetsam。我最后把大的离线包切成多个分片按需解密加载内存曲线才算稳下来。第二越狱检测不要做得太绝对宁可多上报不少杀。iOS 圈子里的安全反馈链路比较特殊用户用不了 App 时不会跟你说“我是越狱用户”只会给你打一星差评。检测逻辑做成信号上报不做本地硬拦截是我反复测试之后最省事的方案。第三二次打包者最大的软肋其实是服务端。他们自己有开发者证书能解决“怎么装上”但很难解决“装完之后的行为和真包一致”。只要服务端有包信息核验和风控策略就能在分发源头上把脏流量挡住。客户端加固是前置防线服务端联动才是不可绕过的底线。这套加固流程做完一遍再遇到市场上出现换皮包我从发现到确权的时间从过去的两三天缩短到了几小时。希望这篇文章的思路和踩坑记录能帮同行少走点弯路。
返回列表