拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

frida-il2cpp-bridge:IL2CPP Hook 与动态调试入门

frida-il2cpp-bridge:IL2CPP Hook 与动态调试入门

1. 那行 "unrecognized arguments: --no-pause" 背后是什么

第一次接触 frida-il2cpp-bridge,是为了给我自己写的一个 Unity IL2CPP 测试工程做崩溃复现。需求很朴素:不重新打包、不把 IL2CPP 反编译回 C# 工程,直接在跑着的进程里,把某个方法的入参和返回值打到终端上。结果第一条命令就吃了个闭门羹,终端回给我的是scripts\frida: error: unrecognized arguments: --no-pause。

这个报错其实一点都不高深,它跟 frida-il2cpp-bridge 本身没什么关系,是 frida 命令行客户端(frida-tools)的参数解析在抱怨:你给的这个--no-pause,我这个版本不认。但新手最容易在这里被带偏——以为是脚本写错了、是 bridge 装错了、是设备没连上,然后开始一通乱改,越改越乱。所以这篇东西我打算按我自己的真实路径来写:先把命令行和版本这层理顺,再讲 IL2CPP 到底把代码变成了什么,然后搭 agent 工程、上手核心 API、跑一个能复现的小案例,最后把我踩过的坑按排查顺序列出来。适合完全没写过 Frida 脚本、但有一点 JavaScript 或 TypeScript 基础的人,也适合写过 Frida 但没用过 il2cpp 桥接层的人。

这里先把边界说清楚:本文所有操作都只针对你自己开发的、或者你明确获得授权的应用与测试工程。用于排查自己的构建问题、做崩溃定位和性能观察,是这套工具最正当的用法。

1.1 命令行参数为什么会对不上

frida 的命令行客户端是 Python 写的,安装在你自己电脑上;frida-server 是跑在目标设备上的原生程序。这两者版本必须匹配,而命令行参数是在客户端这一侧解析的。当你在网上抄了一条教程命令:

frida -U -f com.example.demo --no-pause -l _agent.js

而你本机的 frida-tools 是比较新的版本时,参数集会发生变化。老教程里常用的--no-pause在新版本里可能已经被移除或改名,于是 argparse 直接抛出unrecognized arguments,连设备都不会去连,进程也不会被启动。

这里有个很典型的误导点:报错前缀是scripts\frida:,看起来像是一个叫frida的本地脚本出的问题。实际上这只是 Python 的 argparse 在打印prog名字时用了调用路径。真正的信息量只在最后半句:这个参数不认识。

处理方式很直接,分三步:

  1. 先看本机版本:frida --version,同时pip show frida-tools看一下 frida-tools 的版本号。
  2. 再看当前支持哪些参数:frida --help,翻到 usage 那一段,找跟暂停/继续行为相关的开关。
  3. 如果新版本里确实没有对应开关了,直接把它删掉再跑。很多情况下这个参数本来就是可选的,删掉之后行为差异只是"是否在入口处暂停等你在 REPL 里手动 resume",对着脚本跑的场景影响不大。

我个人的习惯是:网上抄来的命令,先把所有--开头的长参数全部删掉,只保留-U、-f、-l这三个必需的,先跑通再说。跑通之后再逐个加回来。这样出问题时,变量只有一个,排查成本极低。

1.2 先做一次版本体检,再谈跑通

新手常犯的第二个错误,是拿着 A 版本的客户端去连 B 版本的 server。这种组合有时能连上,有时连上就断,有时表现成注入成功但所有 API 都拿不到数据。所以我在任何一台新机器上,第一件事都是做一次版本体检:

# 本机客户端版本 frida --version # 设备侧(以连接安卓设备为例)确认 server 是否在跑 frida-ps -U | head -20

frida-ps -U能列出进程,说明客户端能通过 USB 通道和设备的 server 正常通信,这一步是后续一切操作的前提。如果这一步就失败了,后面的东西完全不用看,先解决连接问题。

