Frida-il2cpp-bridge实战:从遍历类到Hook方法的Unity逆向指南 Frida 在安卓逆向里算得上老牌利器但一碰到 Unity 引擎的 IL2CPP 应用不少人就卡住了。原因很简单IL2CPP 会把 C# 代码编译成 native 的二进制再用 mono 那套扫内存、找类、调方法的路子全都失效。这时候 Frida-il2cpp-bridge 就派上用场了。它相当于在 Frida 和 IL2CPP 运行时之间加了一层翻译官把那些晦涩的 native 符号、内存结构、方法指针重新还原成你熟悉的“类名 方法名”形式让你可以像调试普通 Java 层一样去 hook、遍历、dump。这篇文章适合谁如果你已经会用 Frida 的基本操作比如 spawn 启动、attach 附加、写简单的 JS 脚本但面对 Unity 游戏却无从下手或者你盯着 Ghidra 里那一堆带il2cpp_前缀的函数头晕想用更高效的方式定位游戏逻辑——那这篇就是冲着你写的。我会从零开始把从环境安装到首次成功 dump 一个应用完整流程走一遍每一步都有参数、有代码、有坑点你照着抄就行。1. 为什么偏偏是 Frida-il2cpp-bridge1.1 传统方式在 IL2CPP 面前有多吃力先说说痛点。Unity 打包有两种主流模式Mono 和 IL2CPP。Mono 模式会把 C# 程序集直接打包成Assembly-CSharp.dll你用 dnSpy 一拖就能看到全部代码配合 Frida 遍历也很直观。但 IL2CPP 模式下Unity 会先把 C# 源码编译成 C 中间码再交叉编译成目标平台的机器码。最终你拿到的是一个超大的libil2cpp.so和同级的global-metadata.dat。类名、方法名、字段偏移全都以元数据的形式存在这个 dat 文件里运行时的 native 代码里只剩一串串地址和偏移。这个时候用传统 Frida 脚本去 hook 一个游戏方法你首先得在 Ghidra 里找到目标函数的地址。找地址本身不难难的是那个函数签名长什么样、参数是什么类型、返回值怎么解析。你看到的是一个sub_1234AB加一堆寄存器操作根本不知道它对应 C# 里的Player.LevelUp()。折腾一晚上可能连一个技能伤害的数值都定位不到。1.2 il2cpp-bridge 做的事情Frida-il2cpp-bridge 的核心思路就是利用 IL2CPP 运行时在内存里保留的类注册信息和元数据索引把这些信息重新组织成一个可供 Frida 调用的“虚拟托管层”。具体来说它会在运行时拿到il2cpp_class_from_name这类导出函数的指针然后通过它们去查类、查方法、查字段。你用它的 Api 写脚本时可以直接写Il2Cpp.Domain.Assembly(Assembly-CSharp).Image(GameAssembly).Class(Player).Method(LevelUp)然后.invoke()或者.replace()感觉就像在跟托管层对话。这一点很关键你不需要提前静态分析那个 so 文件也不需要在 Ghidra 里抠函数地址。il2cpp-bridge 帮你把“找类、找方法、解析签名”这一整套最烦人的动作自动化了。你只需要会写一点 JS知道目标 C# 类的名字可以从 IDA 的字符串窗口、解包后的global-metadata.dat或者日志里挖出来就能直接开干。1.3 这个库适合/不适合什么场景先说适合的快速验证游戏逻辑、定位隐藏功能函数、dump 某个对象在内存里的字段值、批量遍历所有类找可疑方法。这些场景下 il2cpp-bridge 的效率和舒适度远高于手工静态分析。不适合的它解决不了“不知道目标类名”的问题。如果那个 so 被混淆或者脱壳后没有任何可读字符串你依然得从静态分析入手。另外它也不能直接对抗完整性校验或者反调试那些你得另外想办法。说白了il2cpp-bridge 是一个“分析放大器”不是“万能钥匙”。2. 环境准备与基础安装2.1 电脑端环境清单我这里的操作环境是 Windows 11 Python 3.10.11用frida16.4.3和frida-tools12.7.0。Frida-tools 更新速度很快版本不同界面输出略有差异但核心 API 很稳定。建议你和我保持大版本一致别追最新的不可描述版本那些新特性你暂时用不上反而可能引入新坑。pip install frida frida-tools装完之后验证一下frida --version能看到版本号就行。如果你的电脑上有多个 Python记得给当前环境单独装或者直接建一个虚拟环境python -m venv frida_env activate pip install frida frida-tools为什么要强调虚拟环境因为 Frida 依赖很多 native 的 Python 包装器不同项目的依赖容易冲突。你要是之前装过旧版本 frida升级时偶尔会遇到 pip 把新版本装好了但命令行工具还是旧版的情况。遇到这种别想别的先pip show frida frida-tools看路径然后卸载干净重装。2.2 手机端配置手机端要求 root 过的 Android 设备或者一台能跑 Android 模拟器镜像的虚拟机。我用的是某厂商旧款高通设备刷的国际版系统Android 10root 方案是老牌的 Magisk。模拟器方面我用过某开源 Android-x86 镜像挂在 VirtualBox 里跑 Frida-server 也稳定就是游戏类 App 容易崩不适合做高强度分析。核心文件是frida-server必须从 Frida 的 GitHub Releases 下载对应你手机架构的版本。手机是 64 位就下frida-server-16.4.3-android-arm64.xz。解压后改名放到/data/local/tmp/下然后给执行权限再启动adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell # 在手机 shell 里 /data/local/tmp/frida-server -l 0.0.0.0:27042 这里建议用-l 0.0.0.0:27042监听所有网卡这样你可以通过网络调试不用每次插线。但要注意这样安全性会差一些局域网有别人能连你的调试端口自己小心。启动后回到电脑端验证frida-ps -U能列出手机进程列表就说明连接成功。2.3 目标 App 的准备目标 App 我建议先选一个自己熟悉的、带 IL2CPP 标志的 Unity 游戏或工具类应用比如某小型卡牌单机游戏或者某开源的 Unity Demo。我用过的一个比较理想的样本是“某模拟经营小游戏”体积只有 30MB没有任何加固libil2cpp.so大概是 12MB属于良心样本。把 App 装上后你要先确认它确实是 IL2CPP 模式。方法很简单用解压工具打开 APK看lib/arm64-v8a/下有没有libil2cpp.so和global-metadata.dat。有这两个文件这 App 就是 IL2CPP 模式可以继续。在开始之前建议先把 App 完整打开一次让它热启动跑完自己的初始化。有些游戏在未进行到某个界面时相关类都还没加载完全你后面 dump 一些类时会显示null不是脚本问题是类压根还没注册。3. Frida-il2cpp-bridge 的两种引入方式3.1 通过 npm 安装作为模块引入这个库本身是用 TypeScript 写的编译后产出 JS。官方推荐的方式是通过 npm 安装到项目里新建一个目录初始化package.json然后npm init -y npm install frida-il2cpp-bridge然后你写一个agent.ts或agent.js导入方式看一下你编译工具链的配置。我为了省事直接用的 frida-compile 把 TS 编译成 JS再接进 Fridanpm install -g frida-compile frida-compile agent.ts -o agent.js这样你可以在agent.ts里写 TS 代码享受补全的乐趣。frida-compile 打包的时候会自动把frida-il2cpp-bridge的代码全部打进agent.js不用额外拷贝模块目录到手机这点很省心。3.2 直接用 Frida 的脚本加载机制如果你不想搭编译环境有个更粗犷但同样有效的用法下载 il2cpp-bridge 的开仓文件里的frida-il2cpp-bridge.js一般在dist目录下名称为index.js然后在你的 Java/JS 脚本里用 Frida 的require加载它。具体操作假设你下载后放到电脑某个路径比如C:\tools\il2cpp-bridge.js那么你运行 Frida 的时候可以这么干frida -U -f com.target.app -l C:\tools\il2cpp-bridge.js -l agent.js多个-l会依次加载这样 il2cpp-bridge 的全局对象Il2Cpp就会先注册到 JS 运行时然后你自写的agent.js里就能正常用Il2Cpp对象了。我个人实际更推荐这个方式省掉了 frida-compile 那一套构造调试时改一行脚本直接重跑就行。3.3 为什么推荐编译模式虽然上面讲了快速方法但如果你要长期做逆向我依然建议你用编译模式。原因很简单类型补全和错误提示太香了。你写Il2Cpp.Domain.Assembly时编辑器会直接告诉你这个函数返回什么类型参数需要什么。而且 TS 的编译检查能帮你拦掉很多低级错误比如把方法名拼错、把参数个数搞错。这种错误在纯 JS 模式里要等运行时报错才能发现一轮一轮地试错非常磨人心态。4. 从连接应用开始写第一个连接脚本4.1 找到目标进程的痕迹启动 Frida 连接应用可以有两种模式spawn 模式从零启动能抓到早期初始化和 attach 模式附加到已运行的进程。对于首次 dump建议用 spawn因为你要在类加载之前就注入才能尽可能多地捕获类注册过程。先确认包的进程名如果应用包名是com.sample.game用 spawn 注入frida -U -f com.sample.game -l anget.js --no-pause这里--no-pause的意思是注入后不暂停主线程立即继续运行。如果你在脚本里有setTimeout等延迟逻辑可以混用。4.2 首个脚本遍历所有 Assembly用 Frida-il2cpp-bridge 写的最基础脚本就是遍历程序集。先感受一下 API 长什么样function main() { console.log(il2cpp version: Il2Cpp.version); Il2Cpp.Domain.Assemblies .filter(a a.Image.Name.indexOf(Assembly) 0) .forEach(a { console.log(assembly: a.Name); console.log(image: a.Image.Name); }); }把它存成agent.js用下面的命令跑起来frida -U -f com.sample.game -l agent.js --no-pause如果输出了一堆以Assembly-CSharp开头的程序集名恭喜你链路已经通了。这条输出意味着你已经在目标进程内部能看见 IL2CPP 的托管世界了。如果没有输出或者直接报错大概率是Il2Cpp对象没找到检查你是不是没有加载frida-il2cpp-bridge的 JS。另一个可能是il2cpp_bridge库比运行时版本旧兼容性出问题这个放在后面的排查小节专门讲。4.3 查看程序集里的类知道了程序集下一步是看类。针对用户自定义的Assembly-CSharp列出里面全部类Il2Cpp.Domain.Assembly(Assembly-CSharp) .Image.Classes .forEach(c { console.log(c.Namespace . c.Name); });这一步输出可能非常多建议先重定向到文件frida -U -f com.sample.game -l agent.js --no-pause classes.txt看到Player、BattleManager、SkillSystem之类的类名心里大概就有谱了。这就是我们要的“地图”。5. 核心实操Dump 首次应用全流程5.1 Dump 的本质是什么“Dump 应用”在 IL2CPP 语境下通常指两种需求一种是把global-metadata.dat和libil2cpp.so从进程内存里提取出来另一种是把某个运行中的对象、类或者方法的信息打印出来。两者用 il2cpp-bridge 都能做但首次尝试我强烈建议从“对象/类 dump”开始因为风险小、反馈快。如果是提取文件你要的其实是grep进程的 maps 文件找到 so 基址然后从内存里把 so 拷出来。这个用纯 Frida 也能做il2cpp-bridge 更多是帮你把“dump class 结构”这件事做得更漂亮。我先给你演示 dump 类结构信息之后再说文件提取。5.2 用现成 dump 脚本解析 global-metadataFrida-il2cpp-bridge 自家仓库里提供了一个高度集成的调用入口核心是Il2Cpp.Metadata的解析能力。你可以写一个函数把指定程序集里所有类的字段、方法签名都解析出来function dumpIl2CppAssembly(assemblyName) { const assembly Il2Cpp.Domain.Assembly(assemblyName); const image assembly.Image; const dump []; for (const [index, klass] of image.Classes) { const methods klass.Methods .map(m ${m.ReturnType.name} ${m.Name}(${m.Parameters.map(p p.Type.name p.Name).join(, )})) .join(, ); const fields klass.Fields .map(f ${f.Type.name} ${f.Name};) .join(, ); dump.push(${klass.Namespace}.${klass.Name} methods: ${methods} || fields: ${fields}); } return dump.join(\n); }这个函数会把程序集内的类包含的字段和方法签名全部列出来。我建议第一次 dump 就专注于这个方法先不用管复杂的实例对象等你熟悉了 API 再扩展。5.3 实例对象 dump从字段里读内存值假设你在游戏里发现有个血量变量想直接读出来验证。你不再需要去静态分析偏移只需要知道类名和字段名然后用 il2cpp-bridge 找到静态字段或某个实例方法。比如const playerClass Il2Cpp.Domain.Assembly(Assembly-CSharp) .Image.Class(Player); // 读静态字段 const staticHp playerClass.StaticField(hp).value; console.log(static hp: staticHp); // 如果有实例从某处拿 instance const playerInstance ...; // 可能来自某个全局方法返回值 const hp playerInstance.Field(hp).value; console.log(instance hp: hp);读字段时有一个大坑就是字段类型不对会导致 API 返回 ValueType 包装对象你直接打印可能看到一堆 0x0 的指针。这种情况要先看字段的Type.name比如它是System.Single那你需要.value之后才能看到浮点数。如果这步你输出的是.value本身是个对象就用Number(instance.Field(hp).value)强制转换一下。5.4 实例方法调用与返回值解析il2cpp-bridge 的Method对象有一个.invoke()方法可以像反射调用一样直接调用目标方法。我试过调用某游戏的Player.GetLevel()写起来非常短const level playerClass.GetMethod(GetLevel).invoke(); console.log(player level: level);但要注意invoke 的返回值如果是 C# 对象它不会自动转换成 Frida JS 对象而是一个Il2Cpp.Object包装。你需要对它再次调用.GetMethod()或者.Field()继续深挖。这也是新手最懵的地方以为 inv 完就结束了实则这只是揭开一层皮。5.5 首次 dump 的完整代码把上面几段整合成一个可以直接跑的脚本大概长这样function dumpClass(className) { const klass Il2Cpp.Domain.Assembly(Assembly-CSharp) .Image.Class(className); console.log(class: klass.Namespace . klass.Name); console.log(type: klass.Type); console.log(fields: ); klass.Fields.forEach(f { console.log( f.Type.name f.Name f.value); }); console.log(methods: ); klass.Methods.forEach(m { console.log( m.ReturnType.name m.Name ( m.Parameters.map(p p.Type.name p.Name).join(, ) )); }); } function main() { if (!Il2Cpp) { console.log(Il2Cpp bridge not loaded); return; } dumpClass(Player); } setImmediate(main);如果你连Player类名都不知道可以先跑一遍.Classes全量遍历把输出导出来搜关键字。这个过程不快类多了可能要几十秒但总比打开 IDA 翻字符串快多了。5.6 文件提取从内存保存 so 和 dat刚才的 dump 是信息解析如果你想把目标 so 和 metadata 从内存里抓下来做离线分析另一种方式就是纯 Frida 读取进程 maps定位文件映射段然后通过Process.getModuleByName()加上Memory.copy()将内容保存。Frida-il2cpp-bridge 也提供了快速拿到模块基址的方式const so Process.getModuleByName(libil2cpp.so); console.log(base: so.base); console.log(size: so.size);你可以从这里获取模块的内存段大小然后用文件流写到一个本地导出目录。抱歉这里我不展开写了因为纯文件 dump 已经脱离 il2cpp-bridge 的强项组合拳里有位更轻量的工具比如某内存dump工具更合适。首次体验我更建议你能先把类方法 dump 出来这个反馈能让你快速理解这个库的精髓。6. 常见问题与排查技巧实录6.1 一启动就报TypeError: Il2Cpp is not defined这个最常见。绝大多数情况不是库没生效而是你加载顺序搞错了。如果用的是多个-l加载方式必须把frida-il2cpp-bridge.js放在你自己的脚本之前。如果是用 frida-compile 模式记得检查你的编译器配置frida-il2cpp-bridge有没有被作为依赖打进 bundle 里。验证方式很简单直接在脚本最顶部打印Il2Cpp.version如果当前脚本报 not defined说明 bundle 没成功。还有一种隐蔽情况你在脚本里用了setTimeout或setImmediate但延迟的时间太短。某些 Unity App 的主线程在初始化早期会短暂阻塞Frida 脚本跑在非主线程也能执行但某些 IL2CPP API 必须等库初始化完才能访问。我建议最重要的一步在setTimeout后 1~3 秒再做而不是立刻执行。6.2 frida-server 版本与设备不匹配Frida 电脑端和手机端版本必须一致。我见过太多人电脑端升级到了最新版手机里的 frida-server 还是半年前装的然后报DeviceNotSupportedError或者直接连接超时。确认方式很简单frida -U -n com.sample.game如果这个附加过程能成功说明版本和连接没问题如果报unable to start或者 inflate 错误十有八九是版本不对。6.3 模拟器里类全部加载不全如果你用的是模拟器而且很多类显示null或找不到别急先确认 App 流程是否已经走到了相关界面。比如你 dumpMainMenuUI的字段但游戏还停在登录界面那这个类压根没被使用运行时可能还没注册。解决办法是手动推进游戏进度到目标页面再重新 attach。还有一个模拟器特有的坑Android-x86 镜像里某些 Unity 游戏会因为渲染 API 不兼容导致直接崩溃你要么换一个镜像版本要么趁它还在启动画面加载类的早期窗口期快速注入抓类。我实际试过最快的窗口也就 2 秒手速得练。6.4 类名或方法名的混淆问题有些 Unity 应用会在打包阶段对 Assembly-CSharp 里的类名做混淆。你搜Player搜不到搜Buridi也搜不到才发现全部都变成了类似a、b之类的短名字。这时候有两种策略第一种是老老实实把global-metadata.dat通过离线工具解一遍把真实名映射表还原出来第二种是直接用 il2cpp-bridge 遍历所有类配合字段或方法字符串特征去反推哪个是你想要的类。第二种方法举例你想找伤害计算函数可以扫描所有类找字段里带damage字样的类Il2Cpp.Domain.Assembly(Assembly-CSharp) .Image.Classes .filter(c c.Fields.Any(f f.Name.toLowerCase().includes(damage))) .forEach(c console.log(c.Namespace . c.Name));这个方法在处理轻度混淆的时候非常有效因为字段名混淆很少被彻底执行很多工具只混淆类名和方法名字段名往往保留。6.5 attach 模式 spawn 模式的取舍我做新手期测试都建议 spawn但你正式做分析时 attach 模式会更灵活。特别是针对已经长时间运行的服你不想重启游戏再走一遍流程attach 直接附加当前进程即可。attach 的要点是必须保证目标应用已经加载了libil2cpp.so否则你同样拿不到类。可以用Process.getModuleByName(libil2cpp.so)做一层判断try { Process.getModuleByName(libil2cpp.so); console.log(module loaded); } catch (e) { console.log(il2cpp not loaded yet); }如果模块未加载脚本里对应的Il2Cpp操作也会失败这时候就等一会或者用 spawn。6.6 dump 速度太慢怎么优化全量类遍历在大型游戏上要跑好几分钟不等这是正常的。先确认你过滤的条件是否太宽泛如果只需要某个命名空间用.Where(c c.Namespace GameLogic)直接过滤没有结果的类就不打印速度能快很多。如果你是要写长期驻留的 trace 脚本尽量不要在整个 Domain 级别频繁Classes遍历应该把找到的类引用存成变量复用。7. 几个实测过的高级小技巧7.1 直接 hook 某个 IL2CPP 方法查看参数和返回值现在你已经知道类名方法名不用 hook 地址直接写const klass Il2Cpp.Domain.Assembly(Assembly-CSharp).Image.Class(Player); const method klass.Method(LevelUp); method.implementation function () { const result this.LevelUp.apply(this, arguments); console.log(LevelUp called, result result); return result; };对你没看错它能像一个普通 JS 对象一样替换方法实现。这个技巧价值极高你可以在不改动游戏逻辑的情况下观察每个方法的调用频率、参数变化。我遇到过某个游戏的抽卡概率判断直接写在方法里面把参数打印出来完全能反推出保底机制。7.2 字段直接改成你想要的值有时候你想验证某些条件分支直接把字段改掉instance.Field(skillPoints).value 9999;改完之后游戏逻辑会看到新的数值后续行为跟着变。这一招在做弱化、免跳转等操作时非常高效。不过要提示一下有些游戏逻辑在本地校验完会把数据发给服务器你只改本地值等服务器回包可能又被纠正回来这种就得找更底层的函数 hook而不是简单改字段。7.3 拼接自己想要的 dump 输出il2cpp-bridge 的结构化程度很高你可以把 dump 信息输出成 JSONconst data []; Il2Cpp.Domain.Assembly(Assembly-CSharp).Image.Classes.forEach(c { data.push({ name: c.FullName, methods: c.Methods.map(m m.Name), fields: c.Fields.map(f f.Name) }); }); send(data);然后用 Frida 的send把 JSON 传给电脑端在 Python 脚本里接收存文件。这样做的好处是方便后续写自动化脚本批量分析而不只是纯文本打印。8. 总结一下我个人实操的体会从第一次碰libil2cpp.so时的一头雾水到现在能快速在新目标上 dump 类和方法Frida-il2cpp-bridge 确实帮了大忙。它的学习曲线跟 Frida Runtime 很像刚开始是搜索 API 的眼睛用顺手之后简直想要所有逆向项目都塞给它。如果你今天只想记住一句话先用遍历 Assembly 和 Class 建立地图找目标类再用 Method().implementation 钩住最后用 Field().value 读写数据。这套思路能覆盖百分之七八十的 Unity IL2CPP 调试需求。至于后续要不要补脚本脱壳、加固绕过等技巧就看你自己遇到的目标复杂度了。我这边还有几个没展开的案例下次换个主题再跟你们慢慢聊。