从hca解码表到cri key——一个略为奇怪的反向算法研究

这篇文章的研究,一般来说是没有可能碰到的,所以也就只是记录一下。

需要解决的问题是:假如我的手上有两份最终波形完全相同的hca,但是其中一个是ciph 0(无加密),另一个是ciph 56(密钥加密),如何处理这个ciph 56的版本呢?


首先,这个研究的起源就是chunithm。21年的大型leak之后,chuni new最终还是给自己的cri套件加上了密码。毕竟exe加了壳,短时间是没办法从源码入手了。不过这就有了一个从未遇到的情况——手上同时有了同一个波形的加密与未加密版本。

先说结论:搞出decrypt table很好办,从table到key也完全可行,只不过第二步的逻辑比较绕,对于我这么个算法苦手来说,试错了很多次才终于出来了结果。

文中的逻辑都对照于libcgss CHcaCipher部分的代码,可以对照着key到table的正向逻辑来阅读。


1. 提取decrypt table

虽然解hca音频已经用了很久,但是其中具体逻辑从来没有仔细去研究过。在读代码外加问了hozuki之下,在初期大概搞懂了原理:输入的cri key经过一系列变换之后会得到一个table,而hca加密的过程就是按照这个table进行字节替换。至于未加密的ciph 0则是直接使用线性对应的替换表,即 [0, 1, 2, … , 0xff] 。额外的,即使是ciph56的表,其中的0和0xff也是不进行替换直接线性对应的。因此,打开两个hca,如果看到header、0x00和0xff在两个文件中都是一样的位置的话,基本就可以确定这两个是相同波形的不同加密表了。

搞明白hca加密的逻辑,提出一个解码表也就不难了。不过如果暴力直接对hca波形部分匹配替换表的话,有可能会出小错误导致某几位的表是错误值(我确实碰到了)。因为hca本身是按照chunk分割的,chunk大小在hca header的comp块里面写着,如果没写默认是0x200,不过也有其他的值,比如0x155之类的。每个chunk最后两字节是这个chunk的crc,计算的过程和hca header的crc是一样的,找替换表的时候把每个chunk结尾两个字节跳过就可以了。

到这一步就已经可以自己写一个解码器了,自己进行ciph 56的最后一步,把hca波形全部替换成ciph 0的未加密数值,然后就可以用来解出wav用了。

2. 细读算法

从下往上读 CHcaCipher::Init56 函数,先看最后一个块:

// Generate CIPH table
t = &_decryptTable[1];
for (uint32_t i = 0, v = 0; i < TableSize; i++) {
    v = (v + 0x11u) & 0xFFu;
    uint8_t a = t3[v];
    if (a != 0 && a != 0xFFu) {
        *(t++) = a;
    }
}
_decryptTable[0] = 0;
_decryptTable[0xFF] = 0xFF;

最终的解密表的数值是从t3中“乱序”挑选出来的,索引v每次加0x11。这个部分目前没有太大的价值,继续往上看

// Generate table #3
uint8_t t3[0x100], t31[0x10], t32[0x10], *t = t3;
Init56_CreateTable(t31, t1[0]);
for (uint8_t i = 0; i < 0x10; i++) {
    Init56_CreateTable(t32, t2[i]);
    uint8_t v = t31[i] << 4u;
    for (uint8_t j = 0; j < 0x10u; j++) {
        *(t++) = v | t32[j];
    }
}

这部分是t3表的生成,每个值分了两个部分,其中高4位是来自t31,低4位来自t32。这里可以发现,对于同样的高4位索引来说,最终t3值的高4位也是相同的,即0x0~0xf都是0xa_,0x30~0x3f都是0xb_。

这时候我们再转过头去观察一下解密表

观察一下hex表示,可以发现右上左下的斜线上,除了偶尔的错位外,每个值的高位基本是一致的。这就是因为第一段里面0x11的差值了。排掉开头的0x00,一般来说dec表的索引就是0x11, 0x22, 0x33, … , 0xff, 0x10,所以按照顺序记录下高4位数值的出现顺序,基本上就拿到了t31表的内容。

但是不要急着进行下一步,首先要注意一个点,在t31 t32计算t3的时候,如果遇到了0x00和0xff是需要跳过的,这样就会造成不连续。而观察第一行结尾的顺序是0xe 0x5 0xf,而第二行则是0xe 0x5 0x0 0xf,所以这里我们正好在开头就碰到了0x00跳过的情况。按照数值首次出现的顺序直接后续计算的话结果会严重对不上。那么这个要如何处理呢?

回到第二段,看一下 Init56_CreateTable的逻辑:

