0%

某上古 PS2 游戏逆向(四)文件格式分析与逆向

文件结构分析

前文 中通过简单地观察 .HD.BIN 的对应关系以及大量的 \xFF 分隔符,实现了文件的初步提取,但是 NORMALSCENE_IDSCENEDATSYSTEMVOICE_ID 六个 .BIN 中,我们仅成功恢复了明文保存的 SYSTEM 以及 ADPCM 的 VOICE_ID,其余 4 个文件格式仍然未知。

我们首先从文件较小的 NORMAL 开始分析。多分析几个文件可以发现比较明显的特征:首先文件开头 4 个字节是一个 int 数字,根据分析 .HD 中的经验,推测可能是文件大小,但是这个数字总是比我们提取出的文件的文件大小要大。接着 4 个字节取值为 1、2、3、4、5、6、7、8、9、10,推测是一个标识位,之后一个字节是非 ASCII 字节。从第 10 个字节开始是文件的正文。

在第 9 个文件中发现了大量明文,看起来是一个结构类似 ini 的配置文件,而且根据内容判断是 SYSTEM 中每个 nut 文件的名字,但是其中仍然包含了不属于明文的字节。

NORMAL.BIN 的第 9 个文件

同样在 NORMAL 的第 13 个文件中也发现了应该保存 NORMAL 中的文件名的文件,那么首先可以确定这两个文件一定是以明文保存的文件。

NORMAL.BIN 的第 13 个文件

分析文件内容可以发现,大部分非 ASCII 字节连续序列以 \xFF 结尾,并且结合字节序列前后文本可以推测该文件被某种加密算法压缩,以及文件开头 4 个字节的 int 是文件压缩前大小。

将各种已知压缩算法均尝试了一遍以后,发现这是游戏作者自己实现的压缩技术。懒得逆程序,Google 了一下,发现有人也在逆向这个游戏,但是同样被这个 16 年前的压缩技术所困惑。帖子 里有两个老哥通过逆向游戏主程序,找到了解压缩函数,并且提取出了算法。

首先第一个老哥 Raccoon Sam 给出了自己的理解:

Hey there!
Bad news – I really can’t wrap my head around it, I’m afraid :C

I’ve got samples for a compressed TIM2 file (“paktim” is just my nomenclature) extracted from the SCENEDAT and a legitimate, uncompressed counterpart TIM2 for it extracted from your save state. You can download them > here.

The first eight bytes in the paktim are a header; 40 64 04 00 01 00 00 00 which should be read as two words, 00046440 00000001 which in turn just define the uncompressed size (0x46440 or 287808 bytes, which unsurprisingly is the file size of the legitimate TIM2 file) and the amount of files in the entry (1, which > unsurprisingly is the … uh, amount of files)

After that, you read a byte as the variable instruction.
Logical AND instruction with 0x1F and add 2 to it to get the variable amount.
Shift instruction right five times to get the variable method.

According to the method and amount, read bytes from the bytestream:
Method 7 is just “read amount bytes and write them to output”.
Method 6 is “read 1 byte as surrogate and then repeat that byte exactly amount times to the output”.
Method 0 is “repeat FF exactly amount times to the output”.

The other methods are…. yeah, I dunno. Some methods look like they make sense but then the same method used later in the file does something completely different. I really can’t figure out what the trick here > is. Really hope someone else takes a look at this, it’s driving me nuts!!

文件开头 4 个字节的确是未压缩大小,之后 4 个字节帖子里的老哥认为是文件数量(不过这个并不是文件数量),之后读取 1 个字节作为 instruction,并进行 2 个运算得到 2 个变量:

  1. instruction & 0x1f + 2 可以得到 amount 变量。
  2. instruction >> 5 可以得到 method 变量。

如果 method 是 7,则读取接下来 amount 个字节作为输出。

如果 method 是 6,则读取下一个字节并且重复 amount 次作为输出。

如果 method 是 0,则重复 \xff 字节 amount 次作为输出。

不过这哥们道行还是有点浅,并没有逆完整,所以这个并不是最终的解压算法,就在这哥们放弃以后,有一个老哥 rufaswan 觉得很有意思,并且接着逆出了“看似“完整的解压算法:

I can see why this game trip you up. I don’t think I saw this kind of compression algorithm before. But I have a good understanding of it now.

