资讯动态

Unity 3D手游Android安全攻防实战:从逆向拆解到多层防护

发布时间:2026/9/20 4:27:29 来源:尧图企业网站定制
1. 从一款Unity 3D手游被“扒光”说起前阵子帮一个做独立游戏的朋友看他的Unity 3D项目一款已经上架的休闲竞技手游。他遇到的问题是上线不到两周某渠道就出现了“无限金币版”而且更离谱的是有人直接把他的APK反编译后重新打包内置了自动脚本在低端机上跑得比原版还流畅。他问我“我这游戏到底是怎么被搞的我该怎么防”这个问题其实非常典型。Android平台上的Unity 3D手游安全本质上是一个“攻防不对称”的战场。攻击者只需要找到一个入口而防守方要把所有入口都堵上。很多开发者以为用了Unity引擎、代码编译成IL2CPP、再打个包就安全了实际上从APK结构、资源文件、内存数据到网络通信每一个环节都可能成为突破口。这篇文章适合三类人看一是正在用Unity做Android手游、准备上线或已经上线的开发者二是负责手游客户端安全的技术同学三是对移动端逆向与防护感兴趣、想了解实际对抗思路的工程师。我会从Unity 3D手游在Android上的实际结构讲起拆解常见的攻击路径然后给出可落地的防护方案和实操步骤。所有内容基于我在实际项目中踩过的坑和验证过的方案不玩虚的。2. Unity 3D手游在Android上的真实结构长什么样2.1 APK里到底装了什么很多人天天打包APK但未必认真拆过自己的包。一个典型的Unity 3D Android手游APK解压后大致是这样的结构classes.dexJava层代码通常只有UnityPlayerActivity、广告SDK、支付SDK等少量内容。lib/native库目录包含libunity.so、libil2cpp.so、libmonobdwgc-2.0.so等。这是核心逻辑所在。assets/bin/Data/Unity的资源与数据目录包含globalgamemanagers、level0、resources.assets、Managed/如果用的是Mono或il2cpp相关元数据。assets/bin/Data/Managed/Mono模式下这里就是你的C#编译后的DLL比如Assembly-CSharp.dll。res/、AndroidManifest.xml、META-INF/标准Android组件。关键点在于如果你用的是Mono后端Assembly-CSharp.dll几乎是明文可读的。用dnSpy或ILSpy打开类名、方法名、字符串常量一览无余。这就是为什么很多小团队的游戏被秒破——攻击者连逆向的力气都省了。2.2 IL2CPP到底改变了什么切换到IL2CPP后C#代码先被转成C再编译成native的libil2cpp.so。这确实提高了门槛因为攻击者面对的是ARM汇编而不是IL字节码。但“提高门槛”不等于“安全”。IL2CPP模式下有几个东西仍然暴露global-metadata.dat位于assets/bin/Data/Managed/Metadata/包含了所有的类名、方法名、字段名、字符串字面量等元数据。用Il2CppDumper这类工具可以直接把符号还原出来。字符串常量即使代码被编译成native字符串仍然以某种形式存在于二进制或元数据中。攻击者可以通过字符串搜索快速定位关键逻辑。函数地址一旦符号被还原攻击者可以在libil2cpp.so中找到对应函数的偏移然后用Frida等工具进行Hook。我实测过一个中等规模的Unity 3D项目用Il2CppDumper处理libil2cpp.so加global-metadata.dat大概三分钟就能输出一份可读的符号表。这意味着攻击者可以精准定位到“扣金币”“加钻石”“判断是否付费”这些函数。2.3 资源文件与配置的暴露面Unity的assets/bin/Data/目录下resources.assets、level0等文件包含了场景、预制体、ScriptableObject等。用AssetStudio或UABE可以直接浏览和导出。很多游戏把数值配置直接放在ScriptableObject里比如武器伤害、掉落概率、内购价格表。攻击者改一个数字重新打包就是一个“破解版”。更隐蔽的是有些团队把加密密钥、API地址、签名盐值硬编码在C#里或放在配置文件中。Mono模式下这些直接可读IL2CPP模式下也能通过字符串搜索找到。3. 攻击者到底怎么下手常见攻击路径拆解3.1 静态逆向从APK到可读代码攻击者的第一步通常是静态分析。流程大致是用apktool反编译APK拿到smali和资源。如果是Mono直接用dnSpy打开Assembly-CSharp.dll。如果是IL2CPP用Il2CppDumper处理libil2cpp.so和global-metadata.dat得到符号和伪代码。用IDA或Ghidra分析libil2cpp.so结合符号表定位关键函数。我见过最“懒”的攻击方式是直接用dnSpy找到PlayerPrefs相关的读写因为很多游戏把金币、关卡进度存在PlayerPrefs里。改一个值重新签名就是一个“无限金币版”。3.2 动态HookFrida与Xposed的实战静态分析找到目标后动态Hook是验证和利用的手段。Frida在Android上的使用非常成熟// 示例Hook一个假设的扣金币函数 Java.perform(function() { var GameManager Java.use(com.example.game.GameManager); GameManager.deductCoins.implementation function(amount) { console.log(原始扣金币: amount); // 直接返回true不扣金币 return true; }; });对于IL2CPP的native函数Frida也可以通过基址加偏移的方式Hookvar base Module.findBaseAddress(libil2cpp.so); var offset 0x123456; // 通过IDA找到的函数偏移 Interceptor.attach(base.add(offset), { onEnter: function(args) { console.log(函数被调用); }, onLeave: function(retval) { retval.replace(0x1); // 强制返回成功 } });Xposed则更适合在Root环境下做全局修改比如拦截SharedPreferences的读写、修改Activity的返回值等。3.3 内存修改GameGuardian与CE对于数值类作弊内存搜索工具是最直接的。GameGuardian在Android上可以搜索并修改内存中的数值。比如金币显示100搜索100花掉10变成90再搜索90就能定位到金币的内存地址。然后直接改成999999。防御内存修改的思路是不要把关键数值以明文形式长期存在内存中或者做校验和、加密存储、频繁变换地址。3.4 重打包与签名绕过攻击者修改完代码或资源后需要重新打包并签名。Android的签名校验如果只在Java层做很容易被绕过。常见手法是用apktool重新打包然后用自己的签名。如果游戏有签名校验用Frida Hook掉校验函数或者直接修改smali跳过校验。有些渠道包甚至不需要重新签名因为渠道SDK本身就有漏洞。我见过一个案例游戏在onCreate里校验签名但校验失败只是弹个Toast没有终止进程。攻击者把Toast去掉游戏照常运行。4. 防护方案从代码到运行时的多层防御4.1 代码混淆与元数据保护Mono模式下必须对Assembly-CSharp.dll做混淆。常用的工具是Obfuscar或ConfuserEx。混淆的目标是让类名、方法名、字段名变成无意义的字符串增加逆向难度。但要注意Unity的序列化机制依赖字段名过度混淆可能导致预制体丢失引用。我的经验是只混淆逻辑代码不混淆MonoBehaviour的字段名或者使用[Obfuscation]特性做精细控制。IL2CPP模式下重点是保护global-metadata.dat。有几个方向元数据加密在加载时解密运行时在内存中还原。这需要修改Unity的加载流程通常通过自定义的libil2cpp.so或注入方式实现。符号剥离编译时开启Strip Engine Code移除未使用的引擎代码。但这不影响自定义代码的符号。字符串加密对关键字符串如API地址、密钥、函数名做加密存储运行时解密。避免在二进制中直接搜索到。我实际用过的一个方案是在IL2CPP编译后对global-metadata.dat做AES加密然后在libil2cpp.so的il2cpp_init之前插入解密逻辑。这样Il2CppDumper直接处理会失败因为元数据是加密的。当然攻击者如果动态调试仍然可以在内存中dump出解密后的元数据但这已经把门槛提高了很多。4.2 native层校验与反调试在libil2cpp.so之外可以再写一个自定义的native库比如libsecurity.so负责以下工作签名校验在native层读取APK的签名信息与预期值比对。native层校验比Java层难绕过因为攻击者需要修改so文件或Hook native函数。反调试检测ptrace、/proc/self/status中的TracerPid、调试端口等。如果发现调试器直接退出或触发异常。完整性校验计算APK中关键文件如libil2cpp.so、global-metadata.dat的哈希值与预期值比对。防止重打包。反Hook检测Frida的典型特征比如/data/local/tmp/frida-server、frida相关的端口、内存中的frida字符串等。一个简单的反调试native代码示例#include stdio.h #include unistd.h #include string.h int is_debugger_attached() { FILE *f fopen(/proc/self/status, r); if (!f) return 0; char line[256]; while (fgets(line, sizeof(line), f)) { if (strncmp(line, TracerPid:, 10) 0) { int pid atoi(line 10); fclose(f); return pid ! 0; } } fclose(f); return 0; }在JNI_OnLoad中调用这个函数如果检测到调试器可以延迟退出或触发一个难以定位的崩溃。4.3 数据加密与内存保护关键数值不要以明文形式长期存在内存中。我的做法是金币、钻石等数值存储时用异或或AES加密使用时解密到临时变量用完立即清零。内存中不保留明文。频繁变换内存地址对于特别敏感的数值可以定期在堆上重新分配把旧地址的数据擦除。校验和对关键数值计算校验和定期验证。如果被修改校验和会不匹配。双变量冗余存两个变量一个存值一个存值的某种变换。使用时比对两者是否一致。这些方法不能完全阻止内存修改但能大幅增加攻击者的工作量。GameGuardian这类工具擅长搜索明文数值如果数值是加密的搜索难度会急剧上升。4.4 网络通信与服务器校验客户端防护再强也不如服务器校验可靠。核心原则是客户端只做展示和输入关键逻辑和数值由服务器裁决。内购校验客户端收到支付回调后把订单信息发给服务器服务器向支付平台验证验证通过后再发放道具。不要信任客户端传来的“支付成功”。数值同步金币、钻石等关键数值以服务器为准。客户端本地修改后下次同步会被服务器覆盖。操作频率限制服务器对关键操作做频率限制比如每秒最多扣一次金币防止脚本刷。通信加密使用HTTPS并对关键请求做签名。签名密钥不要硬编码在客户端或者至少做加密和混淆。我见过一个反面案例游戏的内购逻辑完全在客户端客户端收到Google Play的回调后直接加钻石然后异步通知服务器。攻击者用Frida Hook掉回调直接调用加钻石的函数服务器收到通知时已经晚了。5. 实操从零搭建一个基础防护框架5.1 环境准备与工具选型假设你有一个Unity 3D项目已经能打出Android APK。我们需要做以下几件事Unity版本2021.3 LTS或更高使用IL2CPP后端。Android Studio用于编写和编译native库以及查看APK结构。NDK与Unity的IL2CPP编译版本匹配通常Unity会自带。Obfuscar用于Mono模式的代码混淆如果还在用Mono。自定义native库用Android Studio创建一个libsecurity模块。工具选型的原则是优先用成熟、维护活跃的工具避免自己造轮子。Obfuscar虽然老但稳定Frida检测可以用开源的方案但要注意更新特征库。5.2 编写native安全库在Android Studio中创建一个Native C项目包名可以是com.yourgame.security。核心功能包括// security.cpp #include jni.h #include string #include sys/ptrace.h #include unistd.h #include fcntl.h static bool checkTracerPid() { int fd open(/proc/self/status, O_RDONLY); if (fd 0) return false; char buf[4096]; ssize_t n read(fd, buf, sizeof(buf) - 1); close(fd); if (n 0) return false; buf[n] \0; char *p strstr(buf, TracerPid:); if (!p) return false; int pid atoi(p 10); return pid ! 0; } static bool checkFrida() { // 检测常见Frida特征 const char *paths[] { /data/local/tmp/frida-server, /data/local/tmp/re.frida.server, /system/lib/libfrida-gadget.so, /system/lib64/libfrida-gadget.so }; for (auto path : paths) { if (access(path, F_OK) 0) return true; } return false; } extern C JNIEXPORT jboolean JNICALL Java_com_yourgame_security_SecurityManager_isSafe(JNIEnv *env, jobject thiz) { if (checkTracerPid()) return JNI_FALSE; if (checkFrida()) return JNI_FALSE; return JNI_TRUE; }然后在Java层调用public class SecurityManager { static { System.loadLibrary(security); } public static native boolean isSafe(); }在UnityPlayerActivity的onCreate中调用SecurityManager.isSafe()如果返回false可以延迟退出或触发一个难以定位的崩溃。5.3 集成到Unity项目Unity打包Android时会把Assets/Plugins/Android/下的内容合并到APK中。你可以把编译好的libsecurity.so放到Assets/Plugins/Android/libs/armeabi-v7a/和arm64-v8a/下。Java类可以编译成jar或aar放到Assets/Plugins/Android/下。在Unity中通过AndroidJavaClass调用using UnityEngine; public class SecurityCheck : MonoBehaviour { void Awake() { using (AndroidJavaClass securityClass new AndroidJavaClass(com.yourgame.security.SecurityManager)) { bool isSafe securityClass.CallStaticbool(isSafe); if (!isSafe) { // 延迟退出增加攻击者定位难度 Invoke(ForceQuit, 3.0f); } } } void ForceQuit() { Application.Quit(); } }5.4 元数据加密的实操思路元数据加密需要修改Unity的加载流程比较复杂。一个相对简单的方案是在IL2CPP编译后用脚本对global-metadata.dat做加密然后在libil2cpp.so的il2cpp_init之前插入解密逻辑。具体步骤编译出libil2cpp.so和global-metadata.dat。用Python脚本对global-metadata.dat做AES加密密钥硬编码在native库中或做进一步混淆。写一个native函数在JNI_OnLoad中调用解密global-metadata.dat到内存然后让il2cpp_init从内存加载。这需要修改Unity的源码或使用hook方式门槛较高。如果团队没有native逆向能力建议优先做其他防护。我实际项目中元数据加密是最后才上的因为维护成本高每次Unity升级都要重新适配。对于大多数团队代码混淆加native校验加服务器校验已经能挡住90%的攻击者。6. 常见问题与排查技巧实录6.1 混淆后游戏崩溃怎么办这是最常见的问题。原因通常是混淆了Unity依赖的字段名或类名。排查步骤先关闭混淆确认游戏正常。开启混淆但排除所有MonoBehaviour的子类。如果还有问题逐步缩小混淆范围定位到具体类。使用Obfuscar的SkipType或SkipMethod特性排除关键类。我的经验是只混淆非Unity的纯逻辑类比如GameManager、DataManager但保留字段名。或者使用[Obfuscation(Exclude false, Feature renaming)]做精细控制。6.2 Frida检测被绕过怎么办Frida的检测和反检测是持续对抗。攻击者可以修改Frida的特征比如重命名frida-server、修改端口、使用非标准路径。所以检测不能只依赖文件路径。更可靠的方式是检测/proc/self/maps中是否有可疑的so映射。检测线程名中是否有gum-js-loop、gmain等Frida特征。检测内存中是否有frida相关的字符串。使用ptrace自我附加防止其他进程附加。但要注意过度检测可能导致误报影响正常用户。建议在检测到可疑行为时不要立即退出而是上报服务器由服务器做进一步判断。6.3 重打包后签名校验失效签名校验如果只在Java层做很容易被绕过。建议在native层做并且不要只校验一个点。可以在JNI_OnLoad中校验。在关键函数调用前校验。校验APK的多个文件哈希而不仅仅是签名。把校验结果加密后传给服务器由服务器判断。另外不要在校验失败时弹明显的Toast或对话框这会告诉攻击者“这里就是校验点”。最好是静默失败或者触发一个看起来像正常崩溃的异常。6.4 内存修改的防御效果评估内存修改的防御没有“银弹”。加密存储、校验和、双变量冗余都只能增加攻击者的工作量。我的建议是对于单机游戏接受一定程度的修改重点保护内购和广告。对于联网游戏关键数值以服务器为准客户端修改无意义。定期更新防护策略关注新的作弊工具和手法。下面是一个常见问题速查表问题现象可能原因排查方向解决思路混淆后预制体丢失引用字段名被混淆检查MonoBehaviour字段排除MonoBehaviour或保留字段名Frida附加后游戏无反应反调试生效检查TracerPid检测调整检测逻辑避免误报重打包后游戏闪退签名校验失败检查native校验逻辑确保校验在关键路径上金币被修改内存明文存储用GameGuardian测试加密存储加校验和内购被绕过客户端信任回调检查支付流程服务器验证订单7. 一些实战中的个人体会做了几个Unity 3D手游的安全防护后我最大的体会是安全是一个成本问题不是技术问题。你不可能做到绝对安全但可以把攻击成本提高到攻击者觉得不划算的程度。对于小团队优先级应该是服务器校验 代码混淆 native校验 元数据加密。服务器校验是性价比最高的因为不需要在客户端做太多复杂的东西而且效果最直接。代码混淆是基础至少不要让攻击者用dnSpy就能看懂你的逻辑。native校验和元数据加密是进阶手段适合有一定技术积累的团队。另外不要忽视日志和监控。在关键路径上打点上报异常行为比如短时间内大量金币变化、异常的API调用频率、来自同一设备的多个账号等。这些数据可以帮助你及时发现新的攻击手法。最后分享一个小技巧在游戏里埋一些“蜜罐”函数这些函数看起来像是关键逻辑但实际上永远不会被正常调用。如果这些函数被调用了说明攻击者正在逆向你的游戏。你可以把调用信息上报服务器甚至触发一些反制措施。这个思路来自传统的网络安全在手游安全里同样适用。安全对抗是持续的没有一劳永逸的方案。保持关注新的工具和手法定期更新防护策略才是长久之道。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价