原神1.x时期的il2cpp保护思路分析

Joliph - 2023-03-12 - 技术分享 / CPP / 安全 / unity / il2cpp
2023-3-12|最后更新: 2023-4-2|
type
status
date
slug
summary
tags
category
icon
password

原神1.x时期的unity分析

背景

首先需要强调一下,该系列仅针对1.x时期的加密方案分析当前版本已不适用,方便大家学习下优秀的unity保护思路(原神的相关保护是我见过最夸张的暂时没有之一),本篇文章仅为整理出的分析笔记补全了部分网上的分析资料,仅针对unity-il2cpp中的元数据解密,资源保护分析篇也有后期也会整理下发出来
建议先看上一篇针对崩坏3保护思路的文章集合,本篇大量学习自上一个系列的文章,没有katyscode的分析基础想要看清楚思路会有点难度
另外可以从
GI-Download-Library
MAnggiarMustofaUpdated Aug 30, 2023
获取到历史版本的游戏下载链接

先看下元数据的16进制

notion image
经典前0x40字节高熵,至少1.0.0时期与3rd同加密方式,如果不想采用监控调用栈的方式有什么思路
  1. 通过il2cpp_init反汇编正向找到解密函数地址
  1. 字符串搜索global-metadata字符串反向定位到关键函数
notion image
LoadMetadataFile被改成了无参函数,原版是需要传递"global-metadata.dat"进来的

继续分析看下里面还有有无解密代码