对齐的原则很简单:本机frida的版本号,和设备上 frida-server 的版本号,保持完全一致。小数点后面的也要一致。差一个小版本有时没事,但我见过差一个小版本导致Il2Cpp.perform回调里拿不到任何类的情况,排查起来非常耗时间。

还有一个容易被忽略的点:如果你用的是 TypeScript 工程,frida-il2cpp-bridge是通过 npm 装的,它内部依赖 Frida 的运行时 API。它的 README 里通常会标注兼容的 Frida 大版本区间。装之前扫一眼package.json里的版本约束,比装完之后对着报错猜要省事得多。

2. IL2CPP 把 C# 编译成了什么:不弄清这层只会瞎试

很多人跳过这一节,直接去抄 Hook 代码,然后就陷入"为什么别人能拿到类名,我拿不到"的循环。花十分钟把 IL2CPP 的输出结构搞清楚,后面所有 API 的用法都会变得顺理成章。

Unity 有两种脚本后端。一种是 Mono,C# 编译成中间语言,运行时靠 JIT 编译,程序集就是一堆.dll文件,用通用工具就能反编译出接近源码的东西。另一种就是 IL2CPP:C# 先被转成 C++,再交给平台的原生编译器编译成机器码,最终产物是一个.so(安卓)、一个可执行文件(桌面),或者 iOS 上的 Mach-O。你写的方法名、字段名、类名,在这一步全部消失,变成了内存里的地址和偏移。

这也解释了一个现象:用常规的反编译工具直接看 IL2CPP 的产物,你只能看到一堆没有语义的函数。想知道哪个地址对应哪个方法,必须靠另一份东西——元数据。

2.1 libil2cpp.so 与 global-metadata.dat 的分工

IL2CPP 的构建产物里,有两份东西是核心:

  • 原生代码库:安卓上是libil2cpp.so,里面是编译后的机器指令、方法实现的入口地址、以及 IL2CPP 运行时自己的那套托管内存管理、GC、类型系统。
  • 元数据文件:通常是global-metadata.dat,里面存着所有程序集、类型、方法、字段、字符串常量的名字和结构信息。它不包含代码逻辑,只包含"有哪些东西、它们长什么样、彼此是什么关系"。

关键在于:运行起来之后,IL2CPP 运行时会在初始化阶段把这份元数据加载进内存,在内存里构建出一套完整的类型系统。所以名字并没有真的"消失",只是被搬到了一个运行时才存在的结构里。frida-il2cpp-bridge 干的事情,就是连上这套数据结构,把类、方法、字段重新暴露成 JavaScript 能访问的对象。

顺手说一个实用的小技巧:元数据文件的开头 8 个字节是有规律的,前 4 字节是固定的魔数,紧接着 4 字节是版本号。想快速判断版本,用十六进制看一眼就行:

xxd -l 8 global-metadata.dat

把第二个 4 字节按小端序读出来,就是版本号。这个数字直接决定了桥接层能不能正常解析。大致对应关系如下(这是常见经验的归纳,具体以你的工程为准):

元数据版本大致对应的 Unity 版本
24Unity 2018 到 2019 早期
27Unity 2020 到 2021.1
29Unity 2021.2 到 2022
31Unity 2023 及以后

如果你的元数据版本比桥接层支持的上限还新,表现通常是Il2Cpp.perform里直接抛异常,或者能连上但所有assembly、class查询都返回空。遇到这种情况,别怀疑自己的脚本,先确认版本是不是越界了。做研究用的测试工程,最简单的方法是把 Unity 版本降到自己确认能支持的那一档重新构建一次,比去啃解析逻辑高效得多。

2.2 运行时为什么还能按名字找到类和方法

理解这一点,能帮你判断哪些操作是"便宜"的,哪些是"昂贵"的。

IL2CPP 在初始化时会构建一张类型表。每个类型在里面有一条记录,记录里挂着它的方法列表、字段列表、父类关系。桥接层拿到这份表的入口之后,就可以按名字做字符串匹配去找你想要的类。比如:

