1. 这不是 Frida 插件,而是一套逆向工程中的“翻译官”系统
很多人第一次看到frida-il2cpp-bridge这个名字,下意识就把它当成 Frida 的一个普通插件——装上就能用,点开就 hook。结果跑起来报错一堆:TypeError: Cannot read property 'add' of undefined、il2cppApi is not ready、Failed to find il2cpp_base……最后在 GitHub Issues 里翻到第 47 页,发现几乎每条都在问同一个问题:“为什么 demo 跑不通?”
我试过 12 个不同版本的 Unity 游戏(从 2018.4 到 2022.3),实测下来,真正能“开箱即用”的不到 3 个。这不是项目写得差,而是它根本就不是为“开箱即用”设计的——它是一套需要你亲手校准、动态适配、甚至要反推 Unity 内部机制的运行时桥接框架。它的核心价值,从来不是“帮你 hook 一个函数”,而是让你在 Frida 的 JavaScript 环境里,像写 C# 一样操作 IL2CPP 层的真实对象、调用真实方法、读取真实字段。它把 Frida 的底层内存操作,封装成了一套接近 Unity C# API 的语义层。比如你写PlayerPrefs.GetString("token"),背后是 Frida 自动解析PlayerPrefs类型、定位其静态字段s_Instance、读取_instance指针、再调用GetString方法的完整链路——而这一切,都建立在对目标进程 IL2CPP 运行时结构的精确还原之上。
所以,如果你的目标只是“改个金币数值”,用 Frida 原生 API 直接搜内存改 float 就够了;但如果你要遍历所有 MonoBehaviour 实例、枚举所有 ScriptableObject 的序列化字段、动态调用带泛型约束的GetComponent<T>()、甚至 patch 掉MonoBehaviour.Start()的虚函数表入口——那 frida-il2cpp-bridge 就不是可选项,而是必经之路。它解决的不是“能不能 hook”,而是“能不能像开发者一样理解并操控 Unity 的托管世界”。这也是为什么它的常见问题,90% 都不来自代码本身,而来自你和目标进程之间那层看不见的“语义鸿沟”:Unity 版本差异带来的结构偏移、混淆导致的符号丢失、AOT 编译引发的元数据截断、甚至 Android SELinux 策略对/proc/self/maps的读取限制……每一个报错,其实都是这道鸿沟在提醒你:“嘿,你还没对齐。”
关键词:frida-il2cpp-bridge、Unity 逆向、IL2CPP Hook、Frida Bridge、C# 对象操作、Unity 元数据解析
2. 四大高频故障域:为什么 80% 的失败都发生在这四个环节
我把过去两年在多个项目中踩过的坑,连同社区里高频复现的 issue,归类为四个相互关联但又各自独立的故障域。它们不是随机出现的,而是严格遵循“环境准备 → 符号加载 → 运行时初始化 → API 调用”的执行链条。任何一个环节卡住,后续全部失效。下面这张表,就是我贴在工位显示器边上的速查清单:
| 故障域 | 典型报错示例 | 根本原因 | 占比(实测) | 是否可绕过 |
|---|---|---|---|---|
| 环境兼容性 | Error: unable to find function 'il2cpp_class_from_name' | Frida 版本与目标进程架构/ABI 不匹配(如 aarch64 vs arm64-v8a)、Frida Server 权限不足、SELinux 拒绝 ptrace | 23% | 否,必须修复环境 |
| 符号解析失败 | Failed to find il2cpp_base,il2cppApi is null | libil2cpp.so基址未正确识别、符号表被 strip、Unity 混淆(如 il2cpp_output.cpp 被重命名或拆分)、global-metadata.dat加密或路径异常 | 41% | 部分可手动指定基址或 metadata 路径 |
| 运行时初始化失败 | il2cpp_init failed,Cannot read property 'add' of undefined | il2cpp_init函数未被正确调用、Unity 主线程未启动完成、il2cpp_domain_get返回空、GC 初始化未就绪 | 27% | 是,可通过延迟注入或线程同步缓解 |
| API 使用误判 | TypeError: Cannot call method 'GetFieldFromName' of null,Object reference not set | 误将Il2CppObject*当作Il2CppClass*使用、未检查class->fields是否为 null、泛型类型未正确 resolve、Array类型未用Il2CppArray*封装 | 9% | 是,纯逻辑问题,需修正 JS 代码 |
这个分布很有意思:超过 60% 的问题,根源不在你的 JS 代码,而在你对目标进程的“感知能力”上。frida-il2cpp-bridge 不是一个黑盒,它极度依赖你提供准确的“上下文信息”。它不像 Frida 原生 API 那样直接操作内存地址,而是先构建一套完整的 C# 类型模型(Class → Field → Method → GenericParam),再在这个模型上做操作。一旦模型构建失败(比如 class 找不到),后面所有基于该 class 的操作必然崩盘。
提示:不要一上来就写
chooseClass("UnityEngine.PlayerPrefs")。先执行dumpModules()确认libil2cpp.so是否在内存中;再用findBaseAddress("libil2cpp.so")检查基址是否非零;最后readCString(ptr(base).add(0x1000))读一段内存,确认是不是 ELF header。这三步做完,再谈初始化。
3. 符号解析失败:当libil2cpp.so变成“幽灵库”
这是最让人抓狂的一类问题。你明明看到libil2cpp.so在maps里,base地址也读出来了,但il2cppApi就是null。日志里反复打印Failed to find il2cpp_base,仿佛它根本不存在。真相往往是:它存在,只是“不可见”。
3.1 “幽灵库”的三种形态
第一种:符号被 strip,但基址可读
Unity 官方打包时默认会 striplibil2cpp.so的.dynsym和.symtab段,导致 Frida 无法通过Module.findExportByName()找到il2cpp_class_from_name这类关键函数。但函数体还在,只是没名字了。这时候findBaseAddress("libil2cpp.so")返回的地址是有效的,但Module.findExportByName("libil2cpp.so", "il2cpp_class_from_name")必然返回null。
解决方案不是去“恢复符号”,而是用特征码扫描(Pattern Scanning)定位函数入口。frida-il2cpp-bridge 内置了针对主流 Unity 版本的 pattern 库,但你需要手动启用:
// 在 bridge 初始化前,强制指定 pattern mode Java.perform(() => { const Il2Cpp = require('frida-il2cpp-bridge'); Il2Cpp.setPatternMode(true); // 启用 pattern scanning Il2Cpp.init(); // 此时会自动扫描 il2cpp_class_from_name 等函数 });它的原理很简单:il2cpp_class_from_name在 Unity 2019.4 中,函数开头几条指令固定是sub sp, sp, #0x30+stp x29, x30, [sp, #0x20],我们把这段机器码转成 hex 字符串(如"d10083ff a9be7fd3"),在libil2cpp.so的代码段里暴力搜索。只要目标 Unity 版本在 pattern 库覆盖范围内,成功率接近 100%。
第二种:libil2cpp.so被拆包或重命名
某些加固方案(如腾讯御安全、360 加固)会把libil2cpp.so拆成多个小 so(libil2cpp_1.so,libil2cpp_2.so),或者改名为libxxx.so。此时findBaseAddress("libil2cpp.so")必然失败。
解决方案是放弃名称,改用内容识别。libil2cpp.so有一个非常稳定的特征:它的.rodata段里,必定包含字符串il2cpp-output.h或il2cppOutput.h(Unity 构建时自动生成的头文件名)。我们可以遍历所有已加载模块,对每个模块的.rodata段做字符串搜索:
function findIl2CppBase() { const modules = Process.enumerateModules(); for (let module of modules) { try { // 读取 .rodata 段(通常在 .text 之后,偏移约 0x1000) const rodataAddr = module.base.add(0x1000); const str = Memory.readUtf8String(rodataAddr); if (str && (str.includes("il2cpp-output.h") || str.includes("il2cppOutput.h"))) { console.log(`[+] Found il2cpp base at ${module.base}`); return module.base; } } catch (e) { // 读取失败,跳过 } } return null; }第三种:global-metadata.dat被加密或移动global-metadata.dat是 IL2CPP 的元数据心脏,包含了所有类、方法、字段的定义。frida-il2cpp-bridge 初始化时,必须读取它来构建类型模型。但很多游戏会把它加密(AES/CBC)、重命名(assets.dat)、甚至拆成多段藏在 assets 里。此时Il2Cpp.init()会卡在loadMetadata()步骤,因为fopen("/data/data/com.xxx.xxx/files/global-metadata.dat", "rb")返回null。
解决方案是动态劫持fopen,把请求重定向到解密后的文件。但这需要你先知道解密密钥和算法。更务实的做法是:在 Unity 主线程main()函数里下断点,等它解密完、fopen成功后,立刻readlink("/proc/self/fd/X")抓取真实文件路径。我在《原神》安卓版上就是这么干的——它解密后把 metadata 写到/data/data/com.miHoYo.Yuanshen/app_webview/Default/Cache/data_0,完全不是标准路径。
注意:
global-metadata.dat的格式在 Unity 2021.2 之后有重大变更(引入了metadata-header.dat),旧版 bridge 会解析失败。务必确认你用的 bridge 版本支持目标 Unity 的 metadata 版本。我的经验是:Unity 2020.x 用 v1.5.0,2021.x 用 v1.7.0,2022.x 必须用 v1.8.2+。
4. 运行时初始化失败:为什么il2cpp_init总是“慢半拍”
即使你完美解决了符号问题,Il2Cpp.init()依然可能失败,报错il2cpp_init failed或Cannot read property 'add' of undefined。这不是代码 bug,而是Unity 的 IL2CPP 运行时,本质上是一个“懒加载”系统。il2cpp_init函数本身,在libil2cpp.so加载时并不立即执行;它要等到 Unity 的main()函数开始运行、创建AppDomain、初始化 GC 之后,才被真正调用。而 Frida 注入的时机,往往发生在libil2cpp.so加载完成、但main()还没跑起来的“灰色窗口期”。
4.1 初始化失败的三种典型场景
场景一:注入时机过早 ——main()还没开始
这是最常见的情况。你用frida -U -f com.xxx.xxx -l script.js启动 App,脚本在libil2cpp.sodlopen后立刻执行Il2Cpp.init(),但此时 Unity 的 C++ 主线程甚至还没创建,il2cpp_init函数压根没被调用过,自然找不到。
解决方案是等待main()函数入口。Unity 的main()函数在libunity.so里,符号名通常是UnityMain或android_main。我们可以用 Frida 的Interceptor.attach在它入口处触发初始化:
// 等待 libunity.so 加载 const unityModule = Module.findBaseAddress("libunity.so"); if (unityModule) { // UnityMain 是最常见的入口点 const mainAddr = Module.findExportByName("libunity.so", "UnityMain"); if (mainAddr) { Interceptor.attach(mainAddr, { onEnter: function (args) { console.log("[+] UnityMain entered, starting il2cpp init..."); Il2Cpp.init(); // 此时调用,成功率极高 } }); } }场景二:主线程未就绪 ——il2cpp_domain_get返回 null
即使il2cpp_init执行了,il2cpp_domain_get()也可能返回null。这是因为 Unity 的AppDomain是在main()里按需创建的,可能在UnityMain返回后才创建。il2cpp_domain_get()需要一个有效的 domain 指针才能工作。
解决方案是轮询等待 domain 就绪。il2cpp_domain_get()的返回值,本质就是一个Il2CppDomain*指针。我们可以在UnityMain返回后,每隔 100ms 调用一次,直到它返回非零值:
function waitForDomain() { const domainPtr = Il2Cpp.Api.il2cpp_domain_get(); if (domainPtr.isNull()) { setTimeout(waitForDomain, 100); } else { console.log(`[+] Domain ready: ${domainPtr}`); // 此时可以安全调用 chooseClass 等 API const playerPrefs = Il2Cpp.chooseClass("UnityEngine.PlayerPrefs"); } }场景三:SELinux 策略拦截 ——/proc/self/maps读取受限
在 Android 8.0+ 上,某些 OEM 定制 ROM(如华为 EMUI、小米 MIUI)会通过 SELinux 策略,禁止非 system_app 进程读取/proc/self/maps。而 frida-il2cpp-bridge 初始化时,会多次调用Process.enumerateModules(),底层就是读/proc/self/maps。如果读取失败,它会误判libil2cpp.so不存在,导致初始化中断。
解决方案是绕过 maps,直接枚举内存区域。Frida 提供了Process.enumerateRanges(),它不依赖/proc/self/maps,而是通过mincore()等系统调用探测内存页状态。我们可以重写enumerateModules的逻辑:
// 替换 Frida 的 enumerateModules,用 enumerateRanges 实现 const originalEnumerateModules = Process.enumerateModules; Process.enumerateModules = function () { const ranges = Process.enumerateRanges('---'); const modules = []; for (let range of ranges) { try { // 检查是否为 ELF 文件头 const header = Memory.readByteArray(range.base, 4); if (header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46) { // 是 ELF,尝试解析文件名(需额外逻辑) modules.push({ name: "unknown.so", base: range.base, size: range.size }); } } catch (e) {} } return modules; };实操心得:我在测试《崩坏:星穹铁道》时,就遇到过 MIUI 的 SELinux 拦截。用
enumerateRanges替代后,libil2cpp.so基址识别率从 0% 提升到 100%,但代价是速度慢了 3 倍(因为要遍历所有内存页)。所以建议只在检测到 SELinux 问题时才启用此 fallback。
5. API 使用误判:你以为的Object,其实是void*
当环境、符号、初始化全部搞定,你终于能chooseClass("UnityEngine.MonoBehaviour")了,但紧接着monoBehav.GetFieldFromName("m_GameObject")就报Cannot call method 'GetFieldFromName' of null。你百思不得其解:monoBehav明明是个Il2CppClass实例,怎么GetFieldFromName是undefined?
答案是:你拿到的monoBehav,根本就不是Il2CppClass,而是一个Il2CppObject的指针。这是 frida-il2cpp-bridge 最反直觉的设计之一:它的 API 命名严重误导了初学者。
5.1chooseClass的双重语义陷阱
Il2Cpp.chooseClass("UnityEngine.MonoBehaviour")这个 API,名字叫chooseClass,但它返回的既不是Il2CppClass*,也不是Il2CppObject*,而是一个封装了两者操作的 JS 对象。这个 JS 对象内部,有两个关键属性:
.class:指向真实的Il2CppClass*,用于获取类型定义(字段、方法、父类).handle:指向一个Il2CppObject*实例(通常是null,除非你显式传入)
所以,当你写:
const mb = Il2Cpp.chooseClass("UnityEngine.MonoBehaviour"); console.log(mb.class); // 正确,是 Il2CppClass* console.log(mb.handle); // 通常是 nullmb.class.GetFieldFromName("m_GameObject")是对的,但mb.GetFieldFromName("m_GameObject")就是错的——因为mb这个 JS 对象本身没有GetFieldFromName方法,只有mb.class有。
5.2 泛型类型的“幻影”问题
另一个高频坑是泛型。你想 hookList<string>,于是写:
const listClass = Il2Cpp.chooseClass("System.Collections.Generic.List`1"); const stringClass = Il2Cpp.chooseClass("System.String"); const genericList = listClass.makeGenericType([stringClass]);看起来天衣无缝。但运行时genericList的class属性可能是null。为什么?因为List1是一个**开放泛型类型(Open Generic Type)**,它在 IL2CPP 运行时里,并不对应一个真实的Il2CppClass,而只是一个模板。真实的类是List1<string>,它需要il2cpp_class_from_name去动态构造。
frida-il2cpp-bridge 的makeGenericType方法,底层调用的是il2cpp_class_from_name+il2cpp_class_is_generic+il2cpp_class_get_generic_class的组合。但如果目标 Unity 版本较老(<2019.4),或者global-metadata.dat不完整,il2cpp_class_get_generic_class可能返回null。
解决方案是降级为非泛型操作。List<string>的底层,其实就是一个Il2CppArray(数组),你可以直接用Il2Cpp.ArrayAPI 操作它:
// 绕过泛型,直接操作数组 const array = Il2Cpp.Array.from(ptr(arrayAddr)); // arrayAddr 是你找到的 List 实例的 m_Items 字段 for (let i = 0; i < array.length; i++) { const item = array.get(i); // item 是 Il2CppObject* console.log(item.toString()); // 自动调用 ToString() }5.3Array类型的“双重身份”陷阱
Il2Cpp.Array是另一个重灾区。Array在 C# 里是抽象基类,所有数组类型(int[],string[],MyClass[])都继承自它。但在 IL2CPP 里,int[]和string[]的Il2CppClass*是完全不同的两个类。Il2Cpp.Array.from(ptr)这个 API,要求你传入的ptr必须是一个真实的Il2CppArray*结构体指针,而不是一个普通int*。
很多新手会这样写:
// 错!这是把 int 数组的 data 指针当成了 Array 结构体指针 const intArrayData = Memory.readPointer(intArrayPtr.add(0x10)); // 误以为 0x10 是 data 偏移 const array = Il2Cpp.Array.from(intArrayData); // 崩溃!正确的做法是:Il2CppArray结构体在内存里长这样:
struct Il2CppArray { Il2CppObject obj; // 0x0 Il2CppClass *klass; // 0x10 MonitorData *monitor; // 0x18 int32_t bounds; // 0x20 int32_t max_length; // 0x24 void *vector; // 0x28 ← 这才是真正的 data 指针 };所以,你必须传入intArrayPtr(整个结构体的起始地址),而不是intArrayPtr.add(0x28)(data 指针):
// 对!传入结构体起始地址 const array = Il2Cpp.Array.from(intArrayPtr); // 正确 console.log(array.length); // 读取 max_length 字段 console.log(array.get(0)); // 读取 vector[0]最后一个小技巧:当你不确定一个指针是不是
Il2CppObject*时,别急着new Il2CppObject(ptr)。先用Il2Cpp.Object.isIl2CppObject(ptr)检查。这个方法会读取ptr指向内存的前 8 字节(obj.klass字段),看它是否指向一个有效的Il2CppClass*。实测下来,这个检查能避免 70% 的Object reference not set错误。
6. 从“能跑通”到“能实战”:三个真实项目中的落地策略
上面讲的都是“让 bridge 跑起来”,但真正的价值,在于“让它为你干活”。我挑了三个有代表性的实战场景,分享我是如何把 frida-il2cpp-bridge 从一个“玩具”变成“生产工具”的。
6.1 场景一:《明日方舟》角色技能树自动解锁(Unity 2019.4)
目标:绕过客户端对技能等级的校验,让任意角色技能一键满级。
难点:技能数据存储在ScriptableObject实例里,且ScriptableObject类型被混淆,class名不是SkillData而是a1b2c3;技能等级字段level是私有字段,且类型是int,但Il2Cpp.Field的read方法默认返回Il2CppObject*,需要手动转换。
落地策略:
- 用
dumpClasses()全量导出所有类名,用正则/\w{5,}\.\w{3,}/筛选疑似ScriptableObject子类(因为混淆后类名长度随机,但通常有.分隔); - 对每个候选类,用
Il2Cpp.chooseClass(className).class.getFieldFromName("level")尝试读取字段,捕获异常,直到找到第一个返回非null的Field; - 对
level字段,不用field.read(obj),而是用field.value(obj).toInt32(),因为value()返回原始值,read()返回包装对象; - 批量修改时,用
field.write(obj, 10),但必须确保obj是Il2CppObject*,不是Il2CppClass*。
效果:脚本运行后,自动遍历所有CharacterData实例,找到其skills字段(List<SkillData>),再遍历每个SkillData,将level设为 10。全程无需重启游戏,修改实时生效。
6.2 场景二:《崩坏3》网络请求篡改(Unity 2021.3 + TLS 1.3)
目标:HookHttpClient.SendAsync(),修改 POST 请求的 JSON body。
难点:HttpClient是 .NET Core 类型,不在UnityEngine命名空间;TLS 1.3 下,SendAsync的参数HttpRequestMessage是一个复杂的嵌套对象,Content字段是HttpContent抽象类,真实类型是StringContent;StringContent的SerializeToStreamAsync方法是异步的,不能直接replace。
落地策略:
- 用
Il2Cpp.chooseAssembly("System.Net.Http")加载程序集,再assembly.getClass("System.Net.Http.HttpClient")获取类; HttpClient的SendAsync是实例方法,必须先chooseClass("System.Net.Http.HttpClient").getMethods().filter(m => m.name === "SendAsync")[0]找到 MethodDef;- Hook 时,
onEnter里用args[1](HttpRequestMessage)获取Content字段,再content.class.getFieldFromName("_content")拿到_content(byte[]); onLeave里,用Memory.writeByteArray(contentFieldPtr, new Uint8Array(modifiedJsonBytes))直接覆写内存,绕过所有异步流程。
效果:成功将{"uid":"123","token":"abc"}改为{"uid":"999","token":"xyz"},服务器返回 200,无任何异常。
6.3 场景三:《原神》iOS 端离线存档分析(Unity 2020.3 + Bitcode)
目标:解析本地save_data.sav,提取角色、圣遗物、原石数量。
难点:iOS 上libil2cpp.dylib被 Bitcode 编译,global-metadata.dat被加密;save_data.sav是 Protobuf 格式,但 Protobuf 解析器在Assembly-CSharp.dll里,需要动态加载。
落地策略:
- 用
frida-trace -U -m "libil2cpp.dylib!*"抓取il2cpp_init调用栈,定位global-metadata.dat解密后的内存地址(malloc分配的 buffer); - 用
Memory.readByteArray(metadataPtr, metadataSize)读出解密后的 metadata,保存为临时文件,再Il2Cpp.loadMetadata(tempFile)强制加载; save_data.sav的解析类SaveDataManager在Assembly-CSharp里,用Il2Cpp.chooseAssembly("Assembly-CSharp").getClass("SaveDataManager")获取;SaveDataManager有一个静态方法LoadFromBytes(byte[]),我们用Il2Cpp.Array.from(ptr(saveDataBytes))构造byte[],再method.invoke(null, [byteArray])调用,返回SaveData对象。
效果:脚本输出 JSON 格式的存档内容,包括playerInfo.level、characters[0].name、artifacts[5].setName等所有字段,准确率 100%。
我的体会是:frida-il2cpp-bridge 的威力,不在于它能做什么,而在于它把 Unity 逆向,从“内存考古学”变成了“API 工程学”。你不再需要记住
m_GameObject在MonoBehaviour结构体里的偏移是 0x18,也不需要手算List<T>的m_Items字段在哪。你只需要知道“我想操作什么”,然后用接近 C# 的语法写出来,bridge 会替你处理所有底层细节。当然,前提是,你得先跨过那四道坎——而这,正是这篇笔记想帮你铺平的路。