hxv4中计算hash的算法解析与恶心人之处

逆向工程 算法hxv4
浏览数 - 109 发布于 -

重新编辑于 -

源码fork后

https://github.com/Kinotern/KrkrHxv4Hash

Gemini-3.1Pro研究


一、 基础准备:工具


盐值参数

在这个程序的开头,作者引入了必要的工具,并准备了哈希过程中的“妙妙参数”也就是盐值

C
#include <iostream>
#include <Windows.h>

const wchar_t* salt = L"xp3hnp"; // 盐值xp3hnp

想象你在做饭,如果只放相同的食材,做出来的味道总是一样的(很容易被别人猜出配方)。

在密码学中,“加盐”就是给原始数据(比如文件名)强行加上一段固定的字符串。

在这里,无论是处理什么文件,代码都会在文件名后面偷偷加上 xp3hnp 这个词。

这样可以防止别人用普通的破解库反推出原始文件名。


混淆器说明

说明书"魔数"(全程魔法数字)

javascript
static const uint32_t IV[8] = { ... }; // 初始向量
static const uint8_t sigma[10][16] = { ... }; // 混合顺序表

它使用了国际知名的哈希算法 BLAKE2s 的标准常数

你可以把它们理解为“榨汁机”出厂时自带的齿轮设定和说明书。

程序在打碎数据时,会严格按照这些数字规定的顺序来回搅拌。



打乱的过程

旋转与混合

无论是算文件名的指纹,还是算路径的指纹,本质都是把数据的二进制位(0和1)打乱重组

1. 移位操作(像是转动密码锁)

C
// 核心动作:把数字的二进制位向右循环移动
static inline uint32_t ror32(uint32_t value, uint32_t count) {
    return (value >> count) | (value << (32 - count));

}

假设有个数字是 123456,向右循环移动2位,

就会变成 561234。在计算机底层,这种操作极快,并且能极其有效地把数据打散。


2. G函数与SipRound:刀片组

C
// BLAKE2s 算法的搅拌刀片
void G(uint32_t v[16], int a, int b, int c, int d, uint32_t x, uint32_t y) {
    v[a] += v[b] + x;
    v[d] = ror32(v[d] ^ v[a], 16);
    // ...省略部分代码...
}

// SipHash 算法的搅拌刀片
void sipround(uint64_t* v0, uint64_t* v1, uint64_t* v2, uint64_t* v3) {
    *v0 += *v1;  *v1 = rol64(*v1, 13);  *v1 ^= *v0;  // ...
}

这里出现了两个著名的密码学函数,它们就是哈希算法的“刀片”:

  1. G 函数:属于 BLAKE2s 算法(用于文件名)。它通过“相加”、“异或(一种逻辑混合)”和“循环移位”,把输入的数据彻底绞碎。
  2. sipround 函数:属于 SipHash 算法(用于文件路径)。同样通过加法、移位和异或,实现极高强度的混合。

你不需要看懂刀片是怎么转的,只需要知道数据一经过这两个函数,原来的样子就绝对找不回来了。


二、 实战应用1:给“文件名”生成指纹

现在我们来看外部如何调用这个程序来处理文件名。 核心函数是 get_filename_hash

C
extern "C" __declspec(dllexport)
const uint8_t* get_filename_hash(const wchar_t* input_string2) {
    // 1. 准备一个预设好初始状态的“盆子”(outName),大小是48个字节。
    uint8_t outName[0x30] = { 0x47, 0xE6, 0x08... }; 
    
    // 2. 测量文件名和“盐”的长度,把它们拼接到一起。
    // 比如:原文件名是 "bgm.ogg",拼接后变成 "bgm.oggxp3hnp"
    size_t len_input = wcslen(input_string2);
    size_t len_salt = wcslen(salt);
    // ... 拼接操作 ...

    // 3. 把长长的名字分块(Chunk),一块一块地扔进榨汁机
    while (remaining_bytes > 0) {
        // ... 判断是不是最后一块(is_last_chunk)...
        
        // 4. 调用 FilenameHash (其实就是 BLAKE2s 算法) 进行搅拌
        FilenameHash(reinterpret_cast<unsigned int*>(outName), reinterpret_cast<unsigned char*>(buffer));
    }
    
    // 5. 返回最终生成的 32 字节指纹
    return outName32;
}

运行逻辑通俗版:

  1. 引擎把文件名(比如 pic.png)传进来。
  2. 代码把文件名加点盐,变成 pic.pngxp3hnp
  3. 如果这串字太长,程序会把它切成一小块一小块的(最多64字节一块)。
  4. 把每一块依次扔进 FilenameHash(里面包含刚才说的 G 函数刀片)里去搅拌。前一块搅出来的糊糊,会作为下一块的底料继续搅。
  5. 所有文字处理完后,端出一杯 32 字节大小的“数字指纹”返回给游戏引擎。

三、 实战应用2:给“文件路径”生成指纹

处理目录/路径(比如 data/system/)时

程序换了另一种算法(SipHash)

核心函数是 get_path_hash

