ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity IL2CPP 分析:frida-il2cpp-bridge 实战

Unity IL2CPP 分析:frida-il2cpp-bridge 实战 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 的脚手架和编译都依赖它版本太老会编译失败建议 LTSnpm拉取依赖安装 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还是验证某个逻辑在真机上的真实表现它都能帮上大忙。我写这篇东西的出发点也是希望同样在自学的人能少走点弯路。但技术能力和使用范围是两回事。只在自己的应用、自己的测试环境里用它是唯一能让我安心分享这类内容的前提。一旦越过这条线再漂亮的技巧都失去了正当性。这不是一句场面话而是我自己在做这类工作时始终守住的那条线。如果你也是从新手一路摸过来的大概能体会到那种终于看到类列表刷出来的成就感。那种感觉值得好好珍惜也值得用在对的地方。
返回列表