Unity Mono安卓游戏逆向实战:Frida Hook绕过碰撞死亡判定

Unity Mono安卓游戏逆向实战:Frida Hook绕过碰撞死亡判定

1. 项目概述:一次Unity Mono安卓游戏逆向实战

最近在分析一款基于Unity引擎、采用Mono后端开发的安卓游戏时,遇到了一个典型的“碰撞即死亡”的判定机制。对于想要深入研究游戏逻辑或者进行一些定制化修改的开发者来说,绕过这种核心判定往往是第一步。这次,我们不谈那些复杂的脱壳、加固对抗,就从最实际的游戏逻辑修改入手,分享一下如何定位并利用Frida Hook技术,精准地“欺骗”游戏的碰撞死亡判定,让角色实现“无敌”状态。整个过程涉及Unity游戏结构分析、Mono运行时函数定位、Frida脚本编写与注入,算是一次比较完整的安卓端Unity游戏逆向小实战。

无论你是移动安全研究员、游戏外挂开发者(当然,请在合法合规的范围内进行研究),还是单纯对Unity游戏内部机制感到好奇的玩家,这篇记录都能提供一个清晰的路径。我们假设你已经有基础的Android逆向知识(比如会使用adb、了解smali/Java层Hook),并且对Frida工具有初步接触。这次的核心将集中在Unity C#层的逻辑分析与拦截上。

2. 前期准备与环境搭建

工欲善其事,必先利其器。在开始Hook之前,我们需要准备好相应的分析环境和工具链。这次实战不需要复杂的脱壳机或硬件,大部分工作在一台配置好的PC上即可完成。

2.1 核心工具清单与配置

首先,确保你的实验环境包含以下工具:

  1. 一部已Root的安卓手机或模拟器:这是Frida工作的基础。推荐使用官方Android Studio自带的模拟器(API级别不用太高,兼容性好即可),并刷入Root权限。真机也可以,但需要注意系统版本与Frida的兼容性。
  2. Frida环境:包括PC端的Frida-tools和移动端的Frida-server。
    • PC端安装:pip install frida-tools
    • 移动端:根据你的设备架构(通常是arm或arm64),从Frida的GitHub releases页面下载对应版本的frida-server,通过adb push推送到设备,并赋予可执行权限后运行。
  3. 逆向分析工具
    • Jadx-GUI:用于静态分析APK的Java层代码,寻找UnityPlayer等初始化入口。
    • Il2CppDumper注意,我们的目标是Mono后端,而不是Il2Cpp。所以这个工具这次用不上,但需要明确区分。对于Mono,我们需要其他方法获取C#类信息。
    • UABE (Unity Assets Bundle Extractor)AssetStudio:用于解包游戏的assets目录下的资源文件,特别是global-metadata.datAssembly-CSharp.dll(或类似的托管DLL)。对于Mono游戏,关键的C#逻辑就编译在这些DLL里。
    • dnSpyILSpy:强大的.NET反编译工具,用于打开并分析从游戏包中提取出来的Assembly-CSharp.dll文件。这是我们分析C#逻辑、寻找目标函数的主战场。
  4. ADB (Android Debug Bridge):用于连接设备、安装APK、端口转发等基本操作。

注意:市面上很多Unity游戏已转向Il2Cpp后端以提升性能和安全性。区分方法是:查看APK的lib目录,如果存在libil2cpp.so,则是Il2Cpp;如果存在libmono.so(或libunity.so中集成了Mono),则是Mono。本文方法仅适用于Mono后端。

2.2 目标游戏分析与文件提取

首先,将目标游戏的APK文件通过adb install安装到设备上。然后,使用adb shell pm path <package.name>找到APK的安装路径,再通过adb pull将其拉取到本地。

使用任意压缩软件打开APK,我们需要关注两个地方:

  1. assets/bin/Data/Managed/:这个目录下通常存放着编译好的.NET DLL文件,尤其是Assembly-CSharp.dll。这个文件包含了游戏开发者编写的大部分游戏逻辑C#代码。将其提取出来。
  2. assets/bin/Data/目录下可能存在的global-metadata.dat:对于较新版本的Unity Mono,可能需要这个文件配合才能正确解析DLL中的一些元数据。一并提取。

将提取出的Assembly-CSharp.dll用dnSpy打开。现在,你看到的将是游戏逻辑的“源代码”(虽然经过了编译和混淆,但结构基本清晰)。

3. 逆向核心:定位碰撞死亡判定逻辑

这是整个过程中最考验耐心和逆向思维的一步。我们面对的可能是一个经过混淆的、结构复杂的大型DLL。如何从中找到“碰撞死亡”这个具体的逻辑点?

