尧图精选

IL2CPP 动态插桩:frida-il2cpp-bridge 入门实战

🕒 发布时间:2026/10/1 4:38:17 📁 来源:尧图网络
记一下我这两周折腾frida-il2cpp-bridge的全过程。起因很简单手上有一个自己用 Unity 打包的测试 APK想搞清楚运行时某个角色的数值是怎么被改写的翻代码翻不动静态反编译出来的 C 又全是寄存器跳转看得人头大。后来换思路用动态插桩在运行时直接看类和方法的真实结构才算把这条路走通而frida-il2cpp-bridge就是这条路上最省力的那座桥——它把 IL2CPP 运行时的内部结构包装成了一层很像 JavaScript 对象的 API让你不用手写偏移量也不用管 ARM 汇编怎么读参数。这篇东西是写给和我当时一样的新手的你可能听过frida也大概知道 IL2CPP 是 Unity 的脚本后端但真到自己动手从装环境开始就一路报错。我会按我踩过的顺序讲——环境怎么装、版本怎么对、脚本怎么写、类和方法怎么找、hook 之后怎么读参数和字段、以及那几个让我卡了整整两个晚上的报错。所有演示都跑在我自己编译的测试工程上这也是我一直建议新手起步的方式先有一个完全属于自己的样本把工具链和思路跑通再去碰更复杂的场景。1. 先把这三件事分清楚Frida、IL2CPP、Bridge1.1 一句话讲明白三者关系先把概念理一遍不然后面配置的时候容易迷糊。Frida是一套动态插桩框架它在目标进程里注入一个 JavaScript 运行时叫 Gum你的脚本就跑在这个运行时里可以拦函数、读内存、改返回值。它本身跟 Unity 没有任何关系它只是个能进进程里干活的工具。IL2CPP是 Unity 的一种脚本后端。C# 代码在构建时先被翻译成 C再由平台编译器编译成原生机器码。这就带来一个后果原本在 Mono 后端下清清楚楚的类名、方法名、字段名编译之后全都变成了内存地址和偏移量。你从外部看它就是一坨没有名字的原生代码。frida-il2cpp-bridge做的事情是在这两者之间架一层翻译。它在运行时找到 IL2CPP 导出的几个关键函数比如il2cpp_domain_get、il2cpp_class_from_name、il2cpp_object_new这类通过这些官方 API 反查出类的元数据再把类、方法、字段、字符串、数组都包成你在脚本里能直接点的对象。所以它不是破解工具更像是一个运行时的结构浏览器和调用拦截器。这个定位很重要它能让你看见结构能让你在方法调用前后插一段自己的逻辑但它不负责猜业务逻辑也不负责解释某个数值为什么是那个值。那部分还是得靠你自己去 trace 和分析。1.2 为什么不从静态反编译开始我一开始走的是静态路线把 APK 里的libil2cpp.so和global-metadata.dat拖出来用工具还原类名和方法名。这条路能走通但对我这种新手有几个明显门槛。一是元数据还原对文件完整性要求高稍微被动过一点就对不上二是还原出来的是某方法在某个地址你还得自己去算参数寄存器、自己处理结构体布局三是它是一次性的快照你想知道某方法被调用了多少次、参数是什么静态手段给不了。动态插桩的优势就在这儿运行时是什么样你看到的就是什么样。类名混淆了没关系你可以顺着调用关系往上摸参数在哪个寄存器不用管bridge 直接把参数数组递给你。代价是脚本得跟着进程跑每次调试都要重新注入而且对版本匹配很敏感——这一点后面会重点讲。1.3 什么样的人适合从它入手如果你满足下面几条里的一半我觉得可以试试手里有独立的测试设备或者模拟器能用adb基本操作写过一点 JavaScript 或 TypeScript其实 TypeScript 更推荐因为有类型提示能接受命令行报错然后自己搜。反过来如果你完全没接触过命令行建议先用一两天把adb和npm的基本操作过一遍再来碰这个否则光是环境问题就能劝退。提示把学习样本限定在自己编译的工程、官方示例项目、开源 Demo 上。这不是形式上的合规话术而是实际效率问题——自己的工程你能对照源码看输出出了问题知道是脚本错还是样本错定位速度差好几倍。2. 环境搭建版本对齐比装什么都重要2.1 三个版本必须咬合我浪费最多时间的地方是版本问题。这里有三层版本PC 端frida命令行工具、Python 端的frida库、设备端跑的frida-server。这三个的版本号必须完全一致至少大版本和小版本要对上。我用的是 16.x 系列PC 端和设备端都是同一个精确版本没有例外。安装就两条命令pip install frida16.1.4 frida-tools# 设备端先看架构再下对应包 adb shell getprop ro.product.cpu.abi输出如果是arm64-v8a就去下载frida-server-16.1.4-android-arm64.xz。版本号要跟你pip装的那个一模一样。装完之后用frida --version和frida-ps -U验证后者能列出设备上的进程说明通信链路通了。注意frida-ps -U报unable to connect to remote frida-server九成是设备端 server 没起来、版本不匹配、或者路径没执行权限。按这两个方向查比乱搜快得多。2.2 设备端准备与连接自检设备端我一般放/data/local/tmp/这个目录不用改分区权限。推上去之后给执行权限再后台跑起来adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell su -c /data/local/tmp/frida-server 跑完之后另开一个终端敲frida-ps -U能看到进程列表就说明整条链路 OK 了。如果设备没 root那就得走重打包加 gadget 的路子复杂度会上升一个台阶新手阶段我建议先用一台能 root 的测试机或者带 root 的模拟器把主流程跑顺。还有个小细节同时装了多台设备或者模拟器时-U可能会选错目标用frida-ps -D device-id指定一下device-id从frida-ls-devices里拿。2.3 Node 与 TypeScript 工具链frida-il2cpp-bridge基本是按 TypeScript 写的它的类型定义是你最大的助力——方法名、参数类型、返回类型编辑器里点一下就能看见。所以别偷懒直接写 JS装一套 Node 环境node -v # 建议 18 以上 npm -v然后在工程目录里初始化装两个东西主库和编译器。npm init -y npm install frida-il2cpp-bridge npm install --save-dev frida-compile typescriptfrida-compile负责把 TS 和所有依赖打包成一个单文件 JS因为 Frida 注入的时候只能吃一个脚本文件没法帮你做模块解析。2.4 工程结构与编译脚本我的目录长这样非常简单il2cpp-demo/ ├── index.ts # 主脚本 ├── tsconfig.json ├── package.json └── _agent.js # 编译产物package.json里加两个快捷命令省得每次都敲一长串{ scripts: { build: frida-compile index.ts -o _agent.js, watch: frida-compile index.ts -w -o _agent.js } }watch模式是我最常用的脚本改一下自动重编译另一边注入的会话如果用了热重载还能直接生效来回调试效率提升很明显。tsconfig.json按库里 README 给的那份来就行核心是target别设得太老module用commonjsstrict打开——严格模式会在你参数类型写错的时候直接报错比运行到一半崩溃好得多。3. 第一个脚本从能跑起来到看懂类结构3.1 最小可运行骨架万事开头难但第一版脚本其实短得可怜import frida-il2cpp-bridge; Il2Cpp.perform(() { console.log(IL2CPP 已就绪开始干活); });就这三行能跑通就说明环境没问题了。Il2Cpp.perform这个包装的作用是等 IL2CPP 运行时初始化完成之后再执行回调。它内部做了等待和重试你不用自己写轮询。我第一次跑的时候看到那行日志蹦出来比后面写成百行 hook 还开心因为那意味着最难的环境部分结束了。注入命令是frida -U -f com.your.testapp -l _agent.js-f表示启动应用并注入-l加载脚本。有个坑要注意新版frida-tools里--no-pause这个参数已经被移除了如果你从老教程里抄了带它的命令会看到unrecognized arguments: --no-pause的报错。后面第 5 章我会专门讲这个。3.2 遍历 image、class、method 的正确姿势跑通之后第一个想做的事肯定是看看里面到底有什么。IL2CPP 的组织结构是 Domain → Assembly → Image → Class → Method/Field。写起来是这样Il2Cpp.perform(() { const assembly Il2Cpp.domain.assembly(Assembly-CSharp); const image assembly.image; console.log(程序集 ${assembly.name} 下有 ${image.classes.length} 个类); });这里的Assembly-CSharp是你自己项目里 C# 脚本默认打进去的程序集名。Unity 自带的运行时类在mscorlib里通过Il2Cpp.corlib拿。我第一次看到自己那个类列表刷出来的时候感觉像在黑暗里突然开了灯。不过别急着遍历打印所有类和方法。我实测过一个中等规模的工程Assembly-CSharp里光类就有七千多个方法加起来十几万全打印出来会把日志通道堵死脚本直接卡住。正确做法是按需查找const klass image.tryClass(Game.Player); if (!klass) { console.log(没找到这个类检查命名空间); } else { console.log(类名 ${klass.name}命名空间 ${klass.namespace}); klass.methods.forEach(m console.log( ${m.name}(${m.parameterCount}))); }用tryClass而不是class找不到的时候返回空值而不是抛异常脚本不会因为一个类名写错就整个挂掉调试体验差很多。3.3 方法签名里那些让人困惑的细节打印方法列表的时候你会发现名字千奇百怪有几个规律值得记住。第一重载方法名字完全相同只能靠参数个数或参数类型区分。所以看到三个都叫Update的方法别慌数一下参数数量基本就能定位。第二属性访问器会被翻译成get_XXX和set_XXX这在 hook 数值的时候特别有用很多时候你直接改 setter 比改业务方法更精准。第三泛型方法会有反引号和编号后缀比如GetData后面跟一串符号这是编译器生成的不影响使用但要能认出来。还有一个隐藏坑klass.methods默认只给你当前类自己声明的方法不含父类继承的。如果你要找的方法在基类里得往上找klass.parent。我就因为这个卡了半小时一直以为方法不存在其实是继承了。4. 核心 API 实战hook、读字段、追踪调用4.1 方法拦截的两种写法明白了结构之后真正的重头戏是拦截。bridge 提供两种拦截方式用哪种取决于你想干什么。第一种是完全替换实现适合你想改行为的时候const playerClass Il2Cpp.domain.assembly(Assembly-CSharp).image.class(Game.Player); const addScore playerClass.method(AddScore); addScore.implementation function (value: number): void { console.log(AddScore 被调用传入 ${value}); addScore.invoke(this, value * 2); };这里有几个点必须说清楚。函数要用function声明而不是箭头函数因为桥接层会把实例对象绑定到this上箭头函数拿不到。要执行原逻辑得显式调用method.invoke(this, ...参数)如果你不调用原方法就完全不执行了——这个特性很有用但也很容易在调试的时候忘记导致行为对不上。参数类型要跟真实签名一致写错了桥接层在转换的时候会抛异常报错信息通常不太直观。第二种是只观察不修改用interceptaddScore.intercept({ onEnter(...args) { console.log(进入方法参数个数 ${args.length}); }, onLeave(retval) { console.log(方法返回了); } });这种方式更安全不会破坏原逻辑适合做探针。我一般先用它扫一轮确认真实调用路径和参数含义再决定要不要改成完全替换。4.2 字段读写静态和实例要分开处理字段这块新手最容易绕晕核心就一条规则静态字段挂在类上实例字段挂在对象上。静态字段读起来很直接const maxLevel playerClass.field(MaxLevel); console.log(当前值 ${maxLevel.value}); maxLevel.value 99;实例字段要先拿到对象再从对象上取。问题在于对象通常不在你手里它是在某个方法内部创建或者作为参数传进来的。所以常见套路是在构造方法或初始化方法里拿到它const initMethod playerClass.method(Init); initMethod.intercept({ onEnter() { const self this; const level self.field(currentLevel).value; console.log(初始化时等级是 ${level}); } });这段代码我调试了挺久才写对因为一开始我以为this在所有回调里都可用实际测试下来onEnter里是可以的但语义上它指的是被拦截方法的接收者理解这一点之后很多困惑就解开了。值类型字段int、float、bool 这些读出来直接是 JS 原始类型但结构体字段读出来是个包装对象需要额外处理才能拿到内部的数值这部分我在第一次用的时候完全没意识到看到输出是一串看不懂的东西还以为是乱码。4.3 字符串、数组、结构体的取值方式这三类是踩坑重灾区单独说说。字符串在 IL2CPP 里是托管对象不是 C 字符串。读的时候要用它的内容属性const s someObject.field(playerName).value; console.log(s.content); // 才是真正的文本如果你直接console.log(s)看到的是一堆对象信息一开始我还以为是编码问题折腾了半天编码结果根本不是那回事。数组不能用下标直接取要走方法const arr someObject.field(items).value; console.log(长度 ${arr.length}); for (let i 0; i arr.length; i) { console.log(arr.get(i)); }数组长度大时别循环全打印。我实测过一万个元素的数组逐条console.log通过 USB 通道往 PC 传直接把脚本卡了好几秒。改成只打印前十个加总长度体验立刻正常。结构体要经过装箱拆箱。这块我建议新手先跳过去等前面几类玩熟了再回来看因为结构体的内存布局跟具体版本关系比较紧容易写出不报错但结果不对的代码。4.4 用 trace 快速定位关键方法如果你完全不知道该拦哪个方法trace是最快的破局手段。给一个方法调用method.trace()它会把该方法及其子调用链打印出来形成一棵调用树。playerClass.method(Update).trace();但这里有个量级问题必须提前算. 假设一个方法每秒被调用 60 次Unity 的帧循环基本就是这个量级每次产生的追踪消息平均 300 字节那一秒钟就是 18KB一分钟超过 1MB。听起来不多但 Frida 的消息是通过 adb 通道传回 PC 的实际吞吐远低于理论带宽一旦堆积游戏画面会卡成幻灯片甚至脚本直接被踢出。我的做法是分三步走先用trace只追踪一个很小的方法比如某个按钮回调点一次触发一次看清调用模式然后给追踪加上条件只在特定参数值下打印最后确认路径之后再换成精确的intercept。这个顺序看起来绕实际比我一开始上来就 trace 大方法快得多。提示追踪输出量大时考虑在脚本里做聚合——比如只统计调用次数、只在第一次和每第 100 次打印。信息量少一半可用性反而更高。5. 踩坑记录与排查清单5.1 那个烦人的 --no-pause 报错几乎每个照着老教程做的人都会撞上这个scripts\frida: error: unrecognized arguments: --no-pause看到路径里带scripts\说明这是从 npm 脚本里调出来的不是你在命令行手敲的。原因是frida-tools新版本把--no-pause参数移除了你旧配置里还带着它自然就报参数无法识别。解决办法分两种情况。如果你的命令写在package.json的scripts里直接打开文件把那一段--no-pause删掉然后手动补上恢复流程。如果是在命令行手敲同理去掉参数即可。去掉之后遇到的问题是新版frida -f启动目标后会停在入口等你的指令不会自动继续跑。这时候在 Frida 的交互式界面里输入%resume进程才会继续。我一开始不知道这个脚本加载完了干等着还以为目标卡死了反复重启了好几次。用 Python API 的话更直观device.resume(pid)一行搞定适合写自动化脚本的场景。5.2 类找不到、方法名对不上、进程闪退这三类问题占了新手报错的绝大部分我按先查什么后查什么的顺序列一下。类找不到先确认程序集名对不对Assembly-CSharp之外还有Assembly-CSharp-firstpass、自定义 asmdef 生成的程序集。再确认命名空间——Player和Game.Player是两个不同的键。最后确认这个类有没有被代码裁剪掉Unity 的托管代码剥离Managed Stripping在打包时会把没被引用的类删掉如果这个类只在编辑器里用过打包后就不存在了。方法名对不上检查是不是在父类、重载、属性访问器这三种情况里。我建议先把klass.methods全打出来肉眼扫一遍比猜快。进程闪退多数是脚本执行时抛了未捕获的异常加上在错误的时机访问了还没初始化的对象。用try/catch把可疑代码包起来把异常信息打出来基本都能定位。另一个常见原因是给方法设置了错误的参数类型桥接层在转换时崩掉进程也跟着走。现象最可能原因优先排查动作找不到类程序集名或命名空间不对打印全部程序集名逐一比对找不到方法在父类/重载/属性访问器打印完整方法列表注入后进程退出脚本抛异常加 try/catch 并输出堆栈脚本卡死无响应大量日志堵塞通道减少输出频率或做聚合提示参数无法识别命令行参数与版本不符去掉废弃参数手动恢复5.3 关于反调试检测新手该有的认知搜索热词里frida 反调试出现的频率很高我理解大家的好奇但这里我想换个角度聊因为这一点对新手特别重要。很多应用会检查自己的运行环境比如检测内存里有没有异常模块、检查调试相关接口的状态、校验代码段有没有被改动。这些机制的存在是正常的工程实践它保护的不只是商业利益也包括用户数据安全。作为学习逆向和动态调试的人遇到这类机制时正确的应对不是想办法绕过去而是换一个属于自己的试验场——自己编译一个带同样架构的工程在完全可控的环境里学习工具链和 API 的用法。你在这上面学到的东西一点都不会少反而因为没有对抗干扰效率高得多。我自己的做法是建了一个 Unity 测试工程专门写了一个类里面放各种类型的字段、各种参数形态的方法、一套继承结构打包成 APK 当靶子。所有新脚本都先在这个工程上验证跑通了再考虑其他场景。这个习惯帮我省掉了大量到底是脚本错还是环境干扰的排查时间。5.4 一张能救命的排查速查表把上面零散的经验收拢成一张表出问题的时候按顺序过一遍命中率很高。排查方向具体动作常见结果三段版本是否一致对比 PC 端 frida、Python 库、设备端 server版本不一致是最常见根因通信链路是否通执行 frida-ps -U 看进程列表报连接失败则查 server 是否在跑IL2CPP 是否初始化完成确认逻辑写在 perform 回调内写在外部会拿到空对象目标类是否存在打印 image.classes 数量并搜索类被裁剪或命名空间写错方法是否被正确拦截先加 onEnter 打印再改实现直接改实现容易破坏原逻辑输出是否过量统计每秒日志条数超过阈值就聚合通道堵塞导致卡顿或断开最后再补几个我自己总结的小习惯。写完一段 hook 先跑一遍console.log确认参数类型和你以为的一致很多逻辑不对其实是类型不对脚本里所有可能为空的返回值都做判断别信自己的记忆每次改脚本前先确认设备端 server 还活着它偶尔会被系统回收我因为这个白排查过两次。从能跑通第一个Il2Cpp.perform到能稳定地在自己的测试工程里定位方法、读字段、看调用链我大概花了一个星期其中前三天全在环境上。如果你现在正卡在某个报错上我的建议是把范围缩小到一个最小脚本一次只验证一件事——环境、连接、查找、拦截分开验证成功率比一口气写一大段脚本高得多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →