记一下我这两周折腾frida-il2cpp-bridge的全过程。起因很简单:手上有一个自己用 Unity 打包的测试 APK,想搞清楚运行时某个角色的数值是怎么被改写的,翻代码翻不动,静态反编译出来的 C++ 又全是寄存器跳转,看得人头大。后来换思路,用动态插桩在运行时直接看类和方法的真实结构,才算把这条路走通,而frida-il2cpp-bridge就是这条路上最省力的那座桥——它把 IL2CPP 运行时的内部结构包装成了一层很像 JavaScript 对象的 API,让你不用手写偏移量,也不用管 ARM 汇编怎么读参数。
这篇东西是写给和我当时一样的新手的:你可能听过frida,也大概知道 IL2CPP 是 Unity 的脚本后端,但真到自己动手,从装环境开始就一路报错。我会按我踩过的顺序讲——环境怎么装、版本怎么对、脚本怎么写、类和方法怎么找、hook 之后怎么读参数和字段、以及那几个让我卡了整整两个晚上的报错。所有演示都跑在我自己编译的测试工程上,这也是我一直建议新手起步的方式:先有一个完全属于自己的样本,把工具链和思路跑通,再去碰更复杂的场景。
1. 先把这三件事分清楚:Frida、IL2CPP、Bridge
1.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 frida==16.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用commonjs,strict打开——严格模式会在你参数类型写错的时候直接报错,比运行到一半崩溃好得多。
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, ...参数),如果你不调用,原方法就完全不执行了——这个特性很有用,但也很容易在调试的时候忘记,导致行为对不上。参数类型要跟真实签名一致,写错了桥接层在转换的时候会抛异常,报错信息通常不太直观。
第二种是只观察不修改,用intercept:
addScore.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,到能稳定地在自己的测试工程里定位方法、读字段、看调用链,我大概花了一个星期,其中前三天全在环境上。如果你现在正卡在某个报错上,我的建议是把范围缩小到一个最小脚本,一次只验证一件事——环境、连接、查找、拦截,分开验证,成功率比一口气写一大段脚本高得多。