Let’s use an example for a start, you want to duplicate 0xC9 for 0x40 times. First you use method 6 for 0x1f+2 bytes, so you’ll get “DF C9”. For the remaining 0x1f bytes, you repeat the last command, or method 0, but with length 0x1d+2 instead, so you’ll get “1D”.

Hence, the compressed data to duplicate 0xC9 for 0x40 times is “DF C9 1D”.

And the game keep the last 6 commands as history, or method 0 to 5.

首先文件开始的 8 个字节就像上一个老哥逆的一样,是文件大小和文件数量(实际并不是文件数量),之后读取一个字节 BYTE,计算出 2 个变量:

  1. BYTE & 0x1f 可以得到 LEN 变量
  2. BYTE >> 5 可以得到 METHOD 变量

如果 METHOD 是 7,则读取接下来 LEN + 1 个字节作为输出。

如果 METHOD 是 6,则读取下一个字节并且重复 LEN + 2 次作为输出。

在上述操作完成后,将其作为 current 放入 HISTORY 队列末尾:

1
2
3
4
5
6
HISTORY[5] = HISTORY[4]
HISTORY[4] = HISTORY[3]
HISTORY[3] = HISTORY[2]
HISTORY[2] = HISTORY[1]
HISTORY[1] = HISTORY[0]
HISTORY[0] = current

如果 METHOD[0,5] 之间,则作为 current 并且重新整理在HISTORYMETHOD 之前的所有元素,例如当 METHOD 是 3 时,则:

1
2
3
4
5
6
7
HISTORY[5] // do nothing
HISTORY[4] // do nothing
current = HISTORY[3]
HISTORY[3] = HISTORY[2] // re-queue
HISTORY[2] = HISTORY[1] // re-queue
HISTORY[1] = HISTORY[0] // re-queue
HISTORY[0] = current // re-queue

如果连续的 METHOD 是 6,则重复下一个字节 LEN + 2 次作为输出。

如果连续的 METHOD 是 7,则从 LEN 中提取 2 个变量:

  1. LEN & 3 得到变量 SUB_LEN
  2. LEN >> 2 得到变量 SUB_POS

之后从 SUB_POS 开始读取 SUB_LEN + 1 个字节作为输出。

最后这个老哥给了他对应的 PHP 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
function update_dict( &$dict, $lv, &$e )
{
if ($lv >= 5 ) $dict[5] = $dict[4]; // -69c0
if ($lv >= 4 ) $dict[4] = $dict[3]; // -69c4
if ($lv >= 3 ) $dict[3] = $dict[2]; // -69c8
if ($lv >= 2 ) $dict[2] = $dict[1]; // -69cc
if ($lv >= 1 ) $dict[1] = $dict[0]; // -69d0
$dict[0] = $e; // -69d4
return;
}

function zero_decode( &$file, $siz )
{
$dec = '';
trace("== begin sub_116048\n");

$dict = array(
array(6, ZERO),
array(6, ZERO),
array(6, ZERO),
array(6, ZERO),
array(6, ZERO),
array(6, ZERO),
);
$st = 8;
while ($siz > 0 )
{
$by = ord($file[$st] );
$st++;
$cmd = $by >> 5;
$len = $by & 0x1f;

switch ($cmd )
{
case 6: // c0
$b = $file[$st];
$st++;
$e = array($cmd, $b);

update_dict($dict, $cmd, $e);
$dec .= str_repeat($b, $len + 2);
$siz -= ($len + 2);
break;
case 7: // e0
$b = substr($file, $st, $len + 1);
$st += ($len + 1);
$e = array($cmd, $b);

update_dict($dict, $cmd, $e);
$dec .= $b;
$siz -= ($len + 1);
break;
default:
$ref = $dict[$cmd];
update_dict($dict, $cmd, $ref);
switch ($ref[0] )
{
case 6: // c0
$dec .= str_repeat($ref[1], $len + 2);
$siz -= ($len + 2);
break;
case 7: // e0
$dp = $len >> 2;
$dl = $len & 3;
$b = substr($ref[1], $dp, $dl + 1);

$dec .= $b;
$siz -= ($dl + 1);
break;
default:
goto done;
} // switch ($ref[0] )
break;
} // switch ($cmd)
} // while ($siz > 0)

done:
trace("== end sub_116048\n");
$file = $dec;
return;
}

