Unity手游逆向实战:Frida动态Hook与libil2cpp.so内存修改
1. 项目概述一次从底层到应用的完整逆向之旅最近在分析一款Unity引擎开发的手游时遇到了一个典型的需求绕过某个核心数值的校验或者修改角色的属性。对于Unity手游尤其是使用IL2CPP后端编译的其核心逻辑都封装在libil2cpp.so这个动态库里。直接修改这个二进制文件不仅困难而且每次游戏更新都需要重新定位偏移效率低下。这时候动态注入工具Frida就成为了我们的瑞士军刀。这次记录就是一次完整的、从静态分析libil2cpp.so获取关键函数地址到编写Frida脚本进行动态Hook和内存修改的实战过程。整个过程不仅适用于游戏修改对于安全研究、协议分析、自动化测试等领域也有参考价值。无论你是对移动安全感兴趣的开发者还是想深入了解Unity应用内部机制的逆向爱好者这篇记录都能为你提供一条清晰的路径和可复现的操作步骤。2. 核心思路与技术选型解析2.1 为什么选择Frida libil2cpp.so的分析路径在手游逆向中我们面对的是一个“黑盒”。传统的静态分析工具如IDA Pro, Ghidra能帮我们看清libil2cpp.so的结构但无法得知运行时内存中的具体数值和调用关系。而纯动态调试如使用GDB/LLDB附加又过于笨重难以实现灵活的脚本化操作。Frida的出现完美解决了这个问题。它是一个动态代码插桩框架允许我们将JavaScript或Python脚本注入到目标进程中去Hook挂钩特定的函数、读写内存、甚至调用原生函数。对于我们的目标——修改Unity手游内存最佳路径就是静态定位先使用工具解析libil2cpp.so找到我们关心的C#类、方法在原生代码中的对应函数地址或偏移。动态注入在游戏运行时使用Frida将脚本注入到游戏进程根据静态分析得到的地址对目标函数进行Hook。逻辑修改在Hook的回调函数中我们可以读取传入的参数、修改返回值、或者直接操作内存从而实现游戏逻辑的篡改。这个组合的优势在于“动静结合”。静态分析给了我们“地图”动态注入给了我们“实时操控能力”。相比于直接修改libil2cpp.so文件需要重打包签名Frida脚本的方式是非侵入式的无需修改原始应用更加灵活和安全仅对分析过程而言。2.2 关键工具链与准备工作工欲善其事必先利其器。以下是本次分析需要用到的核心工具及其作用目标环境一台已Root的Android手机或一台Android模拟器如雷电模拟器。Root权限是Frida在Android上发挥全部功能的基础。静态分析套件IDA Pro / Ghidra反汇编libil2cpp.so进行初步的代码流分析。Ghidra免费且功能强大是开源首选。Il2CppDumper这是整个流程中的“钥匙”。Unity在构建IL2CPP应用时会生成一个global-metadata.dat文件它包含了所有C#类型、方法、字段的元数据信息。Il2CppDumper能够解析这个文件并将其与libil2cpp.so中的函数地址关联起来生成一份可读的“映射表”通常是dump.cs或script.json告诉我们Player::get_Health()这个方法在libil2cpp.so中的偏移地址是多少。动态注入工具Frida Server运行在目标手机或模拟器上的守护进程负责接收来自客户端的指令并执行注入。Frida Tools (CLI)安装在你的电脑分析机上的命令行工具用于连接Frida Server、上传和管理脚本。Python / Node.js Frida Binding如果你想用Python或Node.js编写更复杂的控制逻辑需要安装对应的Frida开发库。辅助工具adb (Android Debug Bridge)用于与设备通信推送文件、获取日志、转发端口等。一款文件管理器应用 (如MT管理器)用于从已安装的APK中提取libil2cpp.so和global-metadata.dat文件。注意确保你的Frida Server版本与电脑端Frida Tools的版本匹配否则会出现连接失败或协议错误。这是新手最常见的坑之一。3. 实战第一步提取与解析目标文件3.1 获取关键的libil2cpp.so与global-metadata.dat分析的第一步是拿到目标游戏的这两个核心文件。对于已安装的游戏它们通常位于/data/app/[package.name]/lib/[arch]libil2cpp.so和/data/app/[package.name]/目录下global-metadata.dat。最简单的方法是使用MT管理器等工具进入APK安装目录直接复制出来。更通用的命令行方法是使用adb pull# 先找到游戏的包名和数据目录 adb shell pm list packages | grep [游戏关键词] adb shell pm path [com.game.package.name] # 输出类似package:/data/app/com.game.package.name-xxxxx/base.apk # 根据这个路径推测lib目录 adb pull /data/app/com.game.package.name-xxxxx/lib/arm64-v8a/libil2cpp.so ./ adb pull /data/app/com.game.package.name-xxxxx/global-metadata.dat ./如果是模拟器路径可能有所不同需要根据实际情况调整。确保提取的libil2cpp.so的架构arm64-v8a, armeabi-v7a与你后续分析的设备架构一致。3.2 使用Il2CppDumper还原符号表拿到这两个文件后我们就可以使用Il2CppDumper来“解密”了。这是一个命令行工具用法很简单Il2CppDumper.exe libil2cpp.so global-metadata.dat output_dir执行后它会在output_dir目录下生成一系列文件其中对我们最重要的有dump.cs一个C#格式的文件里面以C#类的形式列出了游戏中的所有类型、方法、字段及其对应的文件偏移地址Offset。这是我们的核心参考资料。script.json一个JSON格式的文件包含了方法名到偏移地址的映射非常适合被Frida脚本直接加载使用。stringliteral.json游戏中所有字符串常量的列表对于寻找特定UI文本或配置项对应的代码位置非常有帮助。打开dump.cs搜索你感兴趣的内容比如“Health”、“Gold”、“Attack”。你可能会找到类似下面的代码// Namespace: GameLogic public class Player : MonoBehaviour { // RVA: 0x12345678 Offset: 0x12345678 VA: 0x12345678 public int get_Health() { } // RVA: 0x87654321 Offset: 0x87654321 VA: 0x87654321 public void set_Health(int value) { } private int Healthk__BackingField; // 0x18 }这里的Offset: 0x12345678就是get_Health方法在libil2cpp.so文件中的偏移地址。但请注意这个偏移是文件偏移不是运行时在内存中的绝对地址。4. 从文件偏移到内存地址Frida脚本的核心逻辑4.1 理解模块基址与绝对地址Frida操作的是进程的内存空间。libil2cpp.so在游戏启动时会被加载到内存的某个随机地址由于ASLR每次启动可能不同这个起始地址称为模块基址Base Address。我们在dump.cs中得到的Offset是函数相对于libil2cpp.so文件开头的偏移。那么该函数在内存中的绝对地址计算公式为函数绝对地址 libil2cpp.so模块基址 函数文件偏移因此Frida脚本的第一步通常是先获取libil2cpp.so的模块基址。4.2 编写基础Frida脚本框架下面是一个最基础的Frida JavaScript脚本框架它完成了模块基址查找和根据偏移计算绝对地址的功能Java.perform(function () { // 1. 枚举进程内所有模块找到libil2cpp.so var modules Process.enumerateModules(); var libil2cpp null; for (var i 0; i modules.length; i) { if (modules[i].name.indexOf(libil2cpp.so) ! -1) { libil2cpp modules[i]; console.log([] Found libil2cpp.so at base: ${libil2cpp.base}); break; } } if (!libil2cpp) { console.log([-] libil2cpp.so not found!); return; } // 2. 假设我们从dump.cs中得到的get_Health偏移是0x12345678 var getHealthOffset 0x12345678; var getHealthAbsoluteAddr libil2cpp.base.add(getHealthOffset); console.log([] get_Health absolute address: ${getHealthAbsoluteAddr}); // 3. 接下来可以在这里进行Hook操作... });将这个脚本保存为hook.js。在电脑上通过Frida CLI连接到设备并注入脚本frida -U -f com.game.package.name -l hook.js --no-pause-U表示连接USB设备-f表示启动新应用-l指定脚本--no-pause表示立即执行。如果游戏已经在运行可以用-n替换-f来附加到现有进程。5. 深入Hook拦截、修改与调用5.1 Hook函数并修改返回值找到了函数的绝对地址我们就可以使用Interceptor.attach来挂钩它了。以get_Health这个getter方法为例我们的目的是让游戏永远认为我们的生命值是满的。// 接上面的脚本框架... // 3. Hook get_Health 函数 Interceptor.attach(getHealthAbsoluteAddr, { onEnter: function (args) { // 这个函数没有参数onEnter一般用于记录或准备 console.log([] get_Health called.); }, onLeave: function (retval) { // retval是函数的返回值我们在这里修改它 // 首先我们需要知道返回值类型。从dump.cs看是int。 console.log([*] Original health value: ${retval.toInt32()}); // 假设最大生命值是100我们总是返回100 var newRetval ptr(100); // ptr()用于将数值包装成Frida的指针对象 retval.replace(newRetval); console.log([] Modified health value to: ${newRetval.toInt32()}); } });onEnter在函数被调用时触发onLeave在函数返回前触发。retval是一个NativePointer对象代表返回值的地址。我们使用retval.replace()来修改这个地址的内容。toInt32()是将指针内容解读为32位整数。5.2 Hook setter函数与修改参数有时候修改getter不够我们需要拦截设置属性的函数setter。例如set_Health(int value)。// 假设set_Health的偏移是0x87654321 var setHealthOffset 0x87654321; var setHealthAbsoluteAddr libil2cpp.base.add(setHealthOffset); Interceptor.attach(setHealthAbsoluteAddr, { onEnter: function (args) { // setter通常有一个参数。在ARM64上第一个参数this指针通常是X0第二个参数value是X1。 // 但Frida的args是一个数组args[0]是thisargs[1]是第一个参数以此类推。 // 对于int类型的参数我们需要根据架构来读取。这里假设是ARM64。 var originalValue args[1].toInt32(); console.log([] set_Health called. Trying to set value to: ${originalValue}); // 如果我们想阻止生命值被降低可以在这里修改参数 if (originalValue 50) { console.log([*] Attempt to set health too low, blocking!); // 修改参数为50。注意直接修改args[1]可能无效需要修改内存。 // 更通用的方法是修改寄存器或栈上的值这需要更底层的操作。 // 一个更简单的思路在onLeave里不做任何事但让函数执行然后在getter里返回假值。 // 或者如果函数逻辑简单可以直接在onEnter里替换参数指针指向的值如果参数是指针。 // 对于基本类型按值传递修改更复杂可能需要用到this.context。 } } });修改传入参数比修改返回值复杂因为参数可能通过寄存器或栈传递。对于基本类型int, float按值传递直接修改args[n]可能不起作用。一个实用的技巧是如果难以修改setter就强化对getter的Hook让它永远返回一个理想值让setter的调用实际失效。5.3 调用原生函数与读写内存除了HookFrida还可以直接调用原生函数和任意读写内存。例如我们找到了一个增加金币的函数AddGold(int amount)。var addGoldOffset 0xABCD1234; var addGoldAbsoluteAddr libil2cpp.base.add(addGoldOffset); // 定义一个NativeFunction描述函数原型 // 第一个参数是地址第二个是返回类型第三个是参数类型数组 var AddGold new NativeFunction(addGoldAbsoluteAddr, void, [pointer, int]); // 调用这个函数。第一个参数通常是this指针我们需要找到Player对象的实例。 // 寻找对象实例是一个更高级的话题可能需要通过全局变量、其他函数的参数或枚举堆来获取。 // 假设我们通过某种方式获得了playerInstance // var playerInstance ...; // AddGold(playerInstance, 99999); // 读写内存示例直接修改内存中的金币值 // 假设我们知道金币变量在Player对象中的偏移是0x28从dump.cs的backing field看出 var goldFieldOffset 0x28; // 同样假设我们有playerInstance // var goldAddress playerInstance.add(goldFieldOffset); // Memory.writeS32(goldAddress, 999999); // 写入一个32位有符号整数直接调用和内存操作威力巨大但风险也高需要精确的对象地址和类型信息否则极易导致游戏崩溃。6. 实战进阶处理泛型、重载与模糊搜索6.1 应对Il2CppDumper的局限Il2CppDumper并非万能。对于高度混淆的游戏或者某些泛型方法、匿名方法dump出的符号可能不完整或难以阅读。这时我们需要结合静态分析工具IDA/Ghidra进行手动分析。在IDA/Ghidra中加载libil2cpp.so虽然缺少符号函数名都是sub_XXXXXX但我们可以利用Il2CppDumper生成的ida.py或ghidra.py脚本将这些偏移处的函数重命名为有意义的C#方法名。这能极大提升反汇编代码的可读性。字符串引用搜索在IDA中搜索游戏UI中出现的特定字符串如“金币不足”、“生命值”可以快速定位到使用这些字符串的逻辑代码附近再结合上下文推断出关键函数。6.2 编写更健壮和灵活的Frida脚本模糊搜索与模式匹配如果无法精确知道偏移但知道函数的某些特征如特定的字节序列、调用约定可以使用Memory.scan在模块内进行搜索。var pattern F0 48 2D E9 10 B0 8D E2; // 一个ARM函数序言的字节码示例 Memory.scan(libil2cpp.base, libil2cpp.size, pattern, { onMatch: function(address, size){ console.log(Found potential function at ${address}); }, onComplete: function(){ console.log(Scan complete); } });批量Hook与脚本模块化将不同的功能如无敌、无限金币、倍攻写成独立的函数或模块通过配置文件来启用或禁用使脚本更易于管理。错误处理与稳定性使用try-catch包裹关键的Hook和内存操作避免因地址错误或游戏更新导致脚本崩溃影响Frida服务。7. 常见问题、排查技巧与避坑指南7.1 连接与注入问题Failed to spawn: unable to connect to remote frida-server检查设备连接adb devices确认设备在线。检查Frida Server在设备上执行ps | grep frida-server确认服务正在运行。确保电脑端与设备端的Frida版本一致。检查端口转发如果使用网络连接确保执行了adb forward tcp:27042 tcp:27042。Access denied或Permission denied确保设备已成功获取Root权限并且Frida Server是以root用户运行的通常ps显示的用户是root。7.2 脚本执行与Hook失效问题Hook后游戏无效果地址错误最常见原因。确认libil2cpp.so的版本与你提取文件时的版本完全一致游戏更新会导致偏移变化。重新提取并解析文件。时机问题脚本注入得太晚关键函数已经在初始化时调用过了。尝试在游戏启动早期注入使用-f参数或者在脚本中使用setImmediate来延迟Hook操作等待目标类被加载。逻辑错误你Hook的函数可能不是真正影响游戏逻辑的那个。游戏可能有多个地方处理同一属性或者你的修改被后续的校验逻辑覆盖了。需要更全面的分析。游戏崩溃参数/返回值类型错误将int当成float处理或者指针操作错误。仔细核对dump.cs中的方法签名。内存访问违规读写了一个无效的地址。确保在读写内存前地址是有效的例如通过Memory.isValid()检查。调用约定错误在定义NativeFunction时调用约定abi错误。对于ARM iOS/Android通常是default(即sysv或aapcs)。7.3 效率与优化技巧使用setImmediate将主要的Hook逻辑放在setImmediate回调中可以确保脚本在目标进程的主线程初始化完成后再执行提高Hook成功率。Java.perform(function () { setImmediate(function() { // 你的主要Hook代码放在这里 console.log(Main thread ready, start hooking...); // ... find module and hook functions ... }); });减少console.log频繁的日志输出会严重影响性能在稳定的脚本中可以注释掉或改为条件输出。使用Frida的Stalker功能进行追踪对于极其复杂的逻辑流可以使用Stalker来追踪代码执行路径但这属于高级用法对性能影响大。7.4 对抗反调试与反Frida一些游戏会检测Frida的存在。常见手段包括检测端口扫描27042默认端口。检测进程/线程名查找包含 “frida” 的进程。检测内存特征在libc等关键库中查找Frida注入的痕迹。应对策略修改Frida Server使用定制版的Frida Server修改默认端口和进程名。隐藏痕迹在Frida脚本中可以主动抹去一些特征例如覆盖fopen、read等libc函数使其在检测时返回假信息。使用非常规注入方式如使用ptrace或LD_PRELOAD等方式预先加载自己的so库再在库中启动Frida。逆向分析是一场攻防战。当你掌握了从libil2cpp.so到 Frida 脚本的完整链条你就拥有了动态剖析绝大多数Unity手游的能力。这个过程需要耐心、细致的观察力和不断的实践。每一次成功的Hook和修改不仅是对游戏机制的破解更是对底层系统、编译原理和软件安全理解的深化。记住工具只是延伸核心是你的分析思路和对目标程序逻辑的洞察。