3.1 静态分析与关键词搜索

在dnSpy中,我们可以利用其强大的搜索功能。思考与“碰撞死亡”相关的关键词:

  • 方法名可能包含OnCollisionEnter,OnTriggerEnter,Die,Kill,TakeDamage,ApplyDamage,SetHealth,isDead
  • 类名可能包含Player,Character,Health,Damageable,GameManager
  • 字符串搜索:在dnSpy中搜索游戏内死亡时可能出现的文本,比如“You Died”、“Game Over”、“生命值”等。找到引用这些字符串的代码位置,顺藤摸瓜。

通常,角色的死亡逻辑会集中在一个管理角色状态或生命值的类中,例如PlayerHealthCharacterController。我们需要找到那个在发生碰撞后被调用的、最终将角色生命值设为0或触发死亡动画的方法。

实操心得:不要只盯着一个关键词。有时开发者会使用自定义的方法名。可以先尝试搜索CollisionTrigger,找到所有碰撞相关的函数,然后查看哪些函数内部调用了可能改变生命值或状态的函数。另外,关注UpdateFixedUpdate方法中是否有持续检测状态并触发死亡的逻辑。

3.2 动态验证与逻辑梳理

仅仅静态分析可能不够,因为代码可能有分支或复杂的条件判断。这时,可以结合简单的动态调试或日志输出来验证。

一个低技术门槛的方法是:在怀疑的关键方法里,寻找是否有调用Debug.Log或类似输出日志的语句。如果没有,我们可以计划在Hook时添加日志。但首先,我们需要通过静态分析,构建出大致的逻辑链条。

假设我们最终定位到了一个疑似处理伤害的函数:

public class PlayerHealth : MonoBehaviour { public int currentHealth; public void TakeDamage(int damageAmount) { if (this.isInvincible) return; // 无敌状态判断 currentHealth -= damageAmount; if (currentHealth <= 0) { this.Die(); // 调用死亡函数 } } private void Die() { // 播放死亡动画、弹出UI等 this.isDead = true; GameManager.Instance.GameOver(); } }

而触发TakeDamage的,可能是在另一个处理物理碰撞的类中:

public class PlayerCollision : MonoBehaviour { void OnCollisionEnter(Collision collision) { if (collision.gameObject.tag == “Enemy”) { PlayerHealth health = GetComponent<PlayerHealth>(); if (health != null) { health.TakeDamage(10); // 这里就是我们要Hook的入口点! } } } }

我们的目标变得非常明确:拦截PlayerCollision.OnCollisionEnter方法,或者更直接地,拦截PlayerHealth.TakeDamage方法,使其不执行减血逻辑。

4. Frida Hook 方案设计与实现

找到目标函数后,接下来就是用Frida编写Hook脚本。Frida提供了强大的Interceptor功能来拦截Native函数,但对于Unity C#的Mono虚拟机中的托管函数,我们需要使用Frida的Mono插件。

4.1 Frida Mono插件基础

Frida的Mono模块允许我们附加到Mono运行时,枚举类、方法,并Hook它们。核心步骤通常包括:

