以蓝色星原:旅谣为例 —— HybridCLR 解密记录 V2
在 BW 试玩过蓝色星原 (蓝色原神) 之后,一直想找包解,浑身刺挠(
包体流出后,发现 AB 包只是单纯的 UnityCN 特色解密(无聊
但是在解密 global-metadata.dat 时,发现被加密了,解密完后头 magic 是 CODEPHIL,一眼代码哲学出品
翻文件的时候,发现几个 CDPH 开头的文件,同时又有 .NET 的 Metadata 段 magic + HybridCLR 的存在,估计这玩意是新的热加载 DLL,故写了这篇文章来记录 ¯\_(ツ)_/¯
补: 这篇文章所涉及的加解密与代码哲学新推出与
HybridCLR共同使用的Obfuz混淆器相关
分析 global-metadata.dat 解密
在 IDA 中搜索 CODEPHIL 或者 global-metadata.dat,前者对应 DecryptMetaData,后者对应 il2cpp::vm::GlobalMetadata::Initialize


DecryptMetaData 核心流程
- 清空输出指针:*a2 = 0。
- 校验头魔术 0x1357FEDA(不匹配则不解密,函数最后仍返回 1,输出保持 0)。
- 以 64 字节为块对 [a1+0x148, a1+0x148+total_size) 原地解密;最后一块按实际剩余长度。
- 解密完成后检查 memcmp(a1+0x148, “CODEPHIL”, 8):
- 若不相等(memcmp != 0)返回 0。
- 若相等:设置 *a2 = a1 + 0x150,然后返回 1。
分析完 DecryptMetaData,可以将 global-metadata.dat 构造成结构体如下
1 | struct globalMetadata { |
对于 DecryptBlock 的分析,见后文内容 =w=
分析 HybridCLR 对 HotPatch DLL 的加载
在官方文档中,直接使用了 Assembly.Load 来加载 HotPatch 的 DLL

其自定义后的 IL2CPP 通过下面的调用顺序对原始二进制进行加载
1 | int32_t RuntimeApi::LoadMetadataForAOTAssembly(Il2CppArray* dllBytes, int32_t mode) |
所以对自定义 HotPatch DLL 的加载和解密会在 RawImageBase::Load 里面
HotPatch DLL 的加载和解密分析
在 IDA 中定位到 RawImageBase::Load (AzurPromilia CBT1 GameAssembly.dll RVA 0x95F380)

LoadCDPHHeader
原版 HybridCLR 中, LoadCDPHHeader 的位置实际是 LoadCLIHeader,所以 LoadCDPHHeader 在读取自定义结构的时候,也会读取 CLI Header

LoadCDPHHeader 具体流程如下 (伪代码已经经过 LLM 处理)
- 读取 magic、版本 和 格式编号
1 | // 1. 头与版本 |
- 读取解密用的 8 段 opcodes
1 | // * 数据读取起点固定为 `data_offset = 0x110`。 |
- 构造特定 Table 以检验密钥合法性
1 | // 4. 密钥校验 |
- 读取 CLI Header
1 | // 5) 入口与元数据 |
LoadStreamHeaders / LoadTables
参照 HybirdCLR 原版代码,这部分读取没有做自定义化处理
LoadImageErrorCode RawImageBase::LoadStreamHeaders(uint32_t metadataRva, uint32_t metadataSize)
LoadImageErrorCode RawImageBase::LoadTables()
PostLoadStreams (解密 #1)
PostLoadStreams 在 HybirdCLR 原版代码没有给出定义,在这里它负责将 .NET 元数据的 streams 进行解密操作
其中的 instructions 和 _cdphKey 正是在 LoadCDPHHeader 读取的
注: 伪代码中的
_cdphKey + 8是我定义结构体时写错了 无伤大雅 ovo

该函数内 instructions (Opcodes) 和 streams 的对应关系如下
| Opcodes 序号 | streams | 说明 |
|---|---|---|
| 1 | #Strings | 标识/名字池 |
| 2 | #Blob | 签名等二进制数据池(方法签名、字段签名、属性签名…) |
| 3 | #US | User String(C# 源里写的 @"..."/普通字符串常量) |
| 5 | #~ / #- | 表流(Tables Stream) #~ 为优化格式,#- 多用于编辑/调试 |
PostLoadTables
这段 PostLoadTables 很“vector 内存管理味儿”,作用就是:
把一个按“表2行数(rowNum)”计的辅助数组(字节数组)扩到足够大,并把新扩出来的部分清零,最后把 size 设为 rowNum。
换句话说:在读取完元数据表后,给“表2(索引 2,对应 ECMA-335 的 TypeDef 表)”准备一块 rowNum 字节的工作区,用来存标记/状态之类的一维字节表。
#US 流解密 sub_1806FF780 (解密 #2)
sub_1806FF780 的唯一引用存在于 hybridclr::managed_cdpe_vtbl,为虚表第五个元素,如下

sub_1806FF780 (DecryptUS) 可以概括为:从 #US(用户字符串)流里按 ECMA-335 的“压缩无符号整数”规则读出一个长度前缀,取出后续那段加密的 UTF-16 字节串,用 instructions[4] + _cdphKey 解密,然后构造成托管字符串返回。长度小于 0x1000 用栈上缓冲区,反之堆上 malloc 一块。
伪代码(等价)
1 | Il2CppString* ReadUserString(managed_cdpe* self, uint32_t offset) { |
解密 TypeDef 表 sub_1807082D0 (解密 #3)
sub_1806FF780 的唯一引用存在于 hybridclr::managed_cdpe_vtbl,为虚表第八个元素
结论一句话:sub_1807082D0 读取并(按需)解密 TypeDef 表(表索引 2)中第 a3 行,把该行的 6 个列值取出写到 a2[0..5] 并返回 a2。
它用一个“已解密标记”字节数组避免重复解密;列宽(2/4 字节)和列内偏移从 a1->_tableRowMetas[2].a 的模式描述里取。
做了什么(按顺序)
一次性解密该行(惰性)
以
a3-1为 0 基行号,在a1->unknown[12]指向的字节数组里查看该行是否已解密:1
if (!flags[a3-1]) { flags[a3-1] = 1; DecryptBlock(..., row_ptr, row_size); }
row_ptr = &a1->_tables[2].data[row_size * (a3-1)]解密材料:
instructions[6].instructions / .count+_cdphKey,长度为整行rowMetaDataSize。
按“列模式”读取 6 列
列模式基址:
meta = a1->_tableRowMetas[2].a。每列的描述是成对字段:
[width32, offset16],在meta上按 8 字节一组排布:1
2
3
4
5
6第0列: *(u32*)(meta+0) 宽度=2或4; *(u16*)(meta+4) 偏移
第1列: *(u32*)(meta+8) ; *(u16*)(meta+12)
第2列: *(u32*)(meta+16) ; *(u16*)(meta+20)
第3列: *(u32*)(meta+24) ; *(u16*)(meta+28)
第4列: *(u32*)(meta+32) ; *(u16*)(meta+36)
第5列: *(u32*)(meta+40) ; *(u16*)(meta+44)读取逻辑:如果该列
width == 2就读uint16,否则读uint32;写入a2[i](扩展为 32 位保存)。
返回
a2。
表 2 正好有 6 列,和 ECMA-335 的 TypeDef 一致:
Flags,TypeName,TypeNamespace,Extends(TypeDefOrRef coded index),FieldList,MethodList。
其中不同列宽(2/4)由堆大小/表行数决定(字符串堆大用 4 字节,索引/编码索引随目标表行数变化)。
等价伪代码
1 | DWORD* ReadTypeDefRow(managed_cdpe* self, DWORD* out6, int row1based) |
关键信息与推断
unknown[12]:上一函数里我们已确定它被初始化为长度=TypeDef 行数的一维字节数组,作用就是**“该行是否已解密”**的标志位。_tableRowMetas[2].a:是“表 2”每列的宽度与偏移描述,按照 6×{u32 width; u16 offset}组成(每组步长 8 字节)。- 列宽 2/4 的含义:完全符合 ECMA-335 的可变宽索引规则(小堆/大堆,或编码索引跨表行数变化)。
- 参数:
a3是1 基的行号;a2必须至少可容纳 6×DWORD。 - 线程安全:无锁的惰性解密 + 标志位,多线程并发读取同一行有竞态(两方可能同时解密/写标记)。
- 越界检查:本函数不检查
a3是否 ≤ 行数,也不检查行内偏移/宽度是否合法,安全性依赖上游元数据与模式生成的正确性。
3.7 IL Code 解密 sub_1806FAF70 (解密 #4)
sub_1806FAF70 为虚表 hybridclr::managed_cdpe_vtbl 第六个元素,也是最后一个解密函数,其伪代码如下

sub_1806FAF70在通过一串门闸校验(sub_18070C160/sub_18070B380比对)后,直接对传入的IL Code做解密,本函数用instructions[7],随后进入sub_18070F190(...)做进一步解析/分发,更像“把已解密的 IL 交给后续解释/加载”。- 状态推进:
sub_180716F90(a1->unknown, 1)像是“阶段标记 = 已解密”,符合方法体加载流程中的一步。
总结
通过上面的分析,我们可以得到在 CDPH Header 中读取的 Opcodes 与实际解密的关系如下表
| Opcodes 序号 | 解密对象 | 解密块大小 | 说明 |
|---|---|---|---|
| 0 | 无 | 无 | 保留 |
| 1 | #Strings | 0x100 (256) | 解密 Metadata 段的 #Strings 整体 |
| 2 | #Blob | 0x100 (256) | 解密 Metadata 段的 #Blob 整体 |
| 3 | #US | 0x100 (256) | 解密 Metadata 段的 #US 整体 |
| 4 | #US | 0x10 (16) | 解密 Metadata 段的 #US 中的字符串 |
| 5 | #~ | 0x100 (256) | 解密 Metadata 段的 表流(Tables Stream) 整体 |
| 6 | TypeDef | typeDefRowMetaDataSize | 解密 表流(Tables Stream) 中的 TypeDef 表(表索引 2) |
| 7 | IL Codes | 0x10 (16) | 解密方法体的 IL Codes |
解密算法分析 (DecryptData/DecryptBlock)
总结一句话:这是个 “指令驱动的字节块变换器”。DecryptData 负责把整段数据按固定块长切片,然后对每个块调用一次 DecryptBlock;DecryptBlock 把 opcode(一串字节程序)逐条解释执行到 output 这个块上,每条指令用到 key[0..255] 某个字节和一些常量,对块内某个索引的字节做 XOR / 加减 / 右旋 / 交换 / 组合更新。
把真实语义写清楚:
DecryptData(opcodes, opcodes_len, key, data, data_len, block_len)opcodes/opcodes_len:指令序列及长度(之前叫buf/buf_size,容易误解)key:256 字节左右的密钥/表(索引到 0..255)data/data_len:要变换(解密/加密)的字节串block_len:每次处理的块大小(见你代码里多为0x10)
DecryptBlock(opcodes, opcodes_len, key, block, n)block[0..n-1]就地被修改
DecryptData 的逻辑(块处理器)
1 | void DecryptData(const u8* opcodes, size_t opcodes_len, |
要点:无链路/无状态,每块独立按同一串 opcodes 变换(更像“每块应用同一个程序”,而非标准分组密码的 CBC 等模式)。
DecryptBlock 的“虚拟机”指令集(概览)
每条指令是一个 int8_t opcode = opcodes[i]。n = output_size。函数里所有索引都写成 IDX = CONST % n 或 IDX = (key[K] ± CONST) % n,保证落在块内。
指令类型大致分为 5 类(在你的代码里分别对应大量 case):
XOR
1
out[ C1 % n ] ^= key[K] ^ C2;
例:
case 2, 11, 12, 25, 42, 45, 46, 47, 48, ...加/减(模 256)
1
out[ C1 % n ] = out[ C1 % n ] - key[K] ± C2; // 或纯 -= key[K] / += 常量
例:
case 1, 3, 4, 5, 8, 17, 21, 23, 28, 33(纯 -= key[65]) ...按位循环右移(单字节 ROR)
1
out[ C1 % n ] = ROR8(out[ C1 % n ], (key[K] + delta) & 7);
例:
case 0, 7, 9, 10, 13, 20, 29, 31, 34, 35, 36, 39, ...__ROR1__即对 8 位数做循环右移;& 7保证 0..7 位数。交换两位置字节(
LABEL_261路径)1
2
3i1 = C1 % n;
i2 = (key[K] ± C2) % n;
swap(out[i1], out[i2]);例:
case 19, 22, 27, 43, 44, 55, 62, 63, 75, 77, 86, 90, 94, 99, 105, 112, 122, 126, -123, -121, -120, -110, -99, -94, -84, -76, -74, -71, -69, -67, -51, -50, -48, -34, -21, -18, -14, -10, -6 ...组合更新:先减 1 再影响另一位(
LABEL_23→LABEL_262路径)1
2
3
4
5i1 = C1 % n;
i2 = (key[K] ± C2) % n;
t = out[i2] - 1;
out[i1] -= t; // out[i1] = out[i1] - (out[i2] - 1)
out[i2] = t; // 同时把 out[i2] 自减 1例:
case 18, 24, 26, 30, 32, 40, 58, 64, 70, 79, 81, 87, 89, 93, 97, 103, 106, 107, 110, 113, 116, 117, 119, 121, 123, 124, 126, -126, -110, -106, -102, -98, -97, -93, -92, -91, -88, -86, -80, -72, -63, -62, -40, -39, -34, -28, -26, -24, -20, -19, -17, -5 ...
由于
opcode是 有符号 8 位,所以128..255的字节在switch中以-128..-1出现(你看到那些负数的case)。
除此之外,每条指令的常量(如 0xB330B7AF 等)在运行时都会取模 n=output_size 成为块内索引。很多常量会在小块(例如 16 字节)时映射为同一个下标,从而多次叠加到同一字节上。
代表性几条指令(逐条翻译)
case 0out[ 0xB330B7AF % n ] = ROR8(out[idx], (key[182] + 1) & 7);case 2out[ 0x8CC11BA1 % n ] ^= key[224] ^ 0x23;case 19(交换)i1 = 0x02BF6A70 % n; i2 = (key[115] - 1335899030) % n; swap(out[i1], out[i2]);case 18(组合更新)i1 = 0x8AAEBD71 % n; i2 = (key[112] + 259923827) % n; t = out[i2]-1; out[i1]-=t; out[i2]=t;case 61out[0x3BDE5592 % n] = ROR8(out[idx], (key[13] - 4) & 7);
……(其余同理,都是以上 5 大模板的实例)
重要性质与安全性提示
- 块独立、指令重放:每个块独立变换,且用同一套
opcodes;无随机化/反馈。 - 操作线性/弱非线性:
XOR/加减/旋转/交换这类操作对攻击者非常友好(尤其是短块、常量取模后重复击中同一索引时)。 - 密钥索引固定:每条指令使用
key[固定位置];只要拿到opcodes和key,就完全可逆。 - 实现方便:按上面的 5 个模板就能写出可运行的参考实现/解密器。
总结
首先感谢我的朋友 @66hh ( 52pojie - zbby | Github - 66hh ),在对解密逻辑的分析以及解密器的编写中都做出了重大贡献,如果没有他的协助,该文章是不可能出现的 ovo
其次,在文章开头说过,本文所分析的解密是由代码哲学所开发的 Obfuz 所生成的,根据该项目的 ReadMe,解密用的 VM,即上文所分析的 DecryptBlock,是随机生成的,故该文章无法保证该解密方法适用于所有使用该混淆器的游戏
最后,来一张 dnSpy 解析解密后的 HotPatch DLL 作为结尾吧 ovo
解密器不公开 别私信问我了 (