const image = Il2Cpp.domain.assembly("Assembly-CSharp").image; const klass = image.class("PlayerController");

这两行做的事情是:先在程序集列表里找到名为Assembly-CSharp的那一项,再从它的镜像里捞出名为PlayerController的类型记录。注意image.class()的参数是包含命名空间的完整名字,如果你的类写在命名空间里,就得写成MyGame.PlayerController。这是我见过新手最高频的错误,没有之一——类名对了但一直返回null,八成是漏了命名空间。

另一个要注意的是开销。字符串匹配是线性查找,偶尔查一次没问题,但如果你打算在每帧执行的回调里做这种查找,就会明显拖慢目标进程。正确做法是在初始化时查一次,把结果存到闭包外面的变量里复用。这个习惯从第一份脚本开始就养成,能省掉后面很多性能上的麻烦。

2.3 三端宿主环境的差异

虽然标题里没写平台,但实际差异不小,先说清楚省得你走弯路。

安卓是最常见的场景。设备侧需要跑 frida-server,需要 root 或者在一个允许调试的环境里操作。客户端用-U走 USB 通道。整个链路最成熟,文档最多。

iOS上通常是通过越狱环境里的包管理器安装对应版本的运行时,操作方式和安卓类似但生态不同,而且系统限制更多。个人建议新手从这个平台入手的话,先找一台专门的测试设备,不要用日常主力机。

桌面平台(Windows、macOS、Linux 上的独立构建)是最舒服的调试环境。因为进程就在本机,你可以直接附加,不需要额外的设备和服务端:

frida -n YourApp.exe -l _agent.js

在桌面环境下调试自己的工程,日志直接打在同一个终端里,断点、崩溃现场、堆栈都能看到,效率比在移动端高一个数量级。我的建议是:先在桌面构建上把脚本逻辑全部调通,再去移动端跑。这样能把"脚本写错了"和"环境没配好"这两类问题彻底分开。

3. 环境搭建的完整链路:Node、TypeScript 和 agent 工程

frida-il2cpp-bridge是发布在 npm 上的一个库,它不是那种"下载一个 js 文件丢进去就能用"的脚本,而是一个需要被打包进 agent 的模块。所以你需要一个最小的前端工程环境。别被"前端工程"这四个字吓到,实际要装的东西很少。

3.1 初始化一个能被 frida-compile 打包的工程

整个流程就是标准的 npm 初始化加装包,四步搞定:

mkdir il2cpp-lab && cd il2cpp-lab npm init -y npm i frida-il2cpp-bridge npm i -D frida-compile typescript

装完之后,在package.json里加两条脚本,方便重复使用:

{ "scripts": { "build": "frida-compile index.ts -o _agent.js", "watch": "frida-compile index.ts -o _agent.js -w" } }

frida-compile的作用是把 TypeScript 编译成 JavaScript,同时把frida-il2cpp-bridge这个依赖一起打包进一个自包含的文件。因为 Frida 注入进程的时候,只能塞进去一个脚本文件,没法帮你做模块解析,所以打包这一步不能省。

我个人强烈建议用watch模式。因为调脚本的过程就是"改一行、重跑一次"的高频循环,手动敲 build 命令会让人烦到放弃。开着 watch,保存文件就自动重新生成产物,你只需要重跑注入命令。

一个实操心得很值得分享:产物文件名用下划线开头,比如_agent.js。这样在 IDE 里它会自动排到文件列表最上面,跟源码文件在视觉上分开,不容易误编辑。同时记得把node_modules和产物文件加进忽略列表,避免污染版本管理。

3.2 写第一个 Il2Cpp.perform 并让它真的打印出东西

第一份脚本别贪心,就干一件事:确认能连上 IL2CPP,并列出某个类的所有方法。

import "frida-il2cpp-bridge"; Il2Cpp.perform(() => { console.log("[*] il2cpp runtime attached"); const image = Il2Cpp.domain.assembly("Assembly-CSharp").image; const klass = image.class("PlayerController"); if (klass === null) { console.log("[!] class not found"); return; } console.log(`[*] ${klass.name} has ${klass.methods.length} methods`); for (const method of klass.methods) { console.log(` ${method.name} / params=${method.parameterCount}`); } });

