每隔一阵就有人问我:游戏上了 iOS,加固这块是不是能省点力气?苹果生态封闭、有沙箱、有强制签名,总归比 Android 稳。但这话只对了一半。
沙箱和签名管的是应用隔离、代码来源和权限,游戏里那套代码、资源、协议签名的逻辑可不归他们管。攻击者盯的从来不是整个 iOS,是把 Mach-O 拖进 IDA 读懂判断、把资源导出来,再在运行时把某个 if 改掉。
一个未加固的 Unity iOS 包里,能下手的入口不少:主二进制里的 IL2CPP 代码(UnityFramework.framework/UnityFramework)、Data/Managed/Metadata/global-metadata.dat、AssetBundle、Lua 字节码、配置表,还有热更下发的那一套。LC_ENCRYPTION_INFO_64 那层 FairPlay 只对 App Store 分发生效,就算有,越狱机上 frida-ios-dump、dumpdecrypted 也能把解密后的页从内存 dump 成能分析的 Mach-O。所以方案够不够,不看勾没勾越狱检测、给哪个文件套了 AES,看它下面这几层覆盖得齐不齐。
代码保护,得做进 Xcode 编译阶段
很多项目 Strip 一把,IDA 里满屏 sub_XXXXXX,就觉得逻辑藏住了。没有。符号表删了,__TEXT,__cstring 里的明文字符串和交叉引用勾出的调用关系一点没少。一段发奖判断没名字,顺着奖励文案和接口字符串照样定位:
// 名字没了,但上面挂着 "reward_daily" 字符串引用、下面调了网络提交
if ( v3 >= *(int *)(a1 + 0x18) ) // itemCount >= requiredCount
sub_1008A31C0(a1); // 实为 GrantReward
要抬逆向成本,得动字符串和控制流。字符串在编译期加密、用到才还原,strings 就扫不出接口和字段名;控制流做混淆,平坦化把顺序执行拆成一个 dispatcher 分发、再叠指令替换和虚假分支,反编译出来不再是一条直线读下去。这类 Pass 有现成的,比如 OLLVM 的 -fla -sub -bcf,不过兼容性跟 LLVM 版本和工具链有关,不同项目用的不一定是这套。战斗结算、资源解密、协议签名、安全检测这几类函数尤其值得盖。

同样要紧的是这些保护做在哪个环节。一种做法是包编译完之后加一层壳,把二进制整个加密、运行时再解开;问题是这层是外挂上去的,调试器 attach 上去等它自解,或直接从内存 dump 出解开后的代码,壳就绕过去了。另一种是把混淆和加密做进 clang 编译流水线:字符串在编译期就成了密文常量,控制流在生成机器码时打乱,符号不写进产物,保护和真实代码编译在一起,没有单独一层壳可脱。这也是编译阶段的保护和成品包套壳,长期看强度拉开的原因。
IL2CPP:代码和 metadata 要一起锁
global-metadata.dat 是 IL2CPP 游戏里线索最多的入口,类名、方法名、字段、类型关系全从这儿恢复。文件在 .app/Data/Managed/Metadata/,头四字节 magic 0xFAB11BAF(内存里 AF 1B B1 FA),和 UnityFramework 一起喂给 Il2CppDumper,sub_XXXX 全还原成 PlayerController$$AddGold,连 IDA 脚本带 stub DLL 一起生成好。

