前阵子一个团队拿包来问:我们用的 IL2CPP,C# 全编成 native,dnSpy 拖进去一片空白,这块该没问题了吧?
现在手游基本都上 IL2CPP,也正因为它是默认项,很容易顺带长出一种"native 化了就藏住了"的底气。但其实不然,IL2CPP 关掉的只是"读 C# 源码"这一个动作,而且为了让游戏能跑,它自己还得往包里塞一份明文说明书,把刚垒起来的那点门槛又送回去一大半。
它确实挡住的
IL2CPP 值钱的地方在于它把 C# 编成了 native。以前托管代码那条老路——IL 塞在 Assembly-CSharp.dll 里,dnSpy 一拖近乎源码——被彻底堵上了。产物是 libil2cpp.so,想读逻辑就得啃 ARM 汇编,确实拔高了分析门槛,但要是以为 native 化 = 逻辑藏住了,那就想岔了。

global-metadata.dat:明文的说明书
native 代码丢了一大堆"关于代码的信息"——类叫什么、方法什么签名、字段在对象里排第几位。可 .NET 重度依赖反射,运行时缺这些跑不起来。IL2CPP 于是把它们单拎出来存成一个文件,默认明文躺在:
assets/bin/Data/Managed/Metadata/global-metadata.dat
文件开头是个头结构,0xFAB11BAF 是它第一个字段(hex 里看到 AF 1B B1 FA 就是它):
typedef struct Il2CppGlobalMetadataHeader {
int32_t sanity; // 0xFAB11BAF
int32_t version; // 元数据格式版本
// ...(中间一堆 offset / size,成对出现)
int32_t stringOffset; // 字符串表:类名方法名就在这
int32_t stringSize;
// ...
} Il2CppGlobalMetadataHeader;
这些成对的 offset/size 把类型定义、方法签名、字段偏移、字符串表一段段切开,名字就在 stringOffset 指的那块——native 抹掉的符号信息,原样又交了出来。

Il2CppDumper 就吃这两样。libil2cpp.so 配上 global-metadata.dat 一起喂进去,它对着解析,产出 IDA 脚本、il2cpp.h、还有能拖进 dnSpy 的 stub DLL。地址配上名字,sub_XXXXXX 就还原成可读方法:
// dump.cs(Il2CppDumper 的产物):地址 + 还原出来的签名
// RVA: 0x8A31C0 Offset: 0x8A31C0 VA: 0x1808A31C0
public void AddGold(int amount) { } // IDA 脚本里叫 PlayerController$$AddGold
你以为下沉 native 就藏住的接口地址、开关字段名,strings 扫 SO 扫不到,可不少就明晃晃写在 metadata 的字符串表里。工具不用逆你的算法,读文件就够。
第一层:加密 metadata
最直接的反应是别让工具白读:改文件头 magic,让 Il2CppDumper 上来 if (ToUInt32(...) == 0xFAB11BAF) 就失败,或者干脆整份加密、运行时在加载入口解回来。入口是固定的,MetadataCache::Initialize() 里那句:
s_GlobalMetadata = vm::MetadataLoader::LoadMetadataFile("global-metadata.dat");
加密放进构建流水线,密钥别跟明文 metadata 进同一个仓库。
但这一层能扛多久,前面已经给了答案:游戏要用 metadata,它必须在内存里解成明文,这点绕不过去。攻击者不碰你的密钥,等进程解开,直接按 magic AF 1B B1 FA 在内存里搜出来 dump 走。Zygisk-Il2CppDumper 这类 root 机上的工具,作者原话是绕过几乎所有保护,走的就是这条内存路子。
那还值不值得做?值。它把攻击从"解包、双击 Il2CppDumper、批量出货"逼成"上 Frida、找 dump 点、绕反调试",成本抬一档,劝退一批。代价另算——整份 metadata 启动时解密,冷启动和内存都要付,低端机更明显,跟资源加密是同一本账。
第二层:抹掉名字
锁容器有个天花板:能从内存 dump 的人,拿到的还是一份带完整可读名的 metadata。真正让 dump 出来也读不懂的,是名字本身——在 IL2CPP 编译之前就把 C# 的类名方法名混淆掉,烘进字符串表的本就是一堆 a1、b2,dumper 把结构全解出来,AddGold 也回不来,只剩 sub_xxxx,逻辑得挨个手动啃。
但名字不能无脑全抹——反射按字符串找类、JsonUtility 序列化、GetComponent 传字符串、各种生命周期回调,都要拿原始名字对上号,混淆进去直接崩。所以按价值来:核心业务类型狠混,运行时依赖名字的在混淆规则里点名放行。
还想再抬,就自编译 libil2cpp、改元数据布局,让 Il2CppDumper 写死的解析假设失效——有效,但维护成本和跟 Unity 版本对齐的成本得一起算。
它挡不住的

metadata 保护做到顶,libil2cpp.so 也终究要执行,有耐心的人顺着执行流在关键点还是能把逻辑抠出来——这是所有静态保护共同的天花板。更要紧的是别指望它越界:包被改了重签名分发、进程跑起来被 hook 返回值、协议被原样重放,IL2CPP 一样都不接,那分别是签名完整性校验、反调试反 hook、防重放各自的活。
所以回到开头那个问题:用了 IL2CPP,这块就没问题了吗?只能说,比没用强,但离“没问题”还差得远。IL2CPP 能让攻击者多绕几步,但不能替你把后面的签名完整性、运行时检测、协议防重放、服务端判定都做完。把它当一层门槛没问题,把它当整套防线,后面大概率还要补课。