在通过 Il2CppDumper 工具尝试 dump Unity 游戏的 SDK 时,经常会碰到一些加密的 global-metadata.dat 文件,导致无法成功 dump。搜索网上的解决方案,个人认为最好用的两种分别是:
- 动态 dump 解密后的 metadata。
- 利用 il2cpp api(未混淆)动态 dump CS 文件。
本文将通过四个案例,由易到难,展示如何完全通过静态分析来解决这些问题。其中有能用上述方法解决的,也有无法使用上述方法,需要另辟蹊径的。
案例一:规律可循,编写解密脚本
首先,既然要解密,就必须先了解加密操作发生在哪里。
通过查阅 GitHub 上的 Unity 源码(以 2020 年版本为例),可以在 MetadataCache.cpp 中找到 il2cpp::vm::MetadataCache::Initialize 函数,这里通常是大多数 metadata 解密的入口点。
bool il2cpp::vm::MetadataCache::Initialize()
{
s_GlobalMetadata = vm::MetadataLoader::LoadMetadataFile("global-metadata.dat");
if (!s_GlobalMetadata)
return false;
s_GlobalMetadataHeader = (const Il2CppGlobalMetadataHeader*)s_GlobalMetadata;
IL2CPP_ASSERT(s_GlobalMetadataHeader->sanity == 0xFAB11BAF);
IL2CPP_ASSERT(s_GlobalMetadataHeader->version == 24);
// ... 后续代码
}
void* il2cpp::vm::MetadataLoader::LoadMetadataFile(const char* fileName)
{
std::string resourcesDirectory = utils::PathUtils::Combine(utils::Runtime::GetDataDir(), utils::StringView<char>("Metadata"));
std::string resourceFilePath = utils::PathUtils::Combine(resourcesDirectory, utils::StringView<char>(fileName, strlen(fileName)));
// ... 读取文件
}
通过源码可知,我们通常可以通过搜索 global-metadata.dat 或 Metadata 字符串来定位到关键的函数实现。大多数加密操作都隐藏在这些函数中。
现在开始第一个案例。在 SO 文件中搜索 Metadata 字符串,定位到 LoadMetadataFile 函数。分析其伪代码后发现,这个函数里并没有进行解密操作。接着,再定位到上层的 Initialize 函数。
bool __fastcall sub_35D25D8(_DWORD *a1, int *a2)
{
v4 = (sub_35FC404)("global-metadata.dat");
v5 = v4;
unk_873B968 = v4;
if ( v4 )
{
unk_873B970 = v4;
v6 = *(v4 + 172);
*a1 = v6 / 0x28;
// ... 后续是填充 metadata 结构头和各类数值
}
}
if ( v4 ) 之后的伪代码已经是在将 metadata 的结构头和一些值填入内存了。按理说,解密操作应该在 sub_35FC404 读取 metadata 之后进行,但我们并没有找到。
既然没找到解密逻辑,那就直接查看加密后的 metadata 数据。

如果你观察过正常的 metadata 结构头,或者对数字比较敏感,可能会立刻发现这个加密很有规律。为了对比,这里有一份正常的结构头:

metadata 中存储的多是各个参数的偏移量和数量,因此很少有数据大于 0xffffff(小端序)。观察 0x03 0x07 0x0b 0x0f 等偏移,可以看出一定的加减规律。更细致地观察,会发现这个规律以 16 字节为周期。
再看 0x100 这个偏移,这里存储的是关于 Windows 平台的数据,在 APK 中用不到,因此通常是不变的。基于此,我们可以提取 0x100 处的十六个字节,并对偏移 0x8 的数据异或 0x1,即可得到一个异或表,从而编写解密脚本。
def decrypt_metadata(encrypted_file_path, output_file_path):
with open(encrypted_file_path, 'rb') as f:
encrypted_data = f.read()
# 初始化密钥表
key_table = [0x70,0x2c,0xe8,0xa4,0x60,0x1c,0xd8,0x94,0x51,0x0c,0xc8,0x84,0x40,0xfc,0xb8,0x74]
key_table[8] ^= 0x1
decrypted_data = bytearray()
for i in range(len(encrypted_data)):
# 使用当前密钥表位置进行异或
key_index = i % 16
temp = encrypted_data[i] ^ key_table[key_index]
# 重要:将解密后的数据写回密钥表
key_table[key_index] = (key_table[key_index] - 0x40) & 0xFF
decrypted_data.append(temp)
with open(output_file_path, 'wb') as f:
f.write(decrypted_data)
# 使用示例
decrypt_metadata('global-metadata.dat', 'global-metadata-decrypted.dat')
运行脚本后,我们得到了一个干净的 metadata,只需要修复一下文件头部特征即可。