这个老哥解释的比较抽象,实际上这是一个分块压缩算法,用一句话总结:解压缩时,对于每个 chunk 的第一个字节,低 5 位保存了长度,高 3 位保存了压缩方法,之后利用一个队列实现流式的 chunk 解压缩。

用这个老哥提供的代码写了个对应的 Python 版本进行解压缩,前文中提到 NORMAL 中第 9 个和第 13 个文件都成功解压缩,并且解压出了部分 TIM2 图片和 BMP 图片,其中 BMP 图片很明显是一个码表。

解压出的码表

不过很可惜,这老哥逆的也不完整,按照这老哥的解压算法,仍然有部分文件解出来仍然是一个未知格式的文件,但是根据前面提到的 第 13 个文件包含了 NORMAL 中的文件名,在第 10~12 这三个文件中理论上应该保存的也是明文,但是解压出来的却是乱码。

理论上应该解压出明文的文件

例如第 10 个文件 SCENE_ID.LST 解压出的效果如下,很明显不可能是文件名列表。

SCENE_ID.LST 解压出的乱码

通过 review 老哥的算法可以发现,从始至终都没有使用过文件开头的第 5~8 个字节,也就是前文中所谓的”文件数量“,这 4 个字节如果没有任何作用的话也就不会被塞在文件开头并且占用 4 个字节的空间,对于寸土寸金的 PS2,4 个字节也是挺奢侈的。

实际上通过观察解压缩失败与成功的文件,可以很容易得出结论:当第 5~8 个字节是 1 时,才会解压缩成功,否则解压缩出的就是乱码。至此我们推测,这个数字要么是表示压缩算法的种类,要么作为一个常量参与解压缩运算中,只不过当其取值为 1 时,这老哥的解压缩算法刚好正确。总之这老哥逆向逆一半,最后还得自己亲自逆,好在这老哥在代码中提供了解压缩函数的地址 sub_116048,自己动手,丰衣足食。

MIPS 逆向准备

前文 中脚本语言分析完成后,还有存在很多没有定义的函数,那么剩下来的只有游戏主程序 SLPS_258.97 中可能存在这些函数的定义以及实现。好在 Ghidra 提供了 MIPS 反编译能力。虽然 PS2 的 CPU 5900l Emotion Engine 使用了 32 位的 MIPS-III,但是却是自定义的精简 MIPS 指令集,类似 microMIPS,在 MIPS16e 的基础上增删了一些指令。


Srpopty: Does the PlayStation2 use MIPS16e?

Bard: No, the PlayStation 2 does not use MIPS16e. It uses a custom RISC processor called the Emotion Engine (EE). The EE is based on the MIPS III architecture, but it includes a number of extensions that are not found in MIPS16e. For example, the EE has a floating-point unit, which is not present in MIPS16e. The EE also has a number of instructions that are specifically designed for multimedia applications.

The PlayStation 2 was released in 2000, and at the time, MIPS16e was not a widely used architecture. MIPS16e was designed for embedded systems, and it was not considered to be powerful enough for high-end gaming consoles. The EE was a more powerful architecture that was better suited for the PlayStation 2’s target market.

Here are some additional details about the Emotion Engine:

The Emotion Engine is a 32-bit RISC processor.
The EE has a clock speed of 300 MHz.
The EE has a 2 MB L2 cache.
The EE has a floating-point unit.
The EE has a number of instructions that are specifically designed for multimedia applications.
The Emotion Engine was a powerful processor for its time, and it helped to make the PlayStation 2 one of the most successful gaming consoles of all time.


因此直接用 Ghidra 内置的 MIPS 处理器无法完全识别所有指令,因此需要安装额外的 Ghidra 扩展 ghidra-emotionengine 或者 ghidra-emotionengine-reloaded,根据 Ghidra 的版本安装不同的 Release,其中 ghidra-emotionengine 仅支持到 9.3,之后的 Ghidra 版本需要使用 ghidra-emotionengine-reloaded 提供的 Relase。

下载好的 Ghidra 扩展是一个 zip 压缩包,将解压后的文件夹移动到 ~/.ghidra/.ghidra_xx.xx_PUBLIC/Extensions/ 目录下,之后在 GUI 的 Project 界面的 File 下就可以加载扩展

Ghidra 开启反汇编 Emotion Engine 指令插件

之后重启 Ghidra 后就可以用扩展提供的 r5900 处理器分析 SLPS_258.97,这时就可以正常解析指令。