  1. 枚举程序集(Assembly)和类(Class):找到包含我们目标代码的模块。
  2. 枚举方法(Method):在类中找到具体的方法。
  3. 附加钩子(Attach):在方法的入口点或出口点插入我们的JavaScript回调函数。

一个典型的HookTakeDamage方法的Frida JavaScript脚本骨架如下:

Java.perform(function () { // 首先确保Mono运行时可用 if (Mono === undefined) { console.log(“Mono runtime not found!”); return; } // 获取Mono的Image,即程序集映像 Mono.assembly.load(“Assembly-CSharp”).then(function (assembly) { // 从程序集中获取类 var PlayerHealthClass = Mono.getClass(assembly.image, “”, “PlayerHealth”); if (PlayerHealthClass) { // 枚举类中的所有方法,寻找TakeDamage var methods = PlayerHealthClass.methods; for (var i = 0; i < methods.length; i++) { var method = methods[i]; // 通过方法名和参数签名来匹配 if (method.name === “TakeDamage” && method.paramCount === 1) { // 假设参数是一个int console.log(“[+] Found TakeDamage method: ” + method.name); // Hook该方法 Interceptor.attach(method.implementation, { onEnter: function (args) { // args[0] 是 this 对象(PlayerHealth实例) // args[1] 是第一个参数 damageAmount console.log(`[Hook] TakeDamage called on ${this.context.pc}`); console.log(` -> Instance: ${args[0]}`); console.log(` -> Damage Amount: ${args[1].toInt32()}`); // *** 关键操作:修改逻辑,绕过伤害 *** // 方案1:直接修改传入的伤害值为0 args[1] = ptr(“0”); // 方案2:更彻底,不让原函数执行任何逻辑,直接返回 // this.returnValue = ptr(“0”); // 如果需要返回值的话 }, onLeave: function (retval) { // 可以在这里检查函数执行后的状态 } }); break; } } } else { console.log(“[-] Could not find PlayerHealth class”); } }).catch(function (error) { console.log(“[-] Error loading assembly: ” + error); }); });

4.2 参数识别与上下文处理

上面的脚本有几个关键点需要根据实际情况调整:

  • 类名和方法名“PlayerHealth”“TakeDamage”必须与dnSpy中反编译出来的完全一致,包括命名空间。如果类在命名空间GameLogic.Player下,则类名应为“GameLogic.Player.PlayerHealth”
  • 参数签名method.paramCount和参数类型需要匹配。TakeDamage(int)有一个参数。如果方法是TakeDamage(int, GameObject),那么paramCount就是2。更精确的匹配可以通过method.signature进行。
  • args索引:在onEnter中,args[0]始终是this指针(对于实例方法),args[1]才是第一个实际参数,以此类推。
  • 修改逻辑:我们选择将传入的伤害值args[1]修改为0。这样,原函数TakeDamage依然会被调用,但计算currentHealth -= 0不会有任何效果。这是一种非常隐蔽的修改方式。你也可以在onEnter中直接this.returnValue提前返回,但需要确保返回类型(如果是void则不需要)。

常见问题:有时直接Mono.assembly.load可能找不到。可以尝试使用Mono.enumerateAssemblies()先列出所有已加载的程序集,找到正确的名称。

4.3 更稳健的Hook策略

如果游戏对类名、方法名进行了混淆,静态分析找到的准确名称可能会失效。此时,可以采取更动态或更底层的Hook策略:

  1. 特征码搜索:如果方法体的机器码有独特指令序列,可以尝试在内存中搜索这段特征码,定位方法地址后再Hook。这需要一定的汇编知识。
  2. Hook Unity引擎API:如果所有伤害最终都通过某个统一的引擎函数(例如,调用UnityEngine.Debug.Log来记录伤害),可以尝试Hook这个更底层的、不易变动的函数。但这种方式影响面可能过大。
  3. 枚举与过滤:编写脚本枚举所有类和方法,通过一些启发式规则(例如,方法包含“Damage”字符串、参数为int类型、被某个已知的碰撞相关类调用等)来缩小范围,然后批量Hook测试。

在我们的例子中,假设游戏没有强混淆,直接HookTakeDamage是最直接有效的。

5. 脚本注入与效果验证

编写好Frida脚本(保存为bypass_damage.js)后,就可以在设备上运行了。

5.1 注入与执行流程

  1. 确保设备上的frida-server正在运行。

  2. 在PC命令行中,使用Frida附加到目标游戏进程:

    frida -U -l bypass_damage.js -f com.target.game.package --no-pause
    • -U: 连接到USB设备。
    • -l: 加载JavaScript脚本。
    • -f: 启动指定的包名应用。
    • --no-pause: 启动后立即继续执行进程。

    如果游戏已经在运行,你可以先找到其PID,然后用frida -U -l bypass_damage.js -p PID附加。

  3. 观察命令行输出。如果脚本成功找到类和方法,你会看到“[+] Found TakeDamage method”的日志。

5.2 游戏内测试与行为观察

启动游戏,操控角色去碰撞原本会导致死亡的敌人或障碍物。预期行为是:

  • 碰撞发生,游戏可能依然会播放碰撞音效或特效(因为物理碰撞和OnCollisionEnter事件依然触发)。
  • 但是,角色的生命值不会减少。
  • 不会触发死亡动画和游戏结束逻辑。
  • 你的Frida控制台会打印出每一次TakeDamage被调用时的Hook日志,包括伤害值(应该被你修改为0)。

如果效果符合预期,那么Hook就成功了。如果角色依然死亡,需要排查:

  • Hook是否真的生效:检查Frida日志,看是否有找到方法和进入Hook回调的记录。如果没有,可能是类名/方法名不对,或者程序集加载时机问题(尝试在Mono.assembly.load的回调里加延时或枚举已加载程序集)。
  • 死亡逻辑是否另有其“人”:可能除了TakeDamage,还有直接调用Die()的方法,或者碰撞检测直接修改了currentHealth属性。你需要回到dnSpy,重新分析碰撞调用链。
  • 存在多个同类组件:可能游戏中有多个PlayerHealth实例,或者你的角色预制体上挂了不同的脚本。确保你的Hook作用于正确的游戏对象实例。

5.3 性能考量与稳定性

这种基于反射和解释执行的Hook,会在每次目标函数被调用时注入一段JavaScript代码。对于OnCollisionEnterUpdate这类每帧都可能高频调用的函数,可能会引入微小的性能开销。但在大多数情况下,这对于单机游戏来说是可以接受的。

注意事项:在线游戏中使用此类修改有极高风险,可能导致账号封禁。本技术仅用于单机游戏学习、研究与安全测试目的。

6. 问题排查与进阶技巧

在实际操作中,很少能一帆风顺。下面记录一些我踩过的坑和对应的解决思路。

6.1 常见问题速查表

问题现象可能原因排查思路与解决方案
Frida脚本执行失败,提示Mono is not defined目标进程不是Mono后端,或Frida版本与Mono插件不兼容。确认游戏使用Mono(检查libmono.so)。更新Frida到最新版本。尝试在Java.perform外直接使用Mono对象。
能找到类,但找不到方法方法名、参数签名不匹配;方法可能是私有、静态或来自父类。在dnSpy中确认方法的完整签名(包括返回类型)。使用Mono.getClass时尝试包含命名空间。枚举类所有方法并打印出来比对。
Hook成功后游戏崩溃Hook回调函数onEnter/onLeave中的操作破坏了栈平衡或访问了非法内存。检查对args数组的索引访问是否越界。确保修改参数值时类型匹配(如int对应toInt32()ptr(“0”))。简化Hook回调,只打印日志,看是否仍崩溃。
有Hook日志,但游戏逻辑未被修改修改的参数不是关键参数;死亡判定逻辑在别处。检查是否修改了正确的args索引。尝试在onEnter中直接this.returnValue返回(如果是void函数,可能无效)。回溯dnSpy,确认是否所有伤害路径都被覆盖。
方法被调用,但this上下文不对Hook了静态方法,或者通过其他对象实例调用的。args[0]对于静态方法是第一个参数。打印args[0]的值,看是否是一个有效的对象指针。
游戏启动后过一段时间Hook失效游戏可能进行了反调试或运行时代码自修改,重置了函数指针。使用Frida的Stalker或持久化Hook选项。尝试Hook更早的初始化阶段。考虑使用setImmediate或定时器重新应用Hook。

6.2 进阶技巧:Hook属性(Getter/Setter)

有时,死亡判定不是通过方法,而是通过直接设置一个属性(Property)来实现的,例如player.Health = 0。在C#中,属性编译后实质上是get_XXX和set_XXX两个方法。

假设有属性:

public int Health { get { return currentHealth; } set { currentHealth = value; if (currentHealth <= 0) Die(); } }

我们需要Hook的是set_Health方法。在dnSpy中,属性会显示为get_Healthset_Health两个方法。在Frida脚本中,你需要搜索并Hook“set_Health”这个方法名。

// 在枚举的方法中寻找 if (method.name === “set_Health” && method.paramCount === 1) { // setter有一个参数value Interceptor.attach(method.implementation, { onEnter: function (args) { let newHealthValue = args[1].toInt32(); console.log(`[Hook] Setting Health to: ${newHealthValue}`); if (newHealthValue < 1) { console.log(`[!] Blocking fatal health set!`); args[1] = ptr(“100”); // 强行设置为100血 } } }); }

6.3 模糊处理与对抗思路

一些游戏会进行简单的混淆,比如将类名、方法名改为a,b,c等无意义字符。这时,静态分析难度增大。我们可以:

  • 动态分析:结合游戏行为,在关键时刻(如碰撞瞬间)使用Frida的Mono.enumerateMethods()对调用栈进行快照,观察哪些短名称方法被频繁调用。
  • 数据流分析:即使名称混淆,但关键数据(如生命值)的存储位置可能是一个整型字段。可以尝试枚举类的所有字段,寻找其值在碰撞前后发生剧烈变化(从正数变为0或负数)的那个字段,然后回溯修改这个字段的代码位置。
  • Hook MonoBehaviour.Update:这是一个每帧都会执行的函数。在它的开头或结尾Hook,并检查角色状态字段,可以快速定位到管理角色状态的类实例。

绕过碰撞死亡判定只是Unity Mono游戏逆向的入门实践。它串联起了从静态分析、动态调试到运行时Hook的完整链条。掌握这个流程后,你可以举一反三,去修改游戏金币、技能冷却、解锁关卡等更多逻辑。核心永远是:静动结合,耐心分析,大胆假设,小心验证。最后再次强调,技术当用于正途,请在法律允许和开发者授权的范围内进行探索。