找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

693

积分

0

好友

89

主题
发表于 10 小时前 | 查看: 0| 回复: 0

在通过 Il2CppDumper 工具尝试 dump Unity 游戏的 SDK 时,经常会碰到一些加密的 global-metadata.dat 文件,导致无法成功 dump。搜索网上的解决方案,个人认为最好用的两种分别是:

  1. 动态 dump 解密后的 metadata。
  2. 利用 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.datMetadata 字符串来定位到关键的函数实现。大多数加密操作都隐藏在这些函数中。

现在开始第一个案例。在 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 数据。

加密后的 global-metadata.dat 十六进制数据

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

正常的 Il2CppGlobalMetadataHeader 十六进制数据

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,只需要修复一下文件头部特征即可。

解密后的 global-metadata.dat 十六进制数据

案例二:元数据文件被改名和加密

第二个案例中,在 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 )
    {
        // ... 后续初始化操作
    }
}

将文件拉入十六进制编辑器查看,初看也是一个循环异或的规律。

文件名被改且加密后的 metadata 数据

但每次都写解密脚本显然不够高效。这里我们可以使用强大的动态分析工具 Unidbg

动态 dump 的原理,除了识别 metadata 特征,就是 hook 读取 metadata 后返回的指针。通过 Unidbg 模拟执行,不需要传入复杂的参数,只需要配置好 global-metadata.dat(或改名后的文件)的读取路径即可。在读取函数返回解密后的内存指针时设置断点,即可将明文数据 dump 出来。这种方法本质上是对 开源实战 工具链的灵活运用。

案例三:元数据加密且结构头顺序被打乱

直接查看第三个案例的加密文件:

结构被打乱的加密 metadata 数据

加快节奏,直接在 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 的区段:stringLiteralDataOffsetstringOffset

使用 Unidbg,在 v24 = *(v10 + 100); 这一行(解密完成,即将使用结构头数据时)设置断点,将解密后的结构头 dump 出来。

观察 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 的一些成员数据,但观察代码中使用的偏移 0xF80xC0,与正常的结构体定义明显不符。这说明它映射的是打乱顺序后的“伪结构头”。

因此,仅解密数据是不够的,我们还需要修复 s_GlobalMetadataHeader,使其指向一个符合标准格式的结构体,Il2CppDumper 等工具才能正确解析。一个方法是找一份同版本未魔改的 SO 文件,在 IDA 中对比引用的偏移量,然后在当前 SO 中手动创建正确的结构体并应用。

使用 010 Editor 的模板可以清晰地看到标准结构头。

010 Editor 解析出的标准 Il2CppGlobalMetadataHeader 结构

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

在 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;

修复完成后,结构头恢复如下:

修复后的 metadata 结构头

案例四:加密、结构头乱序且加减值不一

来到最终的案例,直接查看 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 游戏 逆向工程 的道路上提供一些有价值的参考。




上一篇:NexPhone手机实测:Android、Linux、Win11三系统融合,能取代电脑吗?
下一篇:基于RK3568的Linux嵌入式系统:USB高速采集与WiFi实时传输方案
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-25 19:38 , Processed in 0.288365 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表