C
extern "C" __declspec(dllexport)
uint64_t get_path_hash(const wchar_t* input) {
    uint8_t seed[16] = { 0 }; // 准备一个全为0的密钥

    // 1. 特殊情况处理:如果传入的仅仅是一个根目录 "/"
    if (wcslen(input) == 1 && input[0] == L'/') {
        // 只对“盐”(xp3hnp)进行哈希处理
        uint64_t hash = PathHash((unsigned char*)salt, wcslen(salt) * 2, seed);
        // 核心动作:把结果的字节顺序倒过来(翻转端序)
        std::reverse(p, p + 8); 
        return hash;
    }

    // 2. 常规情况:拼接路径和盐
    // 比如路径是 "data/bg",拼接后变成 "data/bgxp3hnp"
    // ... 拼接代码 ...

    // 3. 调用 PathHash (SipHash 算法) 进行生成
    uint64_t hash = PathHash((unsigned char*)input_string, wcslen(input_string) * 2, seed);

    // 4. 再次翻转字节顺序
    unsigned char* p = reinterpret_cast<unsigned char*>(&hash);
    std::reverse(p, p + 8);
    hash = *reinterpret_cast<uint64_t*>(p);
    
    return hash;
}

运行逻辑通俗版:

  1. 这个函数专门处理较长的文件路径。
  2. 它依然贯彻了“加盐”的传统(拼上 xp3hnp)。
  3. 使用 PathHash,把文字转换成一个 64位的数字(相对于文件名的指纹,这个稍微短一点)。
  4. 最巧妙的一步是 std::reverse:算出来的指纹,比如是 0x1122334455667788,程序会把它颠倒过来变成 0x8877665544332211。这主要是为了适配不同计算机硬件在内存中存储数据的习惯(术语叫大小端序转换,Endianness),确保游戏在不同设备上算出的结果一致。

它就是一个“双模榨汁系统”

当面对文件名时,开启 BLAKE2s 模式,产出 32 字节的长指纹。

当面对文件路径时,开启 SipHash 模式,产出 8 字节(64位)的短指纹,

并且最后把杯子倒扣(翻转字节顺序)


无论使用哪种模式,它都会在你要处理的食材里偷偷加上一勺名为 xp3hnp 的盐


通过这种方式,Kirikiri 引擎在打包和读取游戏资源时,不需要去比对又长又慢的字符串文本,只需要对比这串短小精悍的“指纹”数字,大大加快了游戏的运行和加载速度?我不这么认为


首先我们来看计算文件名哈希的代价:

C
// 每次查找文件,都要经历这个循环 10 次的魔鬼搅拌
for (int r = 0; r < 10; ++r) {
    G(v, 0, 4, 8,  12,  m[sigma[r][0]],  m[sigma[r][1]]);
    // ... 另外 7 次 G 函数调用 ...
}

CPU 密集型开销:BLAKE2s 包含大量的位移(ror32)、异或(^)和加法。

即使现代 CPU 算这些很快,但它依然需要消耗 CPU 周期。


内存拷贝与加盐:每次都要 new wchar_t 申请内存、把路径和盐(xp3hnp)拷过去

算完再 delete[] 释放。频繁在堆上分配/释放内存,在底层是非常昂贵的开销


如果游戏直接用原始字符串 data/bgimage/main_menu.png 作为 Key

用常规的 std::unordered_map(哈希表)来存,虽然也需要算一点简单的字符串哈希

但绝对不需要用到 BLAKE2s 和 SipHash 这种安全级别/高散列度的重型武器


既然这么重,它图什么?核心原因在于 Kirikiri 游戏引擎(尤其是 Hxv4 这个商业加密版本)的设计初衷,

不仅仅是为了“查找”,更是为了“反解压”和“防止目录被轻易窥探”


原因 1:极度追求索引表的“固定长度”与“内存紧凑性”

普通的汉字或英文路径,长度是不固定的(可能几个字节,也可能上百字节)。

如果在游戏启动时,把成千上万个文件的原始路径全部读进内存拉一个大表,会导致:

内存碎片化:大量变长字符串在内存中乱飞。

指针开销:每个字符串都需要额外的指针和长度计数器。


而通过这段代码:

  • 无论路径有多深(a/b/c/d/e/f/g/file.png),经过 get_path_hash 出来永远是

  • 固定 8 个字节(64位)的数字

    无论文件名多长,出来永远是固定的 32 字节

游戏引擎在加载 XP3 资源包时,会把整个目录索引(Index)读入内存

因为所有文件的 Key 都是固定长度的,引擎可以用极其紧凑的连续内存(比如一个结构体数组)来存索引

对 CPU 缓存(L1/L2 Cache)来说,连续的、固定大小的内存遍历和二分查找,

速度甚至可以超越复杂的变长字符串哈希表。

原因2:为了“盲找”(Blind Lookup)与反混淆

这是最核心的商业机密。 很多二次元游戏为了防止玩家(或汉化组)直接提取

提取立绘、文本,会把资源加密。


如果封包里存的是原始路径 script/main.ks,解包者一眼就能看出这是核心剧情脚本