案例二:元数据文件被改名和加密
第二个案例中,在 SO 文件里无法搜索到 metadata 相关的字符串。查看 assets 资源文件,发现文件路径名和文件名都被修改成了无意义命名。
但幸运的是,LoadMetadataFile 函数中还有一个 ERROR: Could not open %s 字符串。通过这个字符串,我们找到了读取函数,发现其内部使用的路径名被改为了 gloxinia。
__int64 __fastcall sub_12C6E34(const char *a1)
{
sub_12E2F9C(v14);
v12 = "gloxinia";
v13 = 8;
// ... 拼接路径等操作
v7 = sub_12E1F00(v14, 3, 1, 1, 0, &v18);
if ( v18 )
{
if ( (v14[0] & 1) != 0 )
v8 = v15;
else
v8 = v14 + 1;
sub_12F631C("ERROR: Could not open %s", v8);
}
// ...
}
向上回溯,我们找到了实际的解密函数,并最终定位到 Initialize 函数,确认了加密后的 metadata 文件名为 feast_plaint_knight。
bool __fastcall sub_12645CC(_DWORD *a1, int *a2)
{
v4 = sub_12C6D60("feast_plaint_knight");
v5 = v4;
qword_2CE58C0 = v4;
if ( v4 )
{
// ... 后续初始化操作
}
}
将文件拉入十六进制编辑器查看,初看也是一个循环异或的规律。

但每次都写解密脚本显然不够高效。这里我们可以使用强大的动态分析工具 Unidbg。
动态 dump 的原理,除了识别 metadata 特征,就是 hook 读取 metadata 后返回的指针。通过 Unidbg 模拟执行,不需要传入复杂的参数,只需要配置好 global-metadata.dat(或改名后的文件)的读取路径即可。在读取函数返回解密后的内存指针时设置断点,即可将明文数据 dump 出来。这种方法本质上是对 开源实战 工具链的灵活运用。
案例三:元数据加密且结构头顺序被打乱
直接查看第三个案例的加密文件:

加快节奏,直接在 SO 中观察 Initialize 函数内的解密逻辑。
_DWORD *__fastcall sub_654BC4(_DWORD *a1, int *a2)
{
// ... 解密 global-metadata.dat 字符串并读取文件
result = sub_673A98(&v40); //读取metadata
qword_227D250 = result;
if ( result )
{
// ... 对读入内存的 metadata 进行解密和重组操作
v10 = sub_654FF8(v8, v7, &v38, &v37);
// ... 交换部分数据
*(v10 + 9) ^= 0x27u;
*(v10 + 5) = v12 ^ 0x59;
qword_227D258 = v10; // v10 指向解密后的结构头
// ... 解密两个主要数据区段 (stringLiteralDataOffset, stringOffset)
}
}
可以看到,函数先将 global-metadata.dat 字符串解密,然后读取文件内容,接着在内存中对数据进行解密和重组。它主要加密了结构头和两个 metadata 的区段:stringLiteralDataOffset 和 stringOffset。
使用 Unidbg,在 v24 = *(v10 + 100); 这一行(解密完成,即将使用结构头数据时)设置断点,将解密后的结构头 dump 出来。
观察 dump 下来的结构头,会发现它不仅顺序被打乱,还被塞入了很多无用数据。

// 源码中的使用方式
v24 = *(v10 + 100);
*v35 = v24 / 0x28;
v25 = *(v10 + 616);
dword_227D260 = v24 / 0x28;
*v36 = v25 >> 6;
qword_227D268 = sub_6B3F18((v24 / 0x28), 56);
qword_227D270 = sub_6B3F18(*(qword_227D248 + 0x30), 8);
qword_227D278 = sub_6B3F18(*(s_GlobalMetadataHeader + 0xF8) / 0x58uLL, 8); // 注意这里的偏移 0xF8
qword_227D238 = sub_6B3F18(*(s_GlobalMetadataHeader + 0xC0) >> 5, 8); // 注意这里的偏移 0xC0
对比源码可知,这里填入的是 s_GlobalMetadataHeader 的一些成员数据,但观察代码中使用的偏移 0xF8、0xC0,与正常的结构体定义明显不符。这说明它映射的是打乱顺序后的“伪结构头”。
因此,仅解密数据是不够的,我们还需要修复 s_GlobalMetadataHeader,使其指向一个符合标准格式的结构体,Il2CppDumper 等工具才能正确解析。一个方法是找一份同版本未魔改的 SO 文件,在 IDA 中对比引用的偏移量,然后在当前 SO 中手动创建正确的结构体并应用。
使用 010 Editor 的模板可以清晰地看到标准结构头。

在 IDA 中创建并应用这个结构体。

但是,结构体有近 60 个成员,一个个手动查找并修复显然效率低下。观察案例一解密后的 metadata 可以发现,除了头部特征和末尾的冗余数据,其余数据(各个偏移量和数量)是紧凑相连的,并且存在数学关系。例如:
stringLiteralOffset + stringLiteralCount == stringLiteralDataOffset
因此,我们可以编写一个算法:遍历我们已知的正确结构头数组,然后在打乱的数据中,寻找满足类似 A + B == C 这种关系的数值对,从而将乱序的数据映射回正确的结构体成员。
最后,这个例子还有一个小坑:在后续读取某些数据时,会在偏移值的基础上加上一个固定值 0x1E4。
v49 = unk_227D250 + *(s_GlobalMetadataHeader + 0x20LL) + 0x1E4 + 88 * v48;
v49 = unk_227D250 + *(s_GlobalMetadataHeader + 0x1D8LL) + 0x1E4 + 16 * v50;
修复完成后,结构头恢复如下:

案例四:加密、结构头乱序且加减值不一
来到最终的案例,直接查看 SO 中的 Initialize 函数。
bool __fastcall sub_11481E4()
{
// ... 解密字符串并读取文件
src = sub_116475C(&s_, a2, a3, a4, a5, a6, a7, a8, s_, *(&s_ + 1), v59, v60, v61, v62, v63, v64, v65, v66); //读metadata
::src = src;
if ( src )
{
dest = malloc(0x150u);
// ... 解密结构头(前84字节)
// ... 解密7个不同的数据区块,每个区块的异或密钥与一个基于其偏移的变量相关
// 例如:
// for ( i = 0; i < size; ++i )
// *(dest_1 + i) ^= v20 + i - 72;
// for ( j = 0; j < size_1; ++j )
// *(dest_2 + j) ^= -v24 + j + 72;
// ... 后续初始化
}
}
这是案例三的加强版,它加密了多达 7 个数据区块,并且每个区块的加/解密操作中,加减的常量各不相同。观察这两行代码,dest 指向的就是解密后的“伪结构头”指针。
v55 = ::src + *(::dest + 0xE8) + 0x1C + 88 * v54;
v55 = ::src + *(::dest + 56) - 0x40 + 16 * v56;
案例三的加减值是统一的 0x1E4,而这个案例中每个数据块的加减值都不同(如 0x1C、-0x40),这让我们无法直接使用案例三的“数学关系推导法”。
但是,观察发现这些加减值大多在 0x50 以内。因此,我们可以将匹配条件放宽到一个区间:
stringLiteralOffset + stringLiteralCount ± 0x50 ≈ stringLiteralDataOffset
但这种做法有一个隐患:可能会匹配到多个候选值。一个更可靠的方法是结合对 Il2Cpp 元数据结构的深入理解。了解每个结构体成员的具体含义(例如,某个偏移量代表的是方法定义表的起始位置),然后根据这些语义信息,在反汇编代码中交叉验证,最终确定唯一正确的映射关系。这个过程需要扎实的 逆向工程 功底和对 技术文档 的仔细研读。
修复后的结构头如下:

总结
通过这四个由浅入深的案例,我们展示了面对不同强度 global-metadata.dat 加密时的静态分析思路。从简单的观察规律、编写脚本,到利用 Unidbg 进行半动态 dump,再到复杂情况下的结构修复与语义分析,每一步都考验着分析者的耐心和技术功底。希望这些实战经验能为你在 Unity 游戏 逆向工程 的道路上提供一些有价值的参考。