加密这文件,只挡得住解包后批量 dumper 这条路。一来是游戏一跑起来,metadata 必然在内存里解成明文,frida-il2cpp-bridge 不碰你的密钥,等进程解开、按 magic 搜出来 dump 走,名字原样又回来;二来就算你死活不让它解,Il2CppCodeRegistration / Il2CppMetadataRegistration 两张原生注册表还躺在二进制里,方法指针、类型布局照样能重建,只是少了可读名。真正耐用的是在 IL2CPP 编译之前就把 C# 类名方法名混淆掉:编进字符串表的本就是 a1、b2,就算被 dump、被重建,落到手里也只剩 sub_xxxx。这几样还得配上运行时反 dump,不然名字混了、文件也加密了,有人照样能等你在内存里解开再整包捞走。
资源:包内热更统一护,加解密还得动态分块
资源最容易漏的是覆盖面:首包 AssetBundle 加了密,热更下发的 .bundle 却是明文。皮肤、活动配置、关卡大多靠热更持续推,攻击者不碰你首包,去更新缓存 grep 一下 UnityFS(55 6E 69 74 79 46 53)就把新资源 carve 出来,AssetStudio 一拖导成 PNG / FBX。首包、热更、版本清单、配置表最好用同一套保护,漏掉一类,前面加密的意义就打了折。
覆盖全了,还有个加解密方式的问题。最省事的是整包加密、加载时整包解开:
byte[] plain = Decrypt(File.ReadAllBytes(path), key);
AssetBundle ab = AssetBundle.LoadFromMemory(plain); // plain 就是完整明文 UnityFS
hook 住 LoadFromMemory,把传进去的 plain dump 下来就是整包明文,你的 Decrypt 再复杂也没参与进去;Lua 同理,进 luaL_loadbuffer 的就是解密后的 bytecode。
整包解还有性能代价:大 bundle 一次性解,内存尖峰、加载卡顿,低端机上更明显。更实际的做法是动态分块,只解当前要用的那块、用完释放,密钥按资源从内容 hash 派生,解出一块也开不了全套,解密放在 native、不落 C# 托管堆。这样内存里的明文一直是零碎小片,加载和内存占用也更稳。
运行时得持续扛:越狱、注入、修改器
靠探几个固定路径来判越狱的老思路,现在基本失效。Dopamine、palera1n 这些 rootless 越狱把整套环境挪去了 /var/jb,/Applications/Cydia.app、/bin/bash 这些老路径根本不在;就算补上新路径,Shadow、A-Bypass 这类插件会直接 hook 检测用的 stat、access,回一个正常结果。

别再靠一张路径表判越狱,何况运行时要防的也不止越狱。能看的信号还有沙箱里本不该成功的 fork、被注入进来的可疑动态库(_dyld_get_image_name() 里冒出 frida、substrate 这类)、Frida 的特征端口 27042。但这些都只是特征,端口能改、库名能伪装、线程名能换,任何一个单独拿出来都不足以定性,靠得住的是多个信号一起看、互相印证,不是查到某个端口就当抓到了 Frida。
越狱和注入之外,还有直接冲着数值来的:内存修改器扫进程内存改金币、血量,变速工具通过 hook clock_gettime 这类时间函数改游戏内的时间流速。这类靠路径和注入信号盖不住,得另外盯关键数值的完整性,以及进程里有没有挂上来的修改器和被动过的时间源。
更要命的是检测函数本身也会被改,最常见就是把所有检查收进一个总开关:
bool isSafeEnvironment() {
return checkJailbreak() && checkDebugger() && checkSignature();
}
一个 MOV W0, #1 ; RET 把返回值改成 true,前面查得再全也一起失效。为什么单点这么脆?因为一整套判断收敛成一个 bool,patch 一处就全关。
所以检测别汇成单一返回值:多点分散、把结果掺进后续逻辑、下沉到 native。这些工具和样本也一直在更新,检测特征不是写死一次就一劳永逸,运行时防护是一场持久战。
加固到最后,要和反外挂拧成一股
前面四层把客户端做硬,可运行时抓到越狱、hook、修改器,结论总得有人接。客户端自己接就是送人头:本地一个 safe: true、App 自己信,被 hook 成永远 true 或上报被掐都一样,「干净设备没上报」和「被改了把上报掐了」在后端没区别。所以检测结果得 HMAC 签名、带 nonce 当证据送出去,「该报没报」本身也算信号。反过来,反外挂逻辑没加固垫底,自己就是最先被 patch 的那个。这两件事本是一套:加固让信号可信,反外挂给信号落点;至于金币掉落战斗结果最终怎么判,归反外挂,跟加固各管一段。
回到标题那句「至少要保护什么」。加密了、测了越狱这种单项谁都会勾,可拆掉一个不碍下一个。真正的标准是从编译期代码、IL2CPP、资源到运行时、再到反外挂,连成一条没有断点的线。缺哪一环,攻击者就从哪一环进,前面几环白垒。