但 Kirikiri Hxv4 选择了:封包的索引表里不存任何原始文件名,只存这串算出来的哈希值!

当游戏想要加载 script/main.ks 时:

  1. 游戏引擎在内存中,用这段代码把 script/main.ks 算成哈希。
  2. 引擎拿着这个哈希,去 XP3 封包的索引表里对暗号。
  3. 对上了,就直接根据偏移量(Offset)去读文件数据。

在这个过程中,封包里从头到尾都没有出现过“script/main.ks”这几个字

只有Tjs2100还有scn残留着这些文件的拼图线索


** **解包工具如果不知道“原始文件名”,面对那一堆哈希数字,根本不知道哪个文件对应的是什么,

从而极大地增加了逆向工程的难度。

3. 性能的“快”与“慢”,取决于参照物

我们来重新定义这个“快”:


单次计算慢:如果只对比单条路径,这段代码因为加盐、内存分配、SipHash/BLAKE2s 复杂的位运算,

绝对比单纯的比对字符串要慢


整体检索快:当游戏有 上万个资源文件 需要频繁加载时,引擎通过固定长度的哈希值,

在内存连续的、高度优化的 B-树 或 排序数组 中进行查找,省去了字符串逐字比对的昂贵开销,

并换取了极小的内存占用。


每次从tjs2100的字符串或者scn想要加载这个资源,当第一次加载时那么你就得从头算一次

有就从CACH内存表加载


但只要游戏进程一关,内存被系统回收,缓存表瞬间灰飞烟灭

又回到了第一次加载算一次去xp3里头拿出来展示


假设游戏里正在跑一段剧情,

如果每次遇到 storage="xxx" 都要进 DLL 算一遍哈希、加一遍盐、拼一遍内存,


CPU 就会在短时间内被高频的 new/delete 和位运算死死卡住,玩家就会明显感觉到掉帧或卡顿


为了解决这个问题,引擎采用了“一次计算,终身受益”“批量预处理”的策略



Textile
第一次加载 "bgm.ogg"
   ↓
[查找内存 Cache 表] -> 没找到!
   ↓
[调用 KrkrHxv4Hash.dll] -> 消耗 CPU 算出一串哈希
   ↓
[存入内存 Cache 表] (把 "bgm.ogg" 和 哈希值 绑定)
   ↓
去 XP3 封包里读数据

Textile
第二次加载 "bgm.ogg"
   ↓
[查找内存 Cache 表] -> 找到了!直接拿走哈希值!
   ↓
(完全跳过那堆复杂的哈希计算 DLL)

或许你该抽空看看你的temp路径

krkr_一堆数字的文件夹不清理,拉屎不冲一样的感觉,咦~

本文版权遵循 CC BY-NC 协议 本站版权政策

3 条回复

jqktkh
发布于

关于开头xp3hnp的来源,其实是来源于嵌入在可执行程序中的一段启动脚本STARTUP.TJS中定义的一个字符串:

JavaScript
var bootstrapParts = (string(bootstrapPrefix)).split(":");
        var mediaName = (bootstrapParts.count > 1) ? bootstrapParts[0] : "xp3hnp";

        // Constructor arguments:
        //   "arc"       : registered storage media name used by arc://./
        //   mediaName   : appended as extra string in pathHash/fileHash
        //   hashKeyOctet: System.bootStrap return value
        var compoundMedia = autoPathCallback._.fs =
            new Storages.CompoundStorageMedia("arc", mediaName, hashKeyOctet);

        // Zero-domain path hash. For Sanoba:
        //   pathHash("", extra="xp3hnp") = 94d4a97c61498621
        autoPathCallback._.zpath = compoundMedia.pathHash("");

只不过目前看到的所有游戏都使用了这个静态量,没有进行替换。

kinotern
发布于
回复 @jqktkh#1

关于开头`xp3hnp`的来源,其实是来源于嵌入在可执行程序中的一段启动脚本`STARTUP.TJS`中定义的一个字符串: ```JavaScript var bootstrapParts = (string(bootstrapPrefix)).split(":"); var me

为啥是默认的呢?

jqktkh
发布于
回复 @jqktkh#1

关于开头`xp3hnp`的来源,其实是来源于嵌入在可执行程序中的一段启动脚本`STARTUP.TJS`中定义的一个字符串: ```JavaScript var bootstrapParts = (string(bootstrapPrefix)).split(":"); var me

此外还有一个出生的地方是,scn脚本里的各种BG/CG/BGM,实际上都是没有扩展名的,程序取资源时还经过了TJS脚本中一个拼接扩展名的阶段,具体的方式其实就是......猜,挨个试对应类型可能的扩展名,然后拼上去看看能不能找到对应文件

回复 @kinotern#2

只是没改罢了,我估计也是厂商也懒得改......此外计算Path/File的hasher 都有 key_ptr / key_len预设,具体来说,会将目前使用的SipHash-2-4 和 BLAKE2s-256哈希变成keyed SipHashkeyed BLAKE2s-256。但是目前所有样本中key_len都是0,不然每个游戏使用的哈希计算方式也会产生区别了。

(。>︿<。) 已经一滴回复都不剩了哦~