void CHcaCipher::Init56_CreateTable(uint8_t *r, uint8_t key) {
    uint32_t mul = ((key & 1u) << 3u) | 5u;
    uint32_t add = (key & 0xEu) | 1u;
    key >>= 4u;
    for (uint32_t i = 0; i < 0x10; i++) {
        key = (uint8_t)((key * mul + add) & 0xFu);
        *(r++) = key;
    }
}

这个算法是输入一个key,输出16个“随机”数列,并且他们刚好不重复。数学上为什么会这样我就不研究了,反正讲不通。这里mul取值只能是5或13,而add值则是1 3 5 7 9 11 13 15,假如有数列中的一部分,就完全可以直接穷举验证,推出mul和add的值了。

先前按照出现顺序推出的t31是 [9, 4, 3, 6, 13, 8, 7, 10, 1, 12, 11, 14, 5, 15, 0, 2],取这个数列的0~3,3~6,6~9分别推算出三组mul和add,可以发现这组数列的mul和add是13和15。

CreateTable算法的另一个神奇之处就是,给定mul和add,数列的顺序是固定的,随便给一个值循环16次计算就能得到完整的顺序。这样最终算出来的数列是 [9, 4, 3, 6, 13, 8, 7, 10, 1, 12, 11, 14, 5, 0, 15, 2] ,可以看到0x0和0xf正好反了,因为第一次遇到0x00被跳过了。

得到的这个数列开头的所以实际上是0x11,所以需要将结尾的2提到最开头,这样才是真正的t31。

这里需要注意,移动了一个数字之后,这里结尾是0xf了,所以其实有一个可能,就是一开头的v=0x11时遇到0xff,实际从v=0x22开始。不过这次并不是这样的情况,如果后续计算出来出错了的话,可以再多移动一次。

得到了t31的正确顺序,首先可以推算出用来计算t31表的 t1[0] 值了。CreateTable输入的key低4位可以从mul和add得到,高4位则是数列最后一个值。

接下来,参照t31的顺序,从dec table开头开始模拟挑选t3,如果遇到一个地方索引高4位和t31不匹配,并且t31对应位置是0x0或者0xf,就是遇到了0x00和0xff。如此遍历完就能找齐16组t32。

到这一步可以进行一次验算,从这个t31和16个t32计算出t3和最终的dec table,确认和之前是不是相同,如果相同就说明得出的数列没有问题。

16个t32数列倒推出mul和add,然后计算出key值,就能得出t2的值,t2则是由t1计算出来的:

// Generate table #2
uint8_t t2[0x10] = {
    t1[1], (uint8_t)(t1[1] ^ t1[6]),
    (uint8_t)(t1[2] ^ t1[3]), t1[2],
    (uint8_t)(t1[2] ^ t1[1]), (uint8_t)(t1[3] ^ t1[4]),
    t1[3], (uint8_t)(t1[3] ^ t1[2]),
    (uint8_t)(t1[4] ^ t1[5]), t1[4],
    (uint8_t)(t1[4] ^ t1[3]), (uint8_t)(t1[5] ^ t1[6]),
    t1[5], (uint8_t)(t1[5] ^ t1[4]),
    (uint8_t)(t1[6] ^ t1[1]), t1[6],
};

t2的0 3 6 9 12 15分别是t1的1 2 3 4 5 6,这里可以再验算一下,从t1计算完整的t2是否一致。

// Generate table #1
uint8_t t1[8];
if (!key1) {
    key2--;
}
key1--;
for (uint8_t i = 0; i < 7; i++) {
    t1[i] = (uint8_t)key1;
    key1 = (key1 >> 8u) | (key2 << 24u);
    key2 >>= 8u;
}

而后便是最终一步,从t1计算原key。这个逻辑看起来有些吓人,但实际上正向的逻辑就是输入的uint64减去1之后,按照小端顺序存成uint8。另外,平常常用的hca key格式是拆成两个uint32,key1是低32位,key2是高32位。


 

4 thoughts on “从hca解码表到cri key——一个略为奇怪的反向算法研究”

  1. 您好,在BiliPlus的帮助栏尾页有您的相关链接,请问您有没有站长的联系方式?目前在tucao网上的投稿视频已经无法下载了,是站点不支持下载了吗?

  2. 能否借地问一下蔚蓝档案的检测是直接对比文件MD5还是别的什么东西?
    目前测试虽然so和metadata并没有加密而且ida能正常跑,但是如果你修改任何东西放回去都会造成还没进游戏就提示检测到app异常强制退出了。我把原文件放回去就没事了,已确定不是root问题

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax