当前位置:首页|资讯|编程|ChatGPT

【逆向工程】《白色相簿2》中的PAK文件解包

作者:AllenHeartcore发布时间:2024-01-31

文末附完整Python代码


工具

  • Ghidra 11.0 Public Version(逆向分析)

  • https://hexed.it(查看二进制文件)

  • Python(实现解包算法)

初步调查

《白色相簿2》的压缩包中,除了WA2.exe、Uninstall.exe和INSTALL.ini外,均为.pak资源文件。但根据“pak解包”搜索到的都是针对虚幻引擎的结果(扩展名相同),搜索“常见Galgame引擎”以及询问ChatGPT均无果。查看二进制文件,发现“KCAP”、“LAC0”、0x75B22630(均为mv文件)等三种文件头,都对应不上。故尝试逆向分析唯一的exe文件。(没有dll已经是很乐观的情况了233)

  • bak.pak(背景图)KCAP

  • BGM.PAK(背景音乐)LAC0

  • char.pak(立绘)KCAP

  • fnt.pak(字体)KCAP

  • grp.pak(CG及UI)KCAP

  • mv*.pak(过场动画)MV

  • script.pak(剧本)KCAP

  • SE.PAK(音效)LAC0

  • VOICE.PAK(语音)LAC0

“KCAP”文件

文件头的魔数写成Big-endian实为“PACK” = 0x5041434B。第一眼感觉是索引+数据区的经典结构,进一步发现索引区似乎有44字节一循环的规律。exe自动分析后,优先搜索含有“.pak”的字符串。以下的函数被数十个函数调用,判断为读取PACK文件的utility:

文件名经过wsprintfA的format后被传入FUN_00437100,顺藤摸瓜:

发现魔数!说明这是正确的道路。读取文件指针pvVar1,若文件头aiStack_10中的魔数匹配,则将SVar2与SVar9指向第iStack_4条索引piVar7,并将其读入pvVar4指向的进程堆中。若索引中pCStack_1c指向的文件名与query param_2匹配,则将文件指针pvStack_20指向第pCVar6条索引的0x24位置(数据区偏移量)。若索引0x00位置(*piVar7)的压缩标记为0,则将长度等于piVar7[10](索引0x28位置)的未压缩数据直接读入进程堆并返回给param_3;若压缩标记为1,则将截除前8字节的压缩数据传入FUN_00436df0,推测为解压缩算法。

至此真相大白,最关键的格式转换就在这一步。Galgame的脚本一般是随用随读的,因此这种打包方式使用了一种用压缩率换取性能的inplace替换算法。替换可读的变量名,整理后的算法如下:

这里32位整数loopvar的用法比较微妙。第一次for loop会因为不满足loopvar & 0x100 != 0的循环条件而直接跳出,在if (inlen < inlim)块中初始化再通过goto回到循环。loopvar的[31:16]位恒为0;[15:8]位恒为1,用于for loop的循环条件,故每次goto LOOP后循环块一定会执行恰好八次;[7:0]位来自*in读取的字节,推测是某种flag。

分析后发现,压缩算法的大致思想如下:压缩数据区的前8字节存储压缩后长度(inlim)和压缩前长度(outlim),正文部分每1个标识符(loopvar低8位)+8个“字节组”=一个小单元。标识符从低到高每位标记对应的“字节组”是否被压缩,本身不进入解压数据中;被标记为1的未压缩“字节组”长1字节,内容为数据本身,被标记为0的压缩“字节组”长2字节,内容仅指示如何从算法内部维护的array获取最近读取过的数据。

绕吗?绕就对了。来看一个例子,取自script.pak中的1002.txt,也即Introductory Chapter的第二幕。(顺便把解包单个文件的全流程演示一遍好了)

这是16字节的文件头,能发现0x5041434B的魔数和0x17B = 379的entry数,表示包内有379个文件。1002.txt是第四个文件,查看16 + 44 * (4 - 1) = 148字节开始的44字节entry:

能看到0x1的压缩标记,ASCII解码为“1002.txt”的文件名,0x939B = 37787的数据区偏移量,和0x43FB = 17403的数据区长度。只取前105字节做演示:

得知压缩后长度为0x43FB - 8 = 17395,压缩前长度为0x75EA = 30186。正文部分已经被切割成了小单元,进一步标出“字节组”:

注意flag是从低位到高位反向写的。接下来把“字节组”替换为最近读取过的数据:新数据从位置0xFEE开始填满长为0x1000 = 4096的buffer;“字节组”中有12位标记读取buffer的位置,另外4位标记读取的长度,具体见下方代码块:

如F6 F3表示从位置0xFF6开始读3 + 3 = 6位,应被替换为82 BF 82 E5 82 C1。(值得注意的是这里Ghidra出了个小bug,原先反汇编输出的是counter = (byte2 & 0xF) + 2,还排查了小半天,最后是直接把游戏脚本当成gold按字节比对发现的端倪233)

将所有“字节组”按这样的规则替换:

最后按Shift JIS解码:

解压缩成功!附上PACK文件的完整结构:

  • 文件头(16字节)

    • 4字节:魔数“KCAP” = 0x5041434B

    • 8字节:未知

    • 4字节:索引条数

  • 索引区(每条索引44字节)

    • 4字节:压缩标记(0为未压缩,1为压缩)

    • 24字节:文件名(以NUL结尾)

    • 8字节:未知

    • 4字节:数据区偏移量

    • 4字节:数据区长度N

  • 未压缩数据区(N字节)

    • N字节:未压缩数据

  • 压缩数据区(N字节)

    • 4字节:压缩后长度 = N

    • 4字节:压缩前长度

    • N-8字节:压缩数据


“LAC0”文件

有意思的是,所有魔数为“LAC0” = 0x0043414C的文件,扩展名都是大写的.PAK,推测为老版本/未经压缩的PACK格式。有了上一步解包的经验,不难扩展已知的规律:再次观察发现索引区似乎按40字节一循环,只是这次文件名的编码似乎既不是Shift JIS,也不是UTF-8。

进入数据区,发现熟悉的文件头:

确定是未压缩的音频数据(三个pak分别是BGM、特殊音效和剧情配音)。附上LAC0文件的完整结构:

  • 文件头(8字节)

    • 4字节:魔数“LAC0” = 0x0043414C

    • 4字节:索引条数

  • 索引区(每条索引40字节)

    • 32字节:文件名(以NUL结尾,编码未知

    • 4字节:数据区长度N

    • 4字节:数据区偏移量

  • 数据区(N字节)

    • N字节:未压缩数据

Sidenotes: 由于日语Shift JIS编码大量采用0x8200及邻近区的字符,加上日语假名多,导致替换压缩效果较好;背景、人物立绘与菜单栏采取.tga图片格式,也有大量冗余字节。但这种压缩算法无法外推到音频。


“MV”文件

所有的视频文件中都能发现以下的明文元数据:

搜索发现是已经过时的视频编码器;与此同时,Ghidra逆向到一半发现大量通过函数指针实现的API调用:

判定为deadend;况且1.2M的exe不可能塞得下video encoder。于是大胆尝试ffmpeg转码,成功!

同时发现:mv后的前两位数字(00~02、07~09、10~14、20~24)表示场次,与剧本编号对应;第三位数字表示清晰度,0表示1280x720,1表示704x480。


结论:完整解包代码(Python)



Copyright © 2024 aigcdaily.cn  北京智识时代科技有限公司  版权所有  京ICP备2023006237号-1