一篇文章讲清两件事:这个游戏的资源用了哪种 Oodle 压缩,以及加密密钥是怎么从引擎库里"挖"出来的。
全程本地静态 + 少量模拟,不上真机。
0. 背景:资源是怎么装的
游戏是 UE 5.6.1(Shipping / ASTC),资源走 IoStore 容器:.utoc(目录) + .ucas(数据) + 可选 .pak。
读取管线:
pakchunk 索引 = ① Oodle 压缩 → ② AES-256 加密
所以想解包,得同时搞定"Oodle 解压"和"AES 解密"。
资源不在 APK 里,在 OBB(assets/main.obb.png,其实是 ZIP) 内:
HT/Content/Paks/pakchunk0-Android_ASTC.utoc/.ucas
HT/Content/Paks/pakchunk1-Android_ASTC.utoc/.ucas
HT/Content/Paks/global.utoc/.ucas
1. 先定位:这游戏用的哪种 Oodle
1.1 哪个库带 Oodle
扫全部 so 的特征串,只有引擎库 libUnreal.so 带,其余 0。
1.2 里面有两套 Oodle
| 套 |
用途 |
特征串 |
| OodleData |
资源压缩(IoStore/pak) |
OodleLZ_Decompress / OodleLZ_Compress / EDecompressionType::Oodle |
| OodleNetwork |
网络包压缩 |
OodleNetworkHandlerComponent / EOodleNetResult::* |
算法族 Kraken / Mermaid / Selkie / Leviathan;加上源码泄漏串 v:\devel\projects\oodle2\core\newlz*.cpp,可判定为 Oodle 2.x SDK 静态链入(不是外挂 oo2core 动态库)。
1.3 一个反直觉的点:名字只是"报错用的"
对 OodleLZ_Decompress 做精确交叉引用 → 只在两处出现,都不是调用点:
真正的解压逻辑是编译进 .text 的代码,名字串基本只在异常时打印。
1.4 为什么静态拿不到"解压器入口"
Oodle 的函数表在一个全局结构里(…+0xe63d590 附近,各槽 blr 过去),而这段落在 BSS——文件里全是 0,运行时才被插件初始化写入。
→ 静态读文件拿不到解压器地址。
1.5 关键结论:Oodle 不是卡点
索引 = Oodle 压缩 + AES 加密,而 Oodle 有公开实现(ooz/oozextract,retoc 内建)——不用自己逆。
真正缺的是 AES 密钥。所以".utoc 打不开"的真因是缺密钥,不是 Oodle 版本对不上。
2. 拿到 AES 密钥的过程
2.1 先踩了一个坑:坏的 oracle
早期我用 SHA1(索引) 当 oracle,去筛"哪个候选常量是密钥"——结果全不对,一度误判"密钥不是静态的"。
真相:IoStore 索引是 compress(Oodle) + encrypt,明文 SHA1 根本对不上 → 这个 oracle 会把正确的 key 筛掉。
换 oracle:用 retoc(内建 Oodle + 真正解密)来判定。
2.2 密钥本体
在 libUnreal.so 里找到 32 字节常量(文件偏移 0xcdbc40):
c09b98c5b96110ffe43ecb8d38422facd7ca967655412eed0e530304690f2f1c
2.3 关键:它不是"孤立常量",而是包在回调里
静态一眼看不出,因为它藏在 key-provider 回调里:
key-provider 回调 0x9a93d08:
adrp x8, #0xcdb000 ; add x8, x8, #0xc40 ; x8 = 0xcdbc40
ldp q1, q0, [x8] ; 读 32B
stp q1, q0, [x0] ; 写回输出 = 密钥
- 注册
0x26a26e8:构造 key-provider 对象,存进全局 keystore(0xe295cd8)。
- 触发
0x9aae9e0:在模块启动函数里 bl 0x26a26e8。
⚠️ 注意:不在 .init_array → 只跑构造器的模拟(如纯 Qiling)到不了这一步。
解密调用链:
sub_3AC7CA8(buf,size) ; pak/IoStore 索引解密包装
├ 有注册 crypto → crypto_arr[0]->vtable+0x60 解密
└ 无注册 → sub_3AC7E14(out32,guid) ; 从 keystore 取 32B
→ sub_264DA0C(key32,buf,size) = br [0xe1ec250] ; OpenSSL AES
2.4 验证(决定性)
retoc -a c09b98c5b96110ffe43ecb8d38422facd7ca967655412eed0e530304690f2f1c \
info pakchunk0-Android_ASTC.utoc
→ rc=0 container_id=c9842237aafca4e chunks=7915 packages=4854
flags=Compressed|Encrypted|Indexed compression=[Oodle]
密钥正确 → 读出 7915 chunks / 4854 packages。
2.5 解包
retoc -a <key> unpack pakchunk0-Android_ASTC.utoc assets/
retoc -a <key> unpack pakchunk1-Android_ASTC.utoc assets/
→ 31,880 个文件(27,300 .uasset + 4,500 .ubulk + 71 .umap + …)
3. 复盘:三条经验
- 别用错的 oracle 否定静态密钥 —— 压缩+加密的容器,SHA1/明文校验会误杀正确 key;要用真解包器当 oracle。
- 密钥常藏在"结构/回调"里 —— 包成 key-provider(靠
adrp+add 才引用常量),要顺"谁调用 → 存到哪 → 谁触发"。
- 触发点可能不在
.init_array —— 本例在模块启动函数里注册,纯跑构造器会漏。
4. 一句话总结
资源 = UE5 IoStore(Oodle 压缩 + AES-256 加密)。Oodle 有公开实现,不用逆;AES 密钥静态藏在 libUnreal.so 偏移 0xcdbc40,由 key-provider 回调 0x9a93d08 在模块启动时注册。用 retoc 当 oracle 验证后,一把解出 31,880 个资源文件。