#推荐
Galgame汉化中的逆向哈希算法分析:以 krkrz_hxv4 为例

2026-02-02 13,606

几年 AI 迅速发展,使得逆向分析门槛大幅下降。若依赖 AI 反而会觉得缺少乐趣,亦或是和 AI 扯皮半天,AI 还睁眼说瞎话,你纠正错误到面红耳赤,它反手给你甩个 “you reached rate limit” 强行结束。逆向作为茶余饭后之娱乐活动,就和我们喜欢手动挡一样,完全由自己掌控的心流令人欲罢不能。

本文将不依赖 AI,依旧以传统的逆向方法和技巧来呈现。

Galgame汉化中的逆向哈希算法分析:以 krkrz_hxv4 为例

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 哈希函数,故写此文。

写逆向游戏分析的文章没有想象中那么容易,一写就是几个小时。因为分析游戏大多时间间隔很长,有时候卡住了往往要过几天才突然有灵感;有些关键点可能突然想到了,或者排查了半天刚好找到。整理并回顾这些过程也花了些时间。

这些突破点往往不容易在文章里准确表达,而且逆向本身也有很多繁琐流程,面面俱到都写进去反而冗长、主线不清晰。因此本文以分析哈希函数为主,其他部分仅保留关键流程和数据结构,略去繁琐调试过程,希望可以抛砖引玉,享受逆向抽丝剥茧的乐趣。

希望对大家有所帮助!

收藏 打赏

感谢您的支持,我会继续努力的!

打开USDT(trc-20)扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

Ts:本站所有内容均为互联网收集整理和网友上传。仅限于学习研究,请必须在24小时内删除。否则由此引发的法律纠纷及连带责任本站概不承担。

如侵犯到您的合法权益,请联系我们删除侵权资源!

韩仔技术 自学开发 Galgame汉化中的逆向哈希算法分析:以 krkrz_hxv4 为例 https://www.hanzijs.com/zixue/8241.html

相关文章

发表评论
暂无评论