几年 AI 迅速发展,使得逆向分析门槛大幅下降。若依赖 AI 反而会觉得缺少乐趣,亦或是和 AI 扯皮半天,AI 还睁眼说瞎话,你纠正错误到面红耳赤,它反手给你甩个 “you reached rate limit” 强行结束。逆向作为茶余饭后之娱乐活动,就和我们喜欢手动挡一样,完全由自己掌控的心流令人欲罢不能。
本文将不依赖 AI,依旧以传统的逆向方法和技巧来呈现。
0x0 background
近些年,wamsoft 魔改的 krkrz 引入了 hxv4 解密方案,最大的区别是:封包只存储文件哈希值,不存储文件名。游戏脚本(通常为 *.scn)内资源文件以原始文件名存储,引擎运行时计算得到哈希值,从而找到封包内对应文件。
由于哈希函数不可逆,这使得要想得到文件名变得非常麻烦(要么你得跑一遍游戏所有分支剧情 dump,要么干脆不要文件名了)。
目前主要有两种方案:
- 运行时 dump(krkrdump)
- 扫描脚本构建字符串碰撞,得到哈希值与文件名映射(KrkrExtractForCxdecV2 + krkr_hxv4_dumphash)
本文将以 dc5ph 为例分析 hxv4 的哈希函数,以及如何还原对应的算法和数据结构。
0x1 krkrz、hxv4
直接分析 hxv4 是非常困难的,可以通过原版 krkrz 了解引擎大概加载流程,再针对性寻找。原版 Stream 流程如下:
tTVPPlugin -> TVPCreateIStream -> _TVPCreateStream
-> tTVPArchive::CreateStream -> TVPStorageMediaManager.Open
-> tTVPXP3Archive::CreateStreamByIndex -> Read
关于 hxv4 可以参考 hxcrypt。Hxv4 entry 内容是加密的,先解密这个 entry,之后得到 filter key,再用旧版 cxdec 方法解密各个文件 entry。大致如下:
// decrypt hxv4 index
Xp3Stream::TryOpen -> HxCrypt::ReadIndex -> HxChachaDecryptor::Decrypt
// decrypt file content
HxFilter::Decrypt -> HxFilterSpan::DecryptHeader
解密相关参数示例如下(获取方式可用我写的 firda 脚本 krkr_hxv4_dumpkey):
control_block.bin // 4096 bytes
hxpoint at 0x5b18f0c3
cxpoint at 0x5b183c6d
* key : b338a06fc12ba33610e7e4428c8389ca0342b418ae6a77e5287e3607e41fe65b
* nonce : ec668fc7eff5f388612eb56f1e6d4d6f
* filterkey : 4eef61df5f2e1771
* mask : 0x273
* offset : 0x178
* randtype : 1
* order : 04 00 02 03 06 01 07 05 04 05 00 01 03 02 00 02 01
* PrologOrder (garbro) : 0, 2, 1
* OddBranchOrder (garbro) : 3, 4, 0, 1, 2, 5
* EvenBranchOrder (garbro) : 2, 6, 3, 1, 0, 4, 5, 7
0x2 program flow
分析切入点:结合 krkrz 源码 + 虚函数 RTTI。找到关键函数 v2link、tTVPXP3ArchiveStream,定位到 Read 函数即可在运行时动态 dump 文件。
示例(RTTI/vftable):
.rdata:00728520 ; class tTVPXP3ArchiveStream: TJS::tTJSBinaryStream; (#classinformer)
.rdata:00728520 dd offset ??_R4tTVPXP3ArchiveStream@@6B@ ; RTTI locator
.rdata:00728524 ; vftable
.rdata:00728524 dd offset tTVPXP3ArchiveStream__Seek_437230
.rdata:00728528 dd offset tTVPXP3ArchiveStream__Read_4372E0
.rdata:00728534 dd offset tTVPXP3ArchiveStream__GetSize_437480
.rdata:00728538 dd offset tTVPXP3ArchiveStream__deconstruct_436E60
如果没有 RTTI,也可以走“函数特征码定位”路线:编译一份同版本/相近版本的 krkrz,对照生成代码特征,再在目标里搜相同指纹。
TVPCreateStream 的函数签名示例:
.text:0040EDB0 ; signature: 55 8b ec 6a ff 68 ? ? ? ? 64 a1 ? ? ? ? ...
.text:0040EDB0 ; void *__fastcall TVPCreateStream(void *name, uint32_t flags)
继续跟到 TVPStorageMediaManager::Open,即可一路摸到 hxv4 相关逻辑。hxv4 不同于传统 filter:它在 StorageMediaManager 很早就接管了 stream。
调试可知:hxv4 的 dll 藏在 exe 资源中。hook LoadLibraryW 可以发现它会在 C 盘生成类似 krkr_xxx/yyy.dll,hxv4 的文件解密、哈希函数都在里面。
哈希切入点(RTTI/vtable):
.rdata:100819A8 ; DefaultCompoundHasher<FileNameHashTrait>
.rdata:100819AC ; vftable
.rdata:100819B0 dd offset FileHashCompute_10016900
.rdata:1008199C ; DefaultCompoundHasher<PathNameHashTrait>
.rdata:100819A4 dd offset DirHashCompute_100169F0
接口/对象布局可抽象为:
typedef tjs_int(__fastcall *FuncHxv4CalcHash)(
Hxv4CompoundHasher* _this, void* _edx,
OUT tTJSVariant* hash,
const tTJSString* str,
const tTJSString* seed
);
typedef struct Hxv4CompoundHasher {
struct {
void* destruct;
FuncHxv4CalcHash calc;
} *vftable;
tjs_uint8* salt;
tjs_int saltsize;
} Hxv4CompoundHasher;
typedef struct Hxv4DirHasher {
Hxv4CompoundHasher base;
tjs_uint8 saltdata[0x10];
} Hxv4DirHasher;
typedef struct Hxv4FileHasher {
Hxv4CompoundHasher base;
tjs_uint8 saltdata[0x20];
} Hxv4FileHasher;
typedef struct Hxv4CompoundStorageMedia {
void* vftable;
int nref;
uint32_t reserve1;
tTJSString prefix;
tTJSString seed; // offset 0x10
CRITICAL_SECTION critical_section;
uint8_t reserve2[0x20];
tTJSString* start;
tTJSString* pos;
tTJSString* end;
Hxv4DirHasher* dirhasher; // offset 0x58
Hxv4FileHasher* filehasher;
} Hxv4CompoundStorageMedia;
至此就能在运行时直接调用:
Hxv4CompoundHasher::vftable->calc- 计算任意字符串 hash
详见 krkr_hxv4_dumphash。
0x3 hash function
动态 dump hash 后,下一步是静态复现。
最笨的方法是直接把 C 伪代码/汇编搬出来逐行模拟(甚至用 unicorn 跑),但费时费力,也容易错。因此去年停在“动态调用”这一步。最近重新整理,发现可以从算法特征切入,把问题变成“它是哪种现有算法的改版”。
从开发者角度,大部分不会自己研制全新哈希,更多是基于成熟算法改参数/流程。哈希通常遵循:
init(key, salt) -> update(buf, lastvalue) -> final(outsize)
hxv4 有两种哈希:
- 文件名 hash(输出 32 字节)
- 文件夹 hash(输出 8 字节)
file hash
流程大致如下:
filehash_init -> filehash_update -> filehash_final32
对 !scnlist.txt(UTF-16LE)+ seed(如 xp3hnp)动态调用得到:
C1F625E3A4BB508E082A52A8B032F4B3D2F34FF7FB3A30502574717DE6579126
关键点:在 init 里能搜到 6A09E667h 这类常量,这是 SHA-256 的 IV 表,BLAKE2s 也复用这套常量,于是大胆假设:file hash 基于 BLAKE2s。
进一步对比结构体布局(和原版略有差异,buffer/pos 等位置不同),但整体 compress 里大量 ROTR32/G 操作能对上。
最后验证:游戏没大改,salt 为空,直接用标准 BLAKE2s,加上 UTF-16LE 输入即可复现:
from hashlib import blake2s
h = blake2s(digest_size=32)
h.update("!scnlist.txt".encode("utf-16le"))
h.update("xp3hnp".encode("utf-16le"))
print(h.hexdigest())
# c1f625e3a4bb508e082a52a8b032f4b3d2f34ff7fb3a30502574717de6579126
dir hash
目录 hash 输出 8 字节。
动态计算示例:ED 得到:
FEF68C92D344F4F6
注意到 init 中有一串非常诡异的常量字符串:
uespemosmodnarodarenegylsetybdet
对应立即数 0x736F6D6570736575 一类常量,很典型:SipHash。
最终验证:目录 hash 使用标准 siphash_2_4。
import siphash
h = siphash.SipHash_2_4(b"\x00" * 16)
h.update("ED".encode("utf-16le"))
h.update("xp3hnp".encode("utf-16le"))
print(h.hexdigest())
# FEF68C92D344F4F6
epilogue
好久没写逆向分析文章了。目前看除了我开源的 krkr_hxv4_dumphash,没有公开资料去具体分析这个臭名昭著的 hxv4 哈希函数,故写此文。
写逆向游戏分析的文章没有想象中那么容易,一写就是几个小时。因为分析游戏大多时间间隔很长,有时候卡住了往往要过几天才突然有灵感;有些关键点可能突然想到了,或者排查了半天刚好找到。整理并回顾这些过程也花了些时间。
这些突破点往往不容易在文章里准确表达,而且逆向本身也有很多繁琐流程,面面俱到都写进去反而冗长、主线不清晰。因此本文以分析哈希函数为主,其他部分仅保留关键流程和数据结构,略去繁琐调试过程,希望可以抛砖引玉,享受逆向抽丝剥茧的乐趣。
希望对大家有所帮助!