notion image
这里的qword_1862773F0是个函数指针__int64 (__fastcall *qword_1862773F0)(_QWORD, _QWORD)
回溯分析赋值代码,交叉引用看到il2cpp_init_security中的赋值,好像在3rd中也看到过,貌似也不是原版il2cpp函数
回到MetadataCache::Initialize函数中
notion image
59行是个明显的*imagesCount = s_GlobalMetadataHeader->imagesSize / sizeof(Il2CppImageDefinition);模式
0x44是编译期算出的sizeof结果,可以推测在此处如果有整体加密也已经解密了=>开启进程后hook dump或者继续分析il2cpp_init_security相关(unityplayer中)
notion image
字符搜索找到LoadIl2Cpp中的il2cpp_init_security调用点,重命名相关变量后回溯交叉引用点
notion image
回溯并重命名一些api增强可读性
char __fastcall InitializeIl2CppFromMain(__int64 a1, __int64 a2, unsigned int a3, __int64 a4) {  __int128 v7; // [rsp+20h] [rbp-48h]  __int128 v8; // [rsp+40h] [rbp-28h] BYREF  __int64 v9; // [rsp+50h] [rbp-18h]  *&v7 = qword_18193E688;  *(&v7 + 1) = qword_18193E698;  v8 = v7;  v9 = qword_18193E6A8;  il2cpp_init_security_(&v8);  sub_18090EF00();  sub_1808DBBB0();  qword_181936570(0i64);  qword_1819366E0(a3, a4, 0i64);  qword_1819366C8();  qword_1819366D0();  il2cpp_init("IL2CPP Root Domain");  il2cpp_set_config("unused_application_configuration");  sub_180920D90();  return 1; }
这里需要结合汇编代码分析下会比较准确,最终确认返回解密后的元数据文件的函数=v8=v7=qword_18193E688 = sub_1801A7B20(交叉引用分析,此处省略)
ida上给sub_1801A7B20修改为正确的函数申明,实际的调用应该是decBuffer = xmmword_1862773F0(encBuffer, size);

试着直接调用吧

#include <iostream> #include <fstream> #include <vector> #include <stdexcept> #include <cstdio> #include <Windows.h> using DecFunction = byte * (*)(const byte*, int); std::vector<byte> read_file(const std::string& file_path) {    std::ifstream file(file_path, std::ios::binary | std::ios::ate);    if (!file) throw std::runtime_error("Failed to open file: " + file_path);    std::vector<byte> buffer(file.tellg());    file.seekg(0);    if (!file.read(reinterpret_cast<char*>(buffer.data()), buffer.size())) {        throw std::runtime_error("Failed to read file: " + file_path);   }    return buffer; } int main(int argc, char* argv[]) {    if (argc != 4) {        std::cerr << "arg must be: metadata_path unityplayer_path func_rva" << std::endl;        return std::getchar();   }    const auto metadata_buffer = read_file(argv[1]);    const auto unity_module = LoadLibraryA(argv[2]);    if (!unity_module) {        std::cerr << "Failed to load Unity player library." << std::endl;        return std::getchar();   }    const auto dec_func = reinterpret_cast<DecFunction>((byte*)unity_module + std::strtoul(argv[3], nullptr, 16));    const auto result = dec_func(metadata_buffer.data(), metadata_buffer.size());    std::ofstream outfile("dec.bin", std::ios::binary);    if (!outfile) {        std::cerr << "Failed to open output file." << std::endl;        FreeLibrary(unity_module);        return std::getchar();   }    outfile.write(reinterpret_cast<const char*>(result), metadata_buffer.size());    FreeLibrary(unity_module);    return std::getchar(); }

看下输出成果

beyond compare对比看下输出的解密数据
notion image
可以看到之前观察到的前0x40加密内容被解密了,并且观察左侧对比窗口可确定其逻辑为周期性块加密(其实可以深入去看下间隔 & 是否为简单的加密)

但依然存在加密数据

这个的发现是在分析数据时发现了一组很突兀的16字节高熵数据混杂在低熵区,然后想到了之前看到过的一篇分析文章,当时没太在意,并且到现在也不清楚这个解密过程是在什么时候完成的,因为我看在分析metadatauserage数据的时候没发现解密过程
但是这篇文章应该是把秘钥分析错了或者是国内海外版的密钥不完全一致,或者文章写错了?一处B0写成了80
反正最终写个python脚本每次间隔0x22C00,异或16字节秘钥为AD 2F 42 30 67 04 B0 9C 9D 2A C0 BA 0E BF A5 68即可

对照源码标注InitializeRuntimemetadata函数

对照libil2cpp 2017的源码分析反编译代码添加unity结构体+重命名后易读性增强,如下
i = 0; token_ = token; metadataUsageLists = (const Il2CppMetadataUsageList *)(*(int *)(metadataheader + 0x150) + DecMetadataBuffer); count = metadataUsageLists[token_].count; start = metadataUsageLists[token_].start; do {  offset = i + start;  metadataUsagePairsStart = (const Il2CppMetadataUsagePair *)(DecMetadataBuffer + *(int *)(metadataheader + 0xF8));  destinationIndex = metadataUsagePairsStart[offset].destinationIndex;  LODWORD(offset) = metadataUsagePairsStart[offset].encodedSourceIndex;  usage = (unsigned int)offset >> 29;  decodedIndex = offset & 0x1FFFFFFF;  ++i; }

关键的字符串字面量在哪里呢?

找这个东西着实花了不少时间,这东西的逻辑从原本Gameassembly中读取移动到了unityplayer中实现,并且unityplayer被混淆了相对分析难度更高,这里也是参考率kate的分析逻辑,再次赞叹下大佬的思路还是牛

元数据空隙分析

上面分析的关键函数大体逻辑是没变的,但是stringdata,stringliteral,stringliteraldata三个关键表没找到,考虑到metadata原始版本每块数据都是有意义的且是分块存储的,所以通过metadata头中的offset+size可以框定已经被使用的块有哪些,从而分析是否存在空隙,而空隙是藏东西的好地方, 去除明显无意义的数据后offset + size如下,前4字节为小端序的偏移,后4字节为小端序的长度(il2cppheader经典内容)
9C 13 0F 00 24 B8 00 00 C0 CB 0F 00 AC 37 10 00 6C 03 20 00 88 65 03 00 F4 68 23 00 90 E8 24 00 84 51 48 00 90 FC 00 00 14 4E 49 00 80 07 00 00 94 55 49 00 F0 0F 00 00 84 65 49 00 20 70 24 00 A4 D5 6D 00 F0 A9 00 00 94 7F 6E 00 B0 1B 05 00 44 9B 73 00 6C 89 01 00 B0 24 75 00 14 05 00 00 C4 29 75 00 D0 38 10 00 94 62 85 00 58 7A 09 00 EC DC 8E 00 3C BD 00 00 28 9A 8F 00 20 8B 00 00 48 25 90 00 00 00 00 00 48 25 90 00 68 0C 00 00 BC 72 C2 00 20 0B 33 00 DC 7D F5 00 B0 0C 00 00 8C 8A F5 00 B0 09 3D 00 3C 94 32 01 38 11 00 00 74 A5 32 01 24 21 00 00 98 C6 32 01 00 EA 0F 00 98 B0 42 01 C0 04 CE 00 58 B5 10 02 7C A0 01 00 D4 55 12 02 B4 82 05 00 88 D8 17 02 F8 F6 13 00
python处理下发现空隙为:0x158~0xf139c, 0x9031b0~0xc272bc, 0x22bcf80~0x22c0f98
然后确认0x9031b0~0xc272bc为null结尾的string符号数据
notion image
所以stringliteral表和stringliteraldata数据在下面这两个范围内0x158~0xE29b4/ 0x22bcf80~0x22c0f98
stringliteral表存的是offset + len对,如果没加密则为低熵,尝试性从上述两个范围内寻找低熵区域(分区算熵软件或者直接搜索0000)
到了0xAADD4处,并且由于stringliteraldata数据是非null结尾的也正因此才需要len字段,所以划定stringliteraldata的标准就是前一个offset + size = next.offset,并且不应该分开,写个python跑下划定的范围结果是0xAADE4 ~ 0xE29b4
所以当前仅剩0x158~0xAADE4和0x22bcf80~0x22c0f98数据意义不确定,stringliteraldata加密存放于其中
数据有个很有意思的点在于:最终我们从stringliteral表的最后一个项发现最后一个字面量在0xAAC44+0x38 = 0xAAC7C结束
而第一块不确定的数据大小有0xAADE4 - 0x158 = 0xAAC8C....所以stringliteraldata在哪以及有所暗示,保持加密后的数据大小与加密前保持一致则可能是按字节加密,最简单的就是异或,当然这些也只是猜测

字符数据动态调用/分析

  1. 找字符串解密就是找GetStringLiteralFromIndex函数,其本身特征不明显但函数中有调用到il2cpp::vm::String::NewLen
  1. userassembly的导出函数il2cpp_string_new_len就是il2cpp::vm::String::NewLen的包装器
  1. 根据2找到il2cpp::vm::String::NewLen,再根据il2cpp::vm::String::NewLen找到GetStringLiteralFromIndex继而分析或者想让回溯InitializeRuntimeMetadata
notion image
notion image
对着源码看下重命名好,case14的是GetConstantValueFromBlob,这里GetStringLiteralFromIndex内联在InitializeRuntimeMetadata里了
notion image
可以看到这里代码和unity原版不一致,给到NewLen的pointer和str_len不是根据index从metadataheader中取得
回溯qword_186277400函数指针的赋值,发现是il2cpp_init_security中获取的另外一个函数指针
通过调用形式分析可以大致确认,qword_186277400函数返回值为解密后的字符串指针
qword_186276E60上述函数分析过了,就是解密后的global-metadata的头指针,index可以在userassembly二进制中扫描得到(根据一定的规则)
v7 = qword_186276E40 = qword_18581A460 = 0x13208h
char* DecStringLiteralFromIndex(byte* metadataheader, int32_t index, int* strlen, __int64)
(il2cpp_init_security_arg1 +16) = qword_18193E6A8 = sub_18012F1F0,修改上述代码尝试动态调用发现出现崩溃,猜测原因是存在全局变量或数据未能正确初始化,且函数中有用到。尝试分析unityplayer.dll时发现存在混淆,jmp reg查表的形式混淆打断ida分析,写了个idapython插件解决这样的问题,选中区域然后模拟执行自动patch成单行跳转,另外github上也发现有针对该混淆的idapython脚本,感兴趣可以自行搜索下

字符串解密算法

如果可以动态加载起来的话就可以不用担心全局变量未初始化导致的call崩溃问题了,或者直接钩挂获取返回数据,但静态分析的话就相对难度大很多去除混淆后可进一步分析相关解密算法的逻辑,具体加密逻辑分析请看VMProtect 控制流混淆(案例研究:崩坏冲击 3rd 中的字符串算法密码分析最终分析的结果与原1.x时期是一致的, 如下
byte cl = (byte)(buffer[i] ^ stringDecryptionBlob[(0x1400 + i) % 0x5000]); byte al = (byte)(stringDecryptionBlob[i % 0x2800 + index % 0x2800] + i); buffer[i] = (byte)(cl ^ al);
所以这里的encbyte = decbyte ^ stringDecryptionBlob[(0x1400 + i) % 0x5000] ^ (stringDecryptionBlob[i % 0x2800 + index % 0x2800] + i)
别看这里后两个key的分布做了一些随机性增强,合并后本质就是单字节异或,只是每个字节异或的值不一样而已,这也是kate说的只要将原始数据改为00,再获取结果就能获得最终的xor key,再异或一次原文即可获的明文的逻辑
unity游戏资产保护分析崩坏3的il2cpp保护思路文章列表