几个必须知道的点:

第一,Il2Cpp.perform是等待并初始化的入口。它会一直等到 il2cpp 模块加载完成才执行回调,所以你把查询逻辑放在里面是安全的,不需要自己写轮询。这也是为什么几乎所有示例代码都把它包在最外层。

第二,console.log的输出会出现在你运行frida命令的那个终端里。如果终端里什么都看不到,先确认脚本是不是真的被注入了(后面第 6 节会专门讲这个)。

第三,如果类里方法特别多,全打出来会刷屏。改成只打印你关心的那几个,或者用klass.method("Name")直接拿单个方法。

第四,TypeScript 的类型标注在这里基本不起作用,因为运行时的对象是桥接层动态构造的。类型标注的价值在于编辑器的补全提示,能帮你少查文档。所以别纠结类型写得对不对,能跑起来才是第一位的。

跑起来的完整命令:

npm run build frida -U -f com.example.demo -l _agent.js

如果你在桌面环境调试,把-U -f com.example.demo换成-n YourApp.exe就行。

3.3 设备侧的准备与版本对齐

设备侧的事情说白了就三件:把对应版本的运行时程序放进去、给它执行权限、启动它。

# 推送到设备 adb push frida-server-<版本>-android-arm64 /data/local/tmp/fs # 赋权 adb shell chmod 755 /data/local/tmp/fs # 启动(放到后台,日志单独存一份) adb shell su -c "/data/local/tmp/fs &"

这里的<版本>必须和你本机frida --version的输出完全一致。我是这么做的:每次装完客户端,立刻去确认版本号,然后按这个号去找对应的设备端程序,绝不凭印象下载。

还有一个很实用的排查技巧:先在设备上直接跑一次,看它有没有报错。如果启动就崩,终端里通常会有明确提示,比你在客户端那边猜要快得多。另外记住进程名和监听端口,如果同机上有多个版本的程序冲突,端口会被占用,表现就是客户端连不上但设备侧明明在跑。

选择注入方式时,-f(spawn,先启动再注入)和-n(attach,附加到已运行进程)差别很大。spawn 模式能从进程最早的阶段介入,适合那些初始化阶段就完成关键逻辑的工程;attach 模式适合你手动启动应用、等它跑起来之后再介入。新手先用 spawn,因为时序最干净,能避开很多"太晚了,类已经加载完了"的困惑。

4. 核心 API 上手:从列类到改行为

环境通了之后,真正的活儿是这几组 API。我把它们分成三类:查找、替换、观察。搞清这三类的边界,你就不会写出奇怪的脚本了。

4.1 定位程序集、类、方法:几种写法和适用场景

查找链条是固定的一层套一层:域 → 程序集 → 镜像 → 类型 → 方法/字段。

