1. 先搞清楚这套工具到底在解决什么问题
1.1 Unity 的 IL2CPP 到底做了什么
我刚开始接触这类工具的时候,最大的困惑不是"怎么装",而是"为什么装完之后能看到的东西这么少"。后来才明白,问题出在 Unity 的打包方式上。Unity 官方给了两种脚本后端:Mono 和 IL2CPP。早期很多项目用 Mono,脚本会被编译成Assembly-CSharp.dll这样的托管程序集,里面保留了大量元数据,类的名字、方法的签名、字段的类型都老老实实躺在里面,随便拿个反编译工具就能看个大概。
IL2CPP 换了一条完全不同的路。打包时它先把 C# 中间语言翻译成 C++ 代码,然后再交给平台自带的编译器编译成真正的机器码。这么做的动机很实在:性能更好、更难被直接反编译、也能跨更多平台。代价就是,运行时那套"类、方法、字段"的元数据被单独塞进了global-metadata.dat这个文件里,代码则变成了一堆没有可读名字的本地函数。你用普通反编译器去打开主程序集,看到的是一片"未找到"或者一堆Il2CppDummyDll里的空壳声明,方法体全是空的。
这就引出了核心难点:代码真实存在于内存里,但它的结构信息被抽离成了另一份元数据。想在这个基础上做分析、做调试、做自测,就必须有一个东西能够在运行时把这两半重新拼起来——一边找到真正的机器码地址,一边解析元数据还原出类名和方法名。frida-il2cpp-bridge 干的就是这件事。
我踩过的第一个坑就是:以为只要用 Frida 附加上去,Module.enumerateExports里就能列出一堆类和方法。实际上一脸茫然,全是il2cpp_前缀的底层导出,没有半点业务语义。这也是为什么需要 bridge 这层封装,它帮我们把元数据解析、符号还原、调用约定这些脏活全包了。
1.2 Frida 与 frida-il2cpp-bridge 的分工
先把两个东西分清楚,不然后面看文档会一直绕。Frida 是底层的动态插桩框架,它负责把一段 JavaScript 注入到目标进程里,提供内存读写、函数 Hook、线程枚举这些基础能力。它本身跟 Unity 一点关系都没有,你拿它去 Hook 一个原生 App 也完全没问题。
frida-il2cpp-bridge 是建立在 Frida 之上的一层高层封装,专门面向 IL2CPP 运行时。它做了一件很聪明的事:不要求你手动去算偏移、手动去读元数据,而是提供一套接近 C# 语义的 API,比如"拿到某个程序集""拿到某个类""Hook 某个方法""读写某个字段"。你写的是 TypeScript,编译出来的还是 Frida 脚本,只不过里面调的是 bridge 封装好的接口。
我个人的理解是,Frida 提供了"手",bridge 提供了"眼睛"。没有眼睛,你看不清 IL2CPP 内部的构造;没有手,你看清了也动不了。两者缺一不可。
拿生活里的场景类比:Frida 像是给你一套精密的螺丝刀和放大镜,而 frida-il2cpp-bridge 像是一份已经标注好每个零件位置和名称的装配图。没有装配图,你得自己一块块摸索每个螺丝是干嘛的;有了它,你直接照着图找"第三排第二个卡扣"就行。
这里要特别提醒一句:这套工具的能力边界,完全取决于你有没有合法的分析目标。它适合用在自己开发的 Unity 应用的性能分析、崩溃定位、逻辑自测上,也适合安全研究人员在自己搭建的测试环境里研究运行时行为。拿它去处理来路不明的、别人家的付费应用,既不合规也不安全,这个底线我在后面还会反复强调。
1.3 这套组合最适合谁来学
如果按经验分层,我觉得有三类人适合往下看。第一类是自己做 Unity 开发、想搞清楚运行时到底发生了什么的开发者,比如某些逻辑在真机上表现异常,日志又打不全,用这套工具能直接在运行时验证字段和方法的结果。第二类是对逆向和运行时有兴趣的学习者,想理解 IL2CPP 的元数据结构和 Hook 原理,这套工具是很直观的入口。第三类是做安全研究或应用自测的工程师,需要在受控环境下观察程序的调用路径。
不太适合的是完全没碰过命令行和 JavaScript/TypeScript 的人。不是说学不会,而是这套链路里报错信息普遍偏底层,一旦缺乏基础的排错直觉,很容易在第一关就卡住。我建议至少先熟悉一下 Node 的包管理和命令行基本操作,再回来折腾,会顺手很多。
2. 环境准备:从零把工具链搭起来
2.1 需要哪些前置软件,为什么是这几个
整套链路需要的东西不多,但每一样都有它的位置,缺了会卡在不同环节。
| 组件 | 作用 | 为什么必须 | 常见踩坑点 |
|---|---|---|---|
| Node.js | 运行 npm 和编译脚本 | bridge 的脚手架和编译都依赖它 | 版本太老会编译失败,建议 LTS |
| npm | 拉取依赖 | 安装 frida-il2cpp-bridge 的载体 | 国内网络慢,容易卡在下载 |
| frida CLI | 附加到进程、加载脚本 | 真正把脚本注入目标 | 版本要和 frida 库对应 |
| frida-il2cpp-bridge | 高层 API 封装 | 提供类/方法/字段操作接口 | 版本更新快,API 有变化 |
| frida-compile | 把 TS 打包成单文件 | Frida 只吃编译后的 JS | 名字/用法在新版有调整 |
这里我着重说 Node 版本这件事。我第一次装的时候用的是某个很老的版本,结果frida-compile一跑就报一堆跟 ES 模块语法相关的错,排查了半天才发现是 Node 太旧。结论很简单:直接用当前 LTS 版本,别图省事用系统自带的旧版。用node -v确认一下,低于 v16 的话我建议先升级。
Frida 这边也一样。frida CLI 的版本和你脚本里依赖的 frida 相关包最好保持一致或者接近。我见过不少"脚本加载后一片空白"的情况,最后都是版本对不上导致的。用frida --version看命令行工具的版本,再对比一下工程里依赖的版本,心里有数。
至于 frida-compile,这几年它的用法变化比较大,早期的写法在新版里可能不适用。如果你照着某篇老教程敲命令却报"命令不存在"或者参数错误,大概率就是踩到了这个坑。我建议直接以你安装的那个版本自带的文档为准,别硬套旧命令。
2.2 工程初始化:把 TypeScript 骨架搭起来
我的习惯是不在全局乱装东西,专门建一个干净的工程目录,所有依赖装在本项目里。这样以后想换版本或者删掉重来,直接删文件夹就行,不会污染环境。
第一步是建目录、初始化项目:
mkdir my-il2cpp-project cd my-il2cpp-project npm init -y第二步装依赖。frida-il2cpp-bridge 作为开发依赖装进来,同时把 TypeScript 和编译工具也装上:
npm install -D frida-il2cpp-bridge npm install -D typescript frida-compile这里的-D表示开发依赖,因为这个工程本身不是要发布的库,只是本地拿来编译脚本的。装完之后打开package.json,你会看到依赖里多了这几项。我强烈建议把版本号用^或者明确写死,避免某天自动升级到不兼容的新版本,导致昨天还能跑的脚本今天就崩了。
第三步是配置 TypeScript。新建一个tsconfig.json,给一个够用的最小配置就行:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist" }, "include": ["src/**/*.ts"] }target我设成 ES2020 是因为 Frida 的运行时对较新的语法支持还行,设太低会导致一些语法糖被转译得很啰嗦。strict打开是好事,能在写代码阶段就帮你抓出类型错误,省得打包完加载时才发现字段名拼错了。skipLibCheck是为了跳过第三方库本身的类型检查,避免被它们内部的类型问题拖累编译。
2.3 把脚本编译成 Frida 能吃的文件
这一步是整个流程里最容易让人迷惑的地方。你写的是 TypeScript,但 Frida 加载的时候只认编译打包后的单个 JavaScript 文件。中间必须有一步"打包"。
在package.json里加一个脚本,把编译命令固化下来:
{ "scripts": { "build": "frida-compile src/index.ts -o dist/agent.js", "watch": "frida-compile src/index.ts -o dist/agent.js -w" } }build是编译一次,watch是监听文件变化自动重编译。开发阶段我基本都用watch,因为我改完代码不用手动再敲一次命令,保存就行,Frida 这边重新附加一下就能看到新逻辑,省了大量来回折腾的时间。
这里要注意一个细节:frida-compile默认会尝试把依赖一起打包进去,包括 frida-il2cpp-bridge 的代码。所以最终产出的agent.js体积会比较大,这是正常的,不用慌。因为桥接逻辑必须随脚本一起注入,不然目标进程里根本没有这套 API。我第一次看到几百 KB 的产物还以为出问题了,后来才明白这些都是必要的。
装依赖的时候如果卡在下载环节,多半是网络问题而不是配置问题。可以换个时段重试,或者确认一下 npm 的 registry 配置是否正常。这个跟我们要做的事情无关,纯粹是拉包速度的问题。
3. 第一个可运行脚本:从加载到 Hook 一个方法
3.1 最小可用骨架,先把"能跑起来"这件事确认掉
我强烈建议第一版脚本什么都别干,就确认能不能正常加载、能不能拿到 IL2CPP 域。很多人一上来就写一堆 Hook 逻辑,结果脚本报错或者什么都没输出,连到底卡在哪一步都不知道,排查起来极其痛苦。
最小骨架大概长这样:
import "frida-il2cpp-bridge"; Il2Cpp.perform(() => { console.log("[*] IL2CPP 域已就绪"); console.log("[*] 程序集数量:", Il2Cpp.domain.assemblies.length); });这里的Il2Cpp.perform是桥接库提供的入口,它会在 IL2CPP 运行时准备好之后再执行回调。为什么必须包在它里面?因为进程刚附加上的时候,IL2CPP 运行时可能还没初始化完,这时候你去访问类和方法会直接拿到空值或者崩溃。perform帮你处理了"等待就绪"这件事,这是新手最容易忽略的一点。
跑起来之后你会看到程序集数量的输出。如果这个数字大于零,说明桥接已经成功挂上了,后面的事情都好办。如果这里就报错或者没输出,那基本可以断定是附加本身、版本匹配或者目标不兼容的问题,跟后面的业务逻辑无关。
加载脚本的命令大致是这样:
frida -U -f com.example.yourgame -l dist/agent.js-U是指定通过 USB 设备附加,-f是指定包名并让 Frida 负责启动这个进程。用-f而不是直接附加已经运行的应用,是很重要的一点。因为很多 IL2CPP 应用在启动早期就完成了运行时初始化,如果你等它跑起来再附加,可能已经错过了最好的时机,而且有些初始化逻辑不会重跑。让 Frida 从启动那一刻就介入,能确保perform一定等到运行时就绪。
3.2 遍历程序集、类与方法,先建立"地图感"
拿到域之后,下一步我建议先别急着改东西,而是把结构摸清楚。这就像进一个陌生的大仓库,先看看货架怎么摆的,再去找具体的东西。
浏览程序集可以这样写:
Il2Cpp.perform(() => { Il2Cpp.domain.assemblies.forEach((assembly) => { console.log("程序集:", assembly.name); }); });程序集里通常有几个你会反复打交道的。mscorlib是基础库,System相关的东西都在里面;而你自己业务逻辑所在的,一般是Assembly-CSharp这一类。找到目标程序集之后,就可以往下钻到 image、class、method 这一级。
Il2Cpp.perform(() => { const assembly = Il2Cpp.domain.assembly("Assembly-CSharp"); const image = assembly.image; const klass = image.class("PlayerController"); console.log("类名:", klass.name); console.log("命名空间:", klass.namespace); klass.methods.forEach((m) => console.log("方法:", m.name)); });这里有个实战经验:方法列表通常比你想象的长得多,一屏刷不完。第一次跑的时候我建议先把它输出到一个文件里,或者只挑关键词过滤一下,不然滚屏滚到怀疑人生。另外image.class如果找不到类会抛异常,做探索的时候用image.tryClass更安全,找不到返回空值,不会把脚本整个搞崩。
类的全名要带命名空间。如果你的类定义在某个命名空间下,直接写类名是找不到的,得写成命名空间.类名这种形式。我第一次就栽在这里,盯着一个明明存在的类来回确认拼写,最后发现是漏了命名空间前缀。
3.3 拦截一个方法,看它什么时候被调用
地图摸清楚了,就可以做第一件有实际意义的事:Hook 一个方法,观察它的进入和退出。
Il2Cpp.perform(() => { const klass = Il2Cpp.domain.assembly("Assembly-CSharp") .image.class("PlayerController"); klass.method("Jump").intercept({ onEnter(this: Il2Cpp.Object) { console.log("[+] Jump 被调用,对象地址:", this.handle); }, onLeave(this: Il2Cpp.Object, retval) { console.log("[-] Jump 结束"); } }); });intercept是这套 API 里最关键的方法之一,它同时给了你onEnter和onLeave两个回调。为什么这两个都要?因为很多逻辑的价值在于"进入时是什么状态"到"离开时变成了什么状态"之间的差异。比如某个方法进入时血量是 100,离开时变成 50,你就能推断出中间发生了什么。
this在这里代表被调用方法的实例对象本身,handle是它在内存里的地址。这个地址在排查问题时很有用,你可以拿它去对比是不是同一个对象,或者在多次调用之间做关联。
需要提醒的是,Hook 本身是有开销的。如果一个方法每帧被调用几十次,你在onEnter里写一大堆日志,帧率会肉眼可见地掉下来,甚至触发超时。所以调试阶段可以放开打日志,一旦确认逻辑对了,就赶紧把日志收一收。我自己就吃过这个亏,一个渲染相关的方法每帧跑几百次,日志把控制台刷爆,应用卡到几乎没响应。
4. 几个高频操作的实际写法
4.1 读写字段:最常用的观测手段
相比 Hook 方法,读写字段往往更安静也更直接。方法 Hook 会频繁触发回调,而字段读写是你主动去查的,时机完全由你控制。
Il2Cpp.perform(() => { const klass = Il2Cpp.domain.assembly("Assembly-CSharp") .image.class("PlayerController"); // 读取静态字段 const instanceField = klass.field("instance"); console.log("单例地址:", instanceField.value); // 读取实例字段需要先有对象 // 通常通过某个已知对象去拿 });静态字段可以直接从类上取,因为它不依赖具体实例。实例字段就不一样了,你得先有一个对象引用。通常的做法是先 Hook 一个能拿到this的方法,把对象存起来,之后再慢慢查它的字段。
改字段和读字段写法一样,只不过是把值写进去:
klass.field("maxHealth").value = 9999;这句话看起来平平无奇,但背后的含义是你在运行时直接改写了内存里的数值。这也是为什么我一直强调合法边界——在自己的测试环境里验证逻辑没问题,但如果你把这种能力用在别人家的在线游戏上,性质就完全变了,那是绝对不行的。技术是中性的,用它做什么才决定了它的意义。
这里有个容易忽略的点:字段类型要对得上。你把一个 int 字段硬塞一个浮点数进去,可能不报错但结果莫名其妙。写之前先确认字段的声明类型,field.type.name能帮你看一眼。我第一次改数值的时候没注意类型,改完发现界面显示的还是旧值,折腾半天才想起来是类型不匹配导致的赋值被忽略了。
4.2 调用方法:不只是观察,还能主动触发
有些时候光观察不够,你想主动调用一个方法看看会发生什么。这在自测场景里特别有用。
const method = klass.method("Reset"); method.invoke(obj);对于有参数的方法,参数要按顺序传进去:
klass.method("ApplyDamage").invoke(enemyObj, 100);invoke的语义和你在 C# 里直接调方法几乎一样,只不过现在是从外部脚本里驱动。我最常在两种场景用它:一是想复现某个只在特定条件下才会触发的逻辑,手动构造条件太麻烦,干脆直接调;二是想批量测试某个方法的边界行为,写个循环一口气跑几十组参数。
要注意的是,调用方法可能会引发连锁反应。你调了一个"重新加载关卡"的方法,结果整个场景都重置了,前面存下来的对象引用可能就失效了。所以调用之前先想清楚它的副作用范围,别在一个已经很复杂的状态下贸然触发。
4.3 追踪调用栈与性能采样
有时候你不知道某个方法是"谁调用的",这时候调用栈信息就派上用场了。在onEnter里打印一下当前线程的调用栈,能顺着往回找到调用链。
onEnter() { console.log( "调用栈:", Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress) .join("\n") ); }这段能帮你看到原生的调用路径。为什么这点很有价值?因为在 IL2CPP 里,托管方法和原生方法之间的边界是模糊的,很多时候一个业务方法的触发源头藏在引擎内部的某个回调里,只看托管层的调用关系是看不全的。
做性能采样的时候,思路是记录方法被调用的次数和时间,找出热点。但要克制,别对所有方法都无条件 Hook。方法数量一多,插桩本身的性能损耗会盖过你观察的目标。我一般先锁定几个可疑的类,缩小范围之后再逐个分析。
5. 常见报错与排查技巧实录
5.1 脚本加载了,但什么都拿不到
这几乎是新手遇到的第一类问题,表现是脚本没报错,但Il2Cpp.domain.assemblies.length是零,或者去找类的时候抛异常。排查顺序我总结成下面这张表:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 程序集数量为 0 | 运行时还没初始化完 | 确认代码在 perform 回调内 |
| 找不到某个类 | 命名空间没带全 | 用 tryClass 试探完整名 |
| 找到类但没方法 | 方法名拼错或存在重载 | 先遍历 methods 看真实名字 |
| 附加就崩 | 版本不匹配 | 对齐 frida 与桥接版本 |
这里面最常见的是"命名空间没带全"和"方法名拼写"。我的习惯是永远不凭记忆写全名,而是先遍历出来再复制。因为 IL2CPP 里方法名区分大小写,还可能有编译器生成的变体,手敲几乎是自找麻烦。
还有一种情况是目标应用有运行时检测机制,注入行为会被它感知到并主动终止。这种就涉及下一节要说的内容了。
5.2 Windows 上那个奇怪参数报错
有个在网上经常被问到的报错,大意是命令行工具提示unrecognized arguments: --no-pause这类参数无法识别。我自己也碰到过,第一反应是以为命令打错了,后来才明白几件事。
第一,这个报错本质上是指令和你当前工具版本对不上。有些参数在旧版本里存在,在新版本里被移除或者改了名字,反过来也一样。你如果是从某篇教程里直接复制过来的命令,而教程写于较早的版本,那大概率就会撞上这个错。
第二,报错里的路径带scripts\,这通常说明你实际执行的是一个项目里定义的脚本或者一个本地封装,而不是你以为的那个全局命令。Windows 下的路径分隔符反斜杠会出现在这种情况下,值得留意。
我的处理办法很朴素:先用frida --help看清楚当前这版到底支持哪些参数,再决定怎么写命令。遇到不认识的参数就删掉,一步一步加回来,看看到底哪个参数触发了报错。删到能跑为止,比对着教程死扛效率高得多。
另一个思路是核对版本。命令行工具的版本、工程里依赖的版本、以及frida-compile的版本,这三者如果差得太多,就会出现各种奇怪的兼容问题。我的建议是把它们固定在一个已知能协同工作的组合上,不要频繁升级。
5.3 符号全是数字,看不懂怎么办
有时候调用栈打出来一堆地址,没有符号名。这不是你写错了,多半是目标应用被裁剪过,或者调试符号本来就没带。IL2CPP 发布版一般会剥离符号信息,只留下运行时的元数据。这种情况下,原生层的调用栈就只能看到地址。
应对办法是:把分析重心放在托管层。桥接库能还原出类名、方法名,这些信息在托管层是完整的,你完全可以先用托管层的 Hook 把逻辑梳理清楚,再决定要不要深挖原生层。别一上来就盯着没有符号的地址发愁,那是给自己找不痛快。
5.4 关于运行时检测,作为自测人员的正确态度
热词里经常出现"反调试"相关的词,这里我以自测和学习的角度说几句。很多应用会做一些运行时完整性检查,比如检测是否存在异常的注入、线程是否被人为挂起、关键函数的字节是否被改动。这些机制本身是应用保护自身的一种手段,理解它们有助于我们做更扎实的自测和更规范的开发。
但我要明确一点:这部分内容是用来理解原理、帮助自己排查为什么自测工具在某些应用上不生效的,不是用来对抗任何第三方应用的防护。我自己做这类工作时,目标永远是自己的应用、自己搭的测试环境。一旦目标不属于我,整件事就不在我的讨论范围里。这个边界不模糊,也不该模糊。
如果你发现自己开发的应用在加了某种检测之后,自己的调试工具就用不了了,那正好是个契机,去理解检测机制到底在检查什么。这比单纯想"绕过它"更有价值,因为你能反过来改进自己的检测策略,让应用更健壮。
6. 学习路径与几个藏在细节里的经验
6.1 一条我实测下来比较顺的学习路线
回头看,我觉得顺序很重要。比较顺的一条路是:先只确认环境能跑通,再学会浏览程序集和类,接着上手 Hook 一个简单方法,最后才去碰字段读写和方法调用。别跳步。我见过有人一上来就写复杂的字段修改逻辑,结果连类都找不到,白白消耗热情。
具体到练习目标,我建议自己写一个简单的 Unity 小项目当靶子,比如一个带计分和生命值的小游戏。在它上面练习遍历、Hook、读写,你能立刻对照界面上的变化,反馈非常直观。比自己找一堆陌生的目标去试要高效得多,也更安全可控。
每学一个 API,就回头问自己一句:"这一步如果不做会怎样?"比如去掉perform会怎样,去掉命名空间会怎样。带着这个问题去踩坑,记忆会深得多。
6.2 三个我踩过之后才记住的细节
第一个是编译产物要用对路径。我改完 TypeScript 忘了重新编译,加载的还是旧的agent.js,然后对着不变的结果怀疑人生。后来我养成习惯,开发时开着监听模式,减少这类低级错误。
第二个是日志要能收能放。排查阶段全开,确认之后立刻收敛,只留关键节点。不然应用性能崩了,你会误以为自己写的 Hook 有 bug,其实是日志量太大。
第三个是版本组合要稳定。这套工具链更新比较活跃,API 偶有调整。锁定一个能跑的版本组合,把工程环境固化下来,能省掉大量"昨天还好好的"这类莫名其妙的故障。
6.3 关于合法边界,我最后再说一遍
这套工具真正的价值,在于它让你能更好地理解和掌控自己负责的软件。无论是排查一个诡异的运行时 bug,还是验证某个逻辑在真机上的真实表现,它都能帮上大忙。我写这篇东西的出发点,也是希望同样在自学的人能少走点弯路。
但技术能力和使用范围是两回事。只在自己的应用、自己的测试环境里用它,是唯一能让我安心分享这类内容的前提。一旦越过这条线,再漂亮的技巧都失去了正当性。这不是一句场面话,而是我自己在做这类工作时始终守住的那条线。
如果你也是从新手一路摸过来的,大概能体会到那种"终于看到类列表刷出来"的成就感。那种感觉值得好好珍惜,也值得用在对的地方。