news 2026/10/1 4:38:15

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
IL2CPP 动态插桩:frida-il2cpp-bridge 入门实战

记一下我这两周折腾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 typescript

frida-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,到能稳定地在自己的测试工程里定位方法、读字段、看调用链,我大概花了一个星期,其中前三天全在环境上。如果你现在正卡在某个报错上,我的建议是把范围缩小到一个最小脚本,一次只验证一件事——环境、连接、查找、拦截,分开验证,成功率比一口气写一大段脚本高得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 4:37:45

Rust集合类型详解:Vec与HashMap的底层原理与实战技巧

说实话&#xff0c;写 Rust 写了这么些年&#xff0c;日常打交道最多的两个类型就是 Vec 和 HashMap。一个负责把数据排好队&#xff0c;一个负责把数据挂好牌&#xff0c;几乎任何项目里都离不开它们。但它俩的细节其实比表面看起来要多得多&#xff0c;尤其当你从 C、Python …

作者头像 李华
网站建设 2026/10/1 4:36:24

SAP UI5与React/Vue:企业级前端框架的十大核心差异

很多从 React、Vue 走进企业级开发的朋友&#xff0c;第一次打开 SAP UI5 的 XML 视图时&#xff0c;都会产生一种强烈的错位感&#xff1a;这到底是前端代码&#xff0c;还是某种配置文件&#xff1f;这种错位感不是错觉——SAP UI5 和现代前端生态&#xff0c;从根上就是两套…

作者头像 李华
网站建设 2026/10/1 4:35:37

Python 3安装与Python 2卸载全攻略:各平台操作与避坑指南

1. 为什么Python 2必须退场&#xff0c;以及卸载前必须想清楚的三件事先聊点实际的。2020年1月1日Python 2官方停止维护那天&#xff0c;我手头还有三台服务器跑着Python 2.7写的内部工具&#xff0c;有数据清洗脚本&#xff0c;有自动化报表&#xff0c;还有一个定时抓取业务系…

作者头像 李华
网站建设 2026/10/1 4:35:13

AnythingLLM 实战指南:打造私有化AI知识库与Agent工作区

1. AnythingLLM 到底解决了什么问题我是在一次给客户做内部知识库选型时第一次接触到 AnythingLLM 的。当时的需求很简单&#xff1a;公司想用上 ChatGPT 级别的大模型能力&#xff0c;但文档和数据一条都不能离开服务器。后来我发现&#xff0c;与其说它是一个“私有 ChatGPT”…

作者头像 李华
网站建设 2026/10/1 4:35:01

界面测试全攻略:从六大维度到移动端物联网实战经验

软件测试的圈子里有个很常见的现象&#xff1a;一提界面测试&#xff0c;大家都觉得简单&#xff0c;不就是"点一点、看看好不好看"嘛。可真到项目里一比划&#xff0c;开发说"这个显示有问题"&#xff0c;产品说"交互不对"&#xff0c;测试自己…

作者头像 李华