Il2Cpp.perform(() => { // 1. 拿程序集镜像 const image = Il2Cpp.domain.assembly("Assembly-CSharp").image; // 2. 拿类型(注意命名空间的完整写法) const klass = image.class("MyGame.PlayerController"); // 3. 拿字段 for (const field of klass.fields) { console.log(`${field.name} : ${field.type.name} static=${field.isStatic}`); } // 4. 拿方法 const method = klass.method("TakeDamage"); });

几点实战体会:

程序集名不一定是Assembly-CSharp。这是 Unity 默认给用户脚本生成的名字,但项目里可能有Assembly-CSharp-firstpass、各种.asmdef拆出来的程序集,或者第三方 SDK 自己的程序集。不知道用哪个的时候,先把所有程序集名打出来看一眼:

for (const assembly of Il2Cpp.domain.assemblies) { console.log(assembly.name); }

方法的参数类型很关键。klass.method("TakeDamage")这种写法在只有一个同名方法时有效,但如果工程里有重载,就会拿不准拿到的是哪一个。稳妥的写法是把参数个数或参数类型一起指定,具体签名以项目 README 为准,因为不同版本的 API 入口有细微差别。判断依据很简单:看你想 Hook 的方法有没有重载。有,就必须精确指定;没有,简写可以。

拿不到的常见原因只有三类:名字写错了(大小写、命名空间)、程序集选错了、方法被裁剪掉了。第三类值得单独提一句:Unity 的代码裁剪(managed stripping)会在构建时把"看起来没用到"的代码删掉,导致开发时明明存在的方法,打包后消失了。这在调试 release 构建时特别容易遇到,切到开发构建做对比就能确认。

4.2 implementation 与 intercept:替换和观察别搞混

这两个是最容易用错的。

implementation是替换语义。你给它赋一个 JS 函数,它就成了那个方法的新实现,原来的 C# 逻辑不会被执行。适合"我就是要改掉这个行为"的场景:

Il2Cpp.perform(() => { const klass = Il2Cpp.domain.assembly("Assembly-CSharp").image.class("MyGame.PlayerController"); const method = klass.method("TakeDamage"); method.implementation = function (amount: number) { console.log(`[hook] TakeDamage 被调用,原始参数 = ${amount}`); // 不调用原逻辑,直接把这次伤害吞掉 return; }; });

intercept是观察语义。它在方法入口和出口挂回调,默认不改变原有执行流程,适合"我只想看看参数和返回值":

method.intercept({ onEnter(this: Il2Cpp.Object, ...args: any[]) { console.log("[enter]", args); }, onLeave(this: Il2Cpp.Object, retval: any) { console.log("[leave]", retval); } });

新手最常见的翻车点,是在implementation里想"先打日志再调原方法",然后写成递归调用,直接把进程干栈溢出。记住:implementation里你没有"原方法"这个概念,你写的就是全部逻辑。如果你需要既观察又保留原行为,那从一开始就该用intercept。

顺带说一个我踩过的坑:implementation会改变方法的行为,如果这个方法被频繁调用(比如每帧的更新循环),你的console.log会瞬间刷爆终端,同时严重拖慢进程,甚至让人误以为是程序卡死了。给高频方法打日志,一定要加计数器限流,比如只打前 50 次:

let count = 0; method.implementation = function () { if (count++ < 50) { console.log("[hook] called"); } };

这个习惯看着不起眼,但它救过我很多次——好几次我以为程序崩了,其实只是被日志刷爆了。

4.3 gc.choose、dump 和 trace:不改逻辑也能拿到大量信息

这三个功能是桥接层真正的价值所在,因为它们能让你在不改任何行为的前提下,把工程结构摸清楚。

Il2Cpp.dump()会遍历运行时里的全部类型信息,生成一份类似 C# 声明结构的文本。对于完全没有源码的构建产物来说,这是最直接的"目录"。用法上把它放在Il2Cpp.perform里调一次,产物会通过send()机制回到客户端侧,注意数据量可能很大,要留出足够的输出空间。

Il2Cpp.gc.choose(klass)是从托管堆上"捞出当前活着的实例"。这个能力很实用:你不需要知道对象是怎么被创建的,只要它活着,就能拿到它,然后读它的字段:

const instances = Il2Cpp.gc.choose(klass); console.log(`live instances: ${instances.length}`); for (const obj of instances) { console.log(obj.field("hp").value); }

这里的心得是:实例数量会随时间变化,抓取时机不同结果差别很大。比如你在一局游戏的进行中抓,可能拿到几十个;在加载阶段抓,可能一个都没有。所以想稳定复现,配合一个定时循环、观察数量变化,比单次抓取可靠得多。

Il2Cpp.trace()用来对一批方法做执行轨迹记录,可以按类或按方法筛选。它的价值在于"我不知道方法名,但我知道它一定被调用了",这时候开一个范围合适的 trace,看谁在动,比逐个试方法名高效得多。不过要提醒一句:trace 的范围千万别开太大,全量 trace 基本等于给进程套上枷锁,帧率会掉到你怀疑人生。先从一个类开始,确认有效果再逐步扩大。

字段读取这块有个小细节:静态字段和实例字段的取值方式不同,前者挂在类上,后者挂在对象上。写脚本时先看field.isStatic,能省掉很多"读出来是 0"的困惑。

5. 一个可复现的实战:给自己的测试工程做方法追踪

光看 API 说明记不住,我按自己练手的方式,把完整链路走一遍。这个场景是我自己写的测试工程:一个角色对象,有个受伤方法,扣血之后如果在 UI 上更新血条。

5.1 造一个最小 IL2CPP 测试场景

我建议你也在自己的工程里造一个这样的最小场景,代码越简单越好:

using UnityEngine; namespace MyGame { public class PlayerController : MonoBehaviour { public int hp = 100; private int shield = 20; public void TakeDamage(int amount) { int real = Mathf.Max(0, amount - shield); hp -= real; if (hp < 0) hp = 0; Debug.Log($"hp = {hp}"); } } }

注意我特意加了几个"麻烦点":字段有 public 有 private,方法有参数计算,类有命名空间。这些在真实工程里到处都是,提前在小场景里撞一遍,比在大工程里撞要省时间得多。

构建时记得用开发构建,并且关掉代码裁剪(把 managed stripping level 设为 Disabled),这样你能确保所有东西都在。等你确认脚本没问题了,再切回发布构建去验证裁剪带来的差异。

5.2 从参数打印到返回值改写的完整链路

第一步,确认能找类,并把方法列表打全(这一步别省,尤其是有命名空间的情况):

import "frida-il2cpp-bridge"; Il2Cpp.perform(() => { const image = Il2Cpp.domain.assembly("Assembly-CSharp").image; const klass = image.class("MyGame.PlayerController"); console.log(`[*] ${klass.name}`); for (const m of klass.methods) { console.log(` -> ${m.name} (${m.parameterCount})`); } });

第二步,观察参数:

const takeDamage = klass.method("TakeDamage"); takeDamage.implementation = function (amount: number) { console.log(`[hook] TakeDamage(${amount})`); // 这里不调用原实现,而是自己把 hp 扣掉,方便验证 hook 是否生效 const hpField = this.field("hp"); hpField.value = hpField.value - 1; };

跑起来之后,你会看到每次受伤都打印一行,同时血量的减少量变成了固定 1,而不是按原公式算。这就说明替换成功。注意this在回调里指的是被调用对象本身的桥接对象,所以能直接this.field("hp")拿到实例字段,这是这个桥接层比较舒服的地方。

第三步,把逻辑切回"保留原行为、只观察",验证另一种写法:

takeDamage.intercept({ onEnter(this: Il2Cpp.Object, amount: number) { console.log(`[enter] amount=${amount}, hp=${this.field("hp").value}`); }, onLeave(this: Il2Cpp.Object) { console.log(`[leave] hp=${this.field("hp").value}`); } });

对比这两段代码,你会立刻明白implementation和intercept的区别:前者让原来的扣血公式失效了,后者没有。这个对比做完,这一节的收获就足够了。

5.3 重载、泛型、静态成员这三个高频难点

重载。如果同一个名字有两个方法,比如TakeDamage(int)和TakeDamage(int, bool),简写会拿不准。解决思路是先遍历klass.methods,按parameterCount筛出你要的那个,或者按参数类型名筛选再取。这个筛选逻辑写一次,后面所有脚本都能复用,值得单独封装成一个小函数。

泛型。泛型类型在运行时的名字会带上尖括号和类型参数,字符串写法跟源码里长得不一样。遇到泛型相关的类或方法,最省事的做法是先 dump 一遍类型列表,看看运行时里它到底叫什么名字,直接复制那个名字来用,比猜要快得多。

静态成员。静态字段和静态方法的访问入口在类上,不在实例上。写的时候别下意识地加this。另外要注意,静态成员经常被初始化代码延迟赋值,你在进程刚启动时读,可能拿到默认值而不是实际值。这个问题很容易误导人,以为是 Hook 没生效,其实是时机问题。

6. 新手最常踩的坑与我的排查顺序

最后这节是我自己整理的一张排查清单。顺序很重要:从最外层往最内层查,不要一上来就怀疑自己的脚本逻辑。

6.1 agent 注入成功但没有任何输出

这是最常见的一类。终端显示已经连接到进程,但console.log一行都没有。按这个顺序查:

  1. 看有没有构建产物。是不是忘了跑npm run build,或者改了源码但没重新生成_agent.js?这是我个人踩得最多的一条,尤其是用 watch 模式时偶尔会漏掉一次失败的构建。每次注入前扫一眼产物文件的修改时间,几秒钟的事。
  2. 看 import 有没有写。import "frida-il2cpp-bridge";这行必须有。漏了的话,脚本能跑,但Il2Cpp这个全局对象不存在,报错信息往往在很后面才出现。
  3. 看是不是卡在等待。Il2Cpp.perform会等模块加载完成。如果目标构建不是 IL2CPP(比如有人误用了 Mono 后端),它会永远等下去,表现就是"什么都没发生"。确认构建类型是最基本的检查。
  4. 看是不是被时序问题挡住了。用-n(attach)时很常见:你附加的时候,你关心的类还没被加载。换成-f(spawn)从启动阶段介入,问题通常就消失了。

6.2 进程启动即退出时先怀疑什么

如果注入之后目标进程直接闪退,先别急着往"对抗"的方向想。绝大多数情况下原因很朴素:

  • 版本不匹配。客户端和设备端运行时版本不一致,注入阶段就可能出问题。
  • 元数据版本超出支持范围。前面提过,表现就是初始化阶段异常。
  • 注入得太早或太晚。某些工程在初始化阶段对时序比较敏感。
  • 脚本本身有语法或运行时错误。这是最容易被忽略的一条——脚本抛异常也可能连带影响进程状态。

排查顺序上,我习惯先跑一个最简脚本(只有 import 和一行打印),确认最基础的注入没问题,再逐步加内容。这个方法看着笨,但它能把问题范围一刀切干净,比对着几千行脚本猜快得多。

需要说明的是,有些应用确实会做运行时环境检查,检测到异常状态会主动结束进程。研究这种情况下的表现,前提依然是你操作的是自己负责的、有授权的目标。这是我给自己划的线,也建议你划一条。

6.3 报错对照表与版本矩阵

把常见现象整理成一张表,方便对着查:

现象或报错最可能的原因处理方向
unrecognized arguments: --no-pause命令行参数与当前客户端版本不匹配看frida --help,删掉或替换该参数
客户端连不上设备设备侧运行时未启动或版本不一致先frida-ps -U验证连通性
能连上但查不到类程序集名或命名空间写错、类被裁剪先打印程序集列表和类型列表
Il2Cpp.perform永远不回调目标不是 IL2CPP 构建,或元数据版本不受支持确认构建类型和元数据版本
脚本无任何输出忘记构建产物、漏 import检查产物时间戳和文件开头
注入后进程退出时序问题、版本问题、脚本异常先用最简脚本二分定位
帧率骤降高频方法的日志未限流、trace 范围过大加计数器、缩小 trace 范围

最后分享一个我自己养成的习惯:每一份脚本都从"只打印、不修改"开始。先把类的结构、方法的调用频率、字段的取值都摸清楚,确认信息无误之后,再动手改行为。这么做有两个好处,一是排查问题时变量最少,二是你对自己在改什么有完全的把握。我早期的脚本基本都是"一上来就替换",结果就是程序行为莫名其妙地变了,而我根本不知道是哪一行导致的,只能全部推倒重来。现在这个顺序,虽然前面多花十几分钟,但整体效率反而高得多。

另外一个小技巧:给每个脚本的日志加一个统一前缀,比如[lab]。当终端里混着运行时自己的输出、系统日志、以及你的打印时,一眼就能区分出来,省掉大量翻屏找日志的时间。这种小细节,用久了就知道有多值。

返回列表