ARTICLE DETAIL

资讯详情

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

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

Unity Mono游戏逆向实战:Frida Hook绕过碰撞死亡判定 1. 项目概述一次从零开始的Unity Mono游戏逆向实战最近在折腾一款老游戏叫《极限摩托》Trial X是个典型的手机重力感应控制摩托车的游戏。玩过的朋友都知道这游戏难度不低摩托车稍微一歪碰到障碍物就判定失败游戏结束。作为一个喜欢“动手”的逆向爱好者我就在想能不能在不修改游戏安装包APK的情况下让角色“金刚不坏”怎么撞都不死呢这个想法听起来有点“作弊”的嫌疑但从技术角度看这是一个非常经典的Unity Mono架构安卓游戏逆向分析课题。它涉及到如何定位游戏逻辑、理解Unity引擎的执行流程并最终通过运行时Hook技术精准干预游戏判定。整个过程就像一次外科手术需要精准地找到“病灶”死亡判定函数并“下刀”拦截调用而工具就是Frida。这篇文章我就来完整复盘这次逆向实战从拿到APK开始到最终成功绕过碰撞死亡判定的全过程。无论你是对安卓逆向、Unity游戏安全还是Frida动态插桩技术感兴趣相信都能从中获得一条清晰的、可复现的技术路径。2. 逆向环境与工具链准备工欲善其事必先利其器。在开始逆向之前搭建一个稳定、高效的工作环境至关重要。这次实战主要涉及安卓逆向和动态分析因此我们的工具链也围绕这两个核心展开。2.1 核心工具选型与配置首先我们需要一台Root过的安卓真机。为什么强调真机因为在后续的实践中我发现像雷电、夜神这类安卓模拟器虽然方便但在处理某些原生库Native Library的加载和Frida Hook时可能会遇到兼容性问题。例如模拟器可能对ARM架构的so库进行了一层转译或非标准加载导致Frida无法像在真机上那样直接通过模块名如libmono.so找到并附加。为了避免不必要的麻烦一部已经获取Root权限的安卓手机是最佳选择。我使用的是一台闲置的旧款高通芯片手机刷入了Magisk来获取Root权限。接下来是核心三件套Frida、ADB和Apktool。Frida这是我们本次的“手术刀”。它是一个强大的动态代码插桩框架允许我们向目标进程注入JavaScript或Python脚本来拦截函数调用、修改内存数据等。我们需要在电脑客户端和手机服务端分别安装。电脑端通过Python的pip安装即可pip install frida-tools。安装后可以使用frida --version检查是否成功。手机端需要根据手机的CPU架构下载对应的frida-server。通过adb shell getprop ro.product.cpu.abi命令可以查看架构常见的是arm64-v8a。将下载的frida-server-xx.x.x-android-arm64推送到手机并赋予执行权限。ADB (Android Debug Bridge)这是与手机通信的桥梁。用于安装APK、推送文件、获取进程列表、开启端口转发等。它是Android SDK Platform-Tools的一部分需要单独下载并配置到系统环境变量中。Apktool一个用于反编译和回编译APK文件的工具。虽然我们最终的目标是不修改APK但在分析阶段我们需要用它来拆解APK查看其内部结构特别是判断游戏使用的是Mono还是IL2CPP脚本后端。这对于后续的Hook策略有决定性影响。注意Frida客户端与服务端的版本需要尽量匹配否则可能出现连接失败或协议不兼容的问题。一个简单的原则是安装frida-tools时它会自动拉取匹配的Frida核心库。你只需确保手机上的frida-server主版本号如17.x与电脑端的Frida主版本号一致即可。2.2 目标APK的初步侦查拿到游戏APK后第一步不是直接运行而是先“拆开”看看。使用Apktool进行反编译apktool d trial_x_game.apk -o output_dir反编译完成后我们重点观察几个目录lib/目录这里存放着游戏使用的原生共享库.so文件。我们特别关注arm64-v8a64位ARM或armeabi-v7a32位ARM子目录。在这里我发现了两个关键文件libmono.so和libunity.so。libmono.so的存在是一个强烈信号它表明这款Unity游戏很可能使用的是Mono脚本后端而不是IL2CPP。IL2CPP后端通常会生成libil2cpp.so。assets/bin/Data/Managed/目录这是Unity Mono游戏的“心脏”。里面包含了编译后的C#程序集主要是Assembly-CSharp.dll开发者编写的游戏逻辑和UnityEngine.dllUnity引擎核心库。如果能顺利看到这些DLL文件就几乎可以100%确认是Mono架构。对于IL2CPP这个目录下通常是空的或者只有一些元数据文件真正的代码被编译进了libil2cpp.so。通过这步侦查我们确定了目标一个使用Unity Mono架构的安卓游戏。这意味着游戏的所有C#逻辑包括我们关心的碰撞检测和死亡判定最终都会通过Mono运行时libmono.so来解释或编译执行。这为我们后续的Hook点选择提供了明确方向——我们不需要去分析复杂的IL2CPP元数据而是可以直接瞄准Mono运行时的核心函数。3. Unity Mono架构与Hook策略深度解析在动手写Hook脚本之前我们必须先理解Unity Mono游戏在安卓上是如何运行的。知其然更要知其所以然这样才能在茫茫函数海中找到那个关键的“开关”。3.1 Unity Mono执行流水线一个典型的Unity Mono游戏其代码执行可以简化为以下流水线Unity 物理引擎/碰撞检测 → C# 脚本函数如 OnCollisionEnter → .NET IL 字节码 → Mono 运行时解释/编译mono_runtime_invoke → 原生机器码执行关键在于mono_runtime_invoke这个函数。它是Mono运行时中用于调用托管C#方法的核心入口。无论游戏里定义了多么复杂的类和方法当它们需要被引擎调用时比如每帧更新的Update发生碰撞时的OnCollisionEnter最终都会汇聚到mono_runtime_invoke这里由它将IL字节码转换为原生代码并执行。这就给我们提供了一个绝佳的统一拦截点。与其去海量的C# DLL中寻找具体的死亡判定方法可能名字经过混淆不如直接Hook这个底层的、必经的调用枢纽。只要我们能在这个枢纽里识别出我们不想执行的那个方法比如Bone.OnCollisionEnter然后让它“失效”就能达到我们的目的。3.2 为什么选择Hook mono_runtime_invoke这个策略有几个显著优势通用性强只要游戏是Mono架构此方法基本都适用。无需针对每个游戏做大量静态分析。精准拦截我们可以在Native层精确过滤方法名和类名避免误伤其他正常游戏逻辑。无需修改APK所有操作在内存中进行属于运行时修改。游戏本体文件没有任何变化避免了重打包可能带来的签名校验、资源损坏等问题。动态灵活通过Frida脚本我们可以随时开启、关闭或修改Hook逻辑实现动态调试和测试。当然这个方法的前提是你能准确识别出哪个C#方法是负责死亡判定的。这需要结合静态分析和动态日志来定位。4. 实战定位与拦截死亡判定函数理论清晰后我们进入实战环节。目标是找到那个让游戏角色“死亡”的函数并阻止它执行。4.1 启动Frida与附加进程首先确保手机上的frida-server已经运行adb shell su cd /data/local/tmp chmod x frida-server-17.5.1-android-arm64 ./frida-server-17.5.1-android-arm64 然后在电脑上我们需要获取目标游戏的进程IDPID。可以先启动游戏然后使用命令adb shell ps -A | grep 游戏包名 # 例如adb shell ps -A | grep com.galapagossoft.trial记下PID例如19480。接着编写一个初步的Frida脚本我们命名为mono_trace.js用于附加进程并Hookmono_runtime_invoke目的是打印出所有被调用的C#方法从中寻找线索。4.2 编写方法追踪脚本这个脚本的核心逻辑是替换mono_runtime_invoke函数在每次调用时通过Mono Runtime提供的API如mono_method_get_name,mono_class_get_name获取当前正在执行的C#方法名和类名并打印到控制台。// mono_trace.js console.log([*] Starting Mono Runtime Trace...); var mono Process.findModuleByName(libmono.so); if (!mono) { console.log([-] libmono.so not found!); return; } console.log([] libmono.so base: mono.base); // 获取关键函数地址 var mono_runtime_invoke_ptr mono.getExportByName(mono_runtime_invoke); var mono_method_get_name_ptr mono.getExportByName(mono_method_get_name); var mono_method_get_class_ptr mono.getExportByName(mono_method_get_class); var mono_class_get_name_ptr mono.getExportByName(mono_class_get_name); // 创建NativeFunction以便调用 var mono_method_get_name new NativeFunction(mono_method_get_name_ptr, pointer, [pointer]); var mono_method_get_class new NativeFunction(mono_method_get_class_ptr, pointer, [pointer]); var mono_class_get_name new NativeFunction(mono_class_get_name_ptr, pointer, [pointer]); // 保存原始函数 var orig_mono_runtime_invoke new NativeFunction( mono_runtime_invoke_ptr, pointer, [pointer, pointer, pointer, pointer] ); // 替换函数 Interceptor.replace(mono_runtime_invoke_ptr, new NativeCallback(function(method, obj, params, exc) { var className ; var methodName ; // 获取类名和方法名 var klass mono_method_get_class(method); if (!klass.isNull()) { var classNamePtr mono_class_get_name(klass); if (!classNamePtr.isNull()) { className classNamePtr.readCString() || ; } } var methodNamePtr mono_method_get_name(method); if (!methodNamePtr.isNull()) { methodName methodNamePtr.readCString() || ; } // 打印调用日志可加过滤条件避免刷屏 if (className.includes(Controller) || methodName.includes(Collision) || methodName.includes(Death) || methodName.includes(Fail)) { console.log([Invoke] ${className}::${methodName}); } // 调用原始函数不影响游戏运行 return orig_mono_runtime_invoke(method, obj, params, exc); }, pointer, [pointer, pointer, pointer, pointer])); console.log([] mono_runtime_invoke hook installed.);使用Frida加载脚本并附加到游戏进程frida -U -p 19480 -l mono_trace.js4.3 分析日志与定位关键函数启动游戏故意让角色碰撞死亡。观察控制台输出的日志。你会看到大量方法被调用。我们需要从中筛选出与失败、死亡、碰撞相关的。在我的这次实战中日志显示了类似这样的序列[Invoke] Bone::OnCollisionEnter [Invoke] FailController::Start [Invoke] FailController::OnGUI ...这个序列非常具有启发性Bone::OnCollisionEnter这很可能是碰撞事件触发的第一个脚本方法。Bone可能是角色骨骼或碰撞体的组件名OnCollisionEnter是Unity的标准碰撞事件函数。FailController::Start紧接着碰撞一个名为FailController的组件被启动Start方法在组件启用时调用一次。FailController::OnGUI这可能是在屏幕上绘制“失败”或“游戏结束”UI的方法。由此我们可以做出一个合理的推断Bone.OnCollisionEnter是碰撞事件的入口而FailController的启动标志着死亡流程的开始。因此我们的拦截点可以设在Bone.OnCollisionEnter。只要阻止这个方法正常执行后续的FailController启动流程可能就不会被触发或者即使触发也因为缺少前置条件而无效。实操心得动态追踪时日志可能会非常庞大。建议一开始使用宽松的过滤条件如包含特定关键词定位到可疑的类和方法后再修改脚本精确只打印这几个方法并观察它们被调用的时机和顺序以确认因果关系。5. 实现精准Hook让碰撞失效定位到关键函数后下一步就是修改Hook脚本从“记录”变为“拦截”。5.1 编写拦截脚本我们创建新的脚本mono_block_collision.js。这次在mono_runtime_invoke的替换函数中加入条件判断如果检测到正在调用的是Bone::OnCollisionEnter则直接返回一个空指针或表示“成功执行但无异常”的指针如ptr(0)而不再调用原始函数。// mono_block_collision.js console.log([*] Attempting to block collision death...); var mono Process.findModuleByName(libmono.so); if (!mono) { console.log([-] libmono.so not found!); return; } var mono_runtime_invoke_ptr mono.getExportByName(mono_runtime_invoke); var mono_method_get_name_ptr mono.getExportByName(mono_method_get_name); var mono_method_get_class_ptr mono.getExportByName(mono_method_get_class); var mono_class_get_name_ptr mono.getExportByName(mono_class_get_name); var mono_method_get_name new NativeFunction(mono_method_get_name_ptr, pointer, [pointer]); var mono_method_get_class new NativeFunction(mono_method_get_class_ptr, pointer, [pointer]); var mono_class_get_name new NativeCallback(mono_class_get_name_ptr, pointer, [pointer]); var orig_mono_runtime_invoke new NativeFunction( mono_runtime_invoke_ptr, pointer, [pointer, pointer, pointer, pointer] ); Interceptor.replace(mono_runtime_invoke_ptr, new NativeCallback(function(method, obj, params, exc) { var className ; var methodName ; var klass mono_method_get_class(method); if (!klass.isNull()) { var classNamePtr mono_class_get_name(klass); if (!classNamePtr.isNull()) { className classNamePtr.readCString() || ; } } var methodNamePtr mono_method_get_name(method); if (!methodNamePtr.isNull()) { methodName methodNamePtr.readCString() || ; } // 核心拦截逻辑 if (className Bone methodName OnCollisionEnter) { console.log([!!! BLOCKED !!!] ${className}::${methodName}); // 返回一个表示“成功”的空结果阻止原函数执行 return ptr(0); } // 对于其他方法正常执行 return orig_mono_runtime_invoke(method, obj, params, exc); }, pointer, [pointer, pointer, pointer, pointer])); console.log([] Collision death hook installed. Game should be invincible now.);5.2 测试与效果验证用Frida加载这个拦截脚本并附加到游戏进程frida -U -p PID -l mono_block_collision.js现在进入游戏操纵摩托车撞向障碍物。你会发现角色虽然可能因为物理引擎的反馈而弹开或翻滚但游戏画面没有弹出“失败”界面游戏也没有结束可以继续操作。这意味着我们的Hook成功了Bone.OnCollisionEnter函数被我们“吞掉”了它原本要执行的死亡判定逻辑可能是设置一个isDead标志、触发失败事件等没有执行从而绕过了死亡。注意事项直接返回ptr(0)并不总是安全的。mono_runtime_invoke的返回值通常是该方法调用结果的指针。对于返回void的方法返回0通常是可行的。但如果被拦截的方法有返回值且游戏逻辑依赖这个返回值直接返回0可能导致游戏崩溃或逻辑错误。更稳健的做法是先调用原始函数获取结果然后根据情况决定是否修改结果或者对于void方法调用原函数后直接返回原结果。但在本例中OnCollisionEnter是Unity事件返回类型为void直接返回0是常见且有效的做法。6. 进阶技巧与问题排查实录一次成功的Hook背后往往伴随着多次的尝试和问题排查。下面分享几个我在实战中遇到的关键问题和解决思路。6.1 如何应对方法名混淆不是所有游戏都像示例这样使用清晰的Bone和OnCollisionEnter命名。很多商业游戏会对C#代码进行混淆类名和方法名可能变成a、b、c或ClassA、MethodB这样无意义的字符串。应对策略动态分析结合静态分析即使名字混淆方法的行为和调用时机不会变。我们依然可以通过mono_trace.js脚本在角色死亡时打印出所有被调用的方法。虽然名字是a::b但它的调用时机碰撞瞬间是确定的。我们可以尝试拦截这个时机出现的所有可疑方法。分析调用栈Frida可以获取并打印调用栈Backtrace。在Hookmono_runtime_invoke时不仅打印当前方法也打印是谁调用了它。这有助于理解函数在游戏逻辑中的上下文即使名字混淆也能通过上下文推断其作用。反编译DLL用.NET反编译工具如dnSpy、ILSpy打开assets/bin/Data/Managed/Assembly-CSharp.dll。即使类名方法名混淆但字符串常量、某些特定的API调用如GameObject.Find(FailUI)、Application.LoadLevel可能没有混淆。通过搜索这些关键字符串可以定位到相关方法再回到动态日志中寻找对应的混淆名。6.2 Hook失败的可能原因与排查libmono.so未找到可能原因游戏不是Mono架构是IL2CPP或者frida-server架构与游戏不匹配。排查用Process.enumerateModules()打印进程所有模块确认是否有libmono.so。检查APK的lib目录确认架构。游戏崩溃可能原因Hook的函数签名不正确在回调函数中进行了非法内存操作拦截方法后返回了错误的值。排查简化脚本先只做日志打印不拦截。确认Hook点正确。仔细核对NativeFunction和NativeCallback的函数签名参数类型、返回值类型必须与mono_runtime_invoke的原型完全匹配。Hook无效游戏依然死亡可能原因拦截的类名或方法名不准确死亡判定可能不在OnCollisionEnter而在OnTriggerEnter、FixedUpdate或其他地方或者游戏有多个碰撞体组件。排查放宽拦截条件例如只拦截包含Collision的所有方法或者拦截FailController的所有方法。观察日志确认在死亡瞬间我们期望拦截的方法确实被调用了。6.3 性能考量与优化Hookmono_runtime_invoke意味着拦截了游戏中每一个C#方法的调用。如果游戏逻辑复杂每秒调用次数可能成千上万。在我们最初的追踪脚本中如果无条件打印所有日志会导致控制台刷屏且可能影响游戏性能。优化建议条件过滤像我们最终脚本那样只对感兴趣的特定类和方法进行判断和操作。采样打印可以设置一个计数器每N次调用打印一次日志减少输出。使用更高效的匹配在回调函数内部将字符串比较放在最后。可以先比较指针或使用更快的哈希检查。7. 扩展思路从“无敌”到“修改”成功绕过死亡判定只是第一步。基于同样的技术框架我们可以做更多事情修改游戏数值我们可以Hook负责计算伤害、分数、速度的方法。在mono_runtime_invoke中不仅拦截还可以修改传入的参数params或篡改返回值。这需要更深入地理解Mono运行时中方法参数的结构通常需要解析MonoMethod和参数数组。调用游戏内部函数利用Frida的NativeFunction我们可以主动调用libmono.so中的其他函数例如mono_runtime_invoke本身来触发游戏内的特定功能比如无条件增加金币、解锁关卡。针对IL2CPP架构如果游戏是IL2CPP后端思路类似但目标不同。IL2CPP将C#代码直接编译为C函数符号名会变得冗长且唯一。我们需要分析libil2cpp.so寻找与游戏逻辑相关的函数符号通常可以通过字符串引用或Metadata来定位然后直接Hook这些C函数。工具上可能会借助Il2CppDumper来生成符号映射表。这次针对Unity Mono游戏的逆向实战核心在于理解引擎的执行模型。通过抓住mono_runtime_invoke这个“牛鼻子”我们能够以一种相对通用和高效的方式干预游戏的逻辑执行。整个过程就像是在游戏的“翻译官”Mono运行时那里安插了一个监听器兼过滤器在它把C#指令翻译给系统执行之前我们就可以进行审查和干预。这种方法比直接修改DLL或内存漫无目的地搜索要精准和优雅得多。当然每个游戏都有其独特性具体的类名、方法名和逻辑需要具体分析但这条从架构分析到动态Hook的技术路径为分析同类Mono游戏提供了一个坚实可靠的模板。
返回列表