Ghidra 开启反汇编

解压缩算法逆向

逆向工作准备完成后,马不停蹄地来到前面老哥提供的解压缩函数 0x116048 处,结合 PCSX2 的 debuger 做了一些简单的分析(PCSX2 的 debugger 是真难用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
void decompress(byte *data,byte *dst,int param_3,ulong size)
{
bool bVar1;
int *piVar2;
int *piVar3;
int *piVar4;
int *piVar5;
int *piVar6;
ulong uVar7;
ulong uVar8;
byte *pbVar9;
byte *ref_0;
byte *end;
uint command;
ulong length;
ulong pos;
byte instruction;

uVar8 = (ulong)(int)dst;
end = dst + (int)size;
uVar7 = (ulong)(int)(end + -1);
if (size == 0) {
return;
}
instruction = *data;
pos = uVar8;
do {
piVar3 = queue[4];
piVar6 = queue[3];
piVar4 = queue[2];
/* command == 6 */
pbVar9 = (byte *)pos;
if ((instruction & 0xe0) == 0xc0) {
*queue[5] = *queue[4];
piVar5 = queue[1];
piVar2 = queue[0];
length = (long)(int)(pbVar9 + (instruction & 0x1f) + 2);
if (uVar7 < (ulong)(long)(int)(pbVar9 + (instruction & 0x1f) + 2)) {
length = (long)(int)end;
}
*piVar3 = *piVar6;
*piVar6 = *piVar4;
*piVar4 = *piVar5;
*piVar5 = *piVar2;
*piVar2 = (int)data;
instruction = data[1];
data = data + 2;
if (pos < length) {
*pbVar9 = instruction;
while(true ) {
pbVar9 = (byte *)((int)pos + 1);
pos = (ulong)(int)pbVar9;
if (length <= pos) break;
*pbVar9 = instruction;
}
}
LAB_00116120:
length = pos - uVar8;
if (uVar7 < pos) {
return;
}
}
else {
/* command == 7 */
command = (uint)(instruction >> 5);
if ((instruction & 0xe0) == 0xe0) {
*queue[5] = *queue[4];
piVar2 = queue[2];
piVar4 = queue[1];
length = (long)(int)(pbVar9 + (instruction & 0x1f));
if (uVar7 < (ulong)(long)(int)(pbVar9 + (instruction & 0x1f))) {
length = uVar7;
}
*piVar3 = *piVar6;
piVar3 = queue[0];
*piVar6 = *piVar2;
*piVar2 = *piVar4;
*piVar4 = *piVar3;
*piVar3 = (int)data;
for (; data = data + 1, pos <= length; pos = (ulong)(int)((byte *)pos + 1)) {
*(byte *)pos = *data;
}
goto LAB_00116120;
}
ref_0 = (byte *)(&DAT_00261390)[command];
if (4 < command) {
*queue[5] = *queue[4];
}
if (3 < command) {
*queue[4] = *queue[3];
}
if (2 < command) {
*queue[3] = *queue[2];
}
if (1 < command) {
*queue[2] = *queue[1];
}
piVar4 = queue[0];
if (command != 0) {
*queue[1] = *queue[0];
}
*piVar4 = (int)ref_0;
/* dp == 6 */
if ((*ref_0 & 0xe0) == 0xc0) {
length = (long)(int)(pbVar9 + (instruction & 0x1f) + 2);
if (uVar7 < (ulong)(long)(int)(pbVar9 + (instruction & 0x1f) + 2)) {
length = (long)(int)end;
}
instruction = ref_0[1];
if (pos < length) {
*pbVar9 = instruction;
while(true ) {
pbVar9 = (byte *)((int)pos + 1);
pos = (ulong)(int)pbVar9;
if (length <= pos) break;
*pbVar9 = instruction;
}
}
bVar1 = uVar7 < pos;
LAB_001162d8:
if (bVar1) {
return;
}
}
else {
/* dp == 7 */
if ((*ref_0 & 0xe0) == 0xe0) {
ref_0 = ref_0 + 1 + ((instruction & 0x1f) >> 2);
length = (long)(int)(pbVar9 + (instruction & 3));
if (uVar7 < (ulong)(long)(int)(pbVar9 + (instruction & 3))) {
length = (long)(int)end;
}
bVar1 = uVar7 < pos;
if (pos <= length) {
do {
*(byte *)pos = *ref_0;
pos = (ulong)(int)((byte *)pos + 1);
ref_0 = ref_0 + 1;
} while (pos <= length);
bVar1 = uVar7 < pos;
}
goto LAB_001162d8;
}
}
data = data + 1;
length = pos - uVar8;
}
if (size <= length) {
return;
}
instruction = *data;
} while(true );
}

果然老哥没逆错,这个函数的内容和老哥逆出来的一样,从 data 中解压数据到 dst 中,queue 就是老哥的 HISTORY,表达式 (xxx & 0xe0) == 0xc0 等价于 xxx == 6(xxx & 0xe0) == 0xe0 等价于 xxx == 7

发现这个函数中也没有使用前面提到的文件中第 5~8 个字节(为了表达方便,我们记为 unknown),只用了前 4 个字节 size,那么就回到这个函数的调用者,看看调用者在执行完这个函数后会不会对 dst 做其他处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
ulong decompress_chunk(byte *chunk,byte *result,long param_3,long param_4,long param_5,
undefined8 param_6)
{
byte *dst;
long lVar1;
ulong uVar2;
long unknown;
long lVar4;
ulong decompressed_size;
int decompressed_size_1;
int *buf [4];
long lVar3;
byte c4;
byte c5;
byte c6;
byte c7;

lVar1 = (long)(int)result;
reset();
decompressed_size_1 = (uint)*chunk + (uint)chunk[3] * 0x1000000;
lVar3 = (long)decompressed_size_1;
c7 = chunk[7];
c6 = chunk[6];
decompressed_size_1 = decompressed_size_1 + (uint)chunk[2] * 0x10000 + (uint)chunk[1] * 0x100;
c5 = chunk[5];
lVar4 = (long)(int)((uint)c7 * 0x1000000);
c4 = chunk[4];
decompressed_size = (ulong)decompressed_size_1;
malloc((int *)buf,decompressed_size);
/* try { // try from 00116474 to 0011647b has its CatchHandler @ 001164dc */
dst = (byte *)extend_size(buf,0);
decompress(chunk + 8,dst,1,(long)decompressed_size_1);
unknown = (long)(int)((uint)c4 + (uint)c7 * 0x1000000 + (uint)c6 * 0x10000 + (uint)c5 * 0x100);
uVar2 = decompressed_size;
FUN_00116348(dst,result,decompressed_size_1,unknown);
free_buf((int *)buf,lVar1,uVar2,unknown,param_5,param_6,lVar3,lVar4);
return decompressed_size;
}

其中 chunk 就是一个文件的内容,dst 是 decompress 解压的输出,可以看到第 33 行执行完 decompress 后,取出 chunk 的第 5~8 个字节小端转换为 int 作为 unknown,最终带着 dst、为压缩文件大小 decompress_sizeunknown 调用了 FUN_00116348 函数,并且结果放在 result 中,作为 decompress_chunk 函数的返回结果。

那么很明显,FUN_00116348 就是那个老哥没有逆干净的代码,进去看看这个函数用 unknown 干了啥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void FUN_00116348(byte *buf,byte *result,int size,long unknown)
{
int i;
int k;
byte *pos;
int j;

j = size / (int)unknown;
i = 0;
if (unknown == 0) {
trap(7);
}
k = j * (int)unknown;
if (0 < j) {
pos = buf;
while(true ) {
for (; pos < buf + k; pos = pos + j) {
*result = *pos;
result = result + 1;
}
i = i + 1;
if (j <= i) break;
pos = buf + i;
}
}
for (; k < size; k = k + 1) {
*result = buf[k];
result = result + 1;
}
return;
}

果然,我们前面的推理没错,unknown 确实作为一个常量参与解压计算,FUN_00116348 会基于 unknowndecompress 的结果进行新一轮解压,当 unknown 取值为 1 时,这个函数原封不动的复制 bufresult 中,这就是为什么老哥逆出来的解压算法只能成功解压 unknown 为 1 的文件。

最终,基于 FUN_00116348 的逆向结果,重新解压 NORMAL 的第 10 个文件 SCENE_ID.LST,这次解压出了正确的结果。

此外,通过逆向可以发现,前文中发现 .BIN 中每个文件之间总是间隔大量 \xff 只是为了将文件对其到 0x800,便于寻址。

SCENE_ID.LSTs