前言
某著名百合R18游戏
以前尝试逆过一次,半途而废了。今天想起来再逆一下,记录下逆向的过程。
游戏文件结构:
游戏资源extract
主要目标是弄明白游戏资源:SE、CG这些怎么加载解密的。
还是像万华镜那样下三个API断点:
以SE为例分析结构:
0~8:签名
0~54:固定结构的头部
剩下读的A0刚好读到这里:
最后的65读到这里:
0~7:签名
0~53:固定长度头部
54~END1:
END1~END2:文件名,长度应该是来自0C处的值。
再看看MSD结构分析下:
0~7:签名
0~53:固定长度头部
54~END1:
END1~END2:文件名,长度来自0C 0D的WORD值。
08 09处的WORD指示的是END2结束后下一部分开始的位置。
先跟踪下DATA的读取调用,在sub_43C330
(ReadHeader)
跟一跟sub_43C1A0
看到对三种版本进行了判断:
HANA5对应的都是FJSYS,所以return 4。
可以看到这里就有0x54这个值(固定头部长度)出现了:
后面两段内容的读取就在这里:
调试了下,除了那个*(_DWORD *)(this+44)!=4
不明所以外,其它就是原先想的那样,读取了后面两部分的内容。
掠过后面几个文件读取后,在这里发现了异样:
OP.MPG,但我搜索本地却没有这个文件🤔。内存中的也能这么读?(不行吧)
(嘶,
但是我看没引入CreateFileMapping啊。。。)
对应在这个函数sub_442BC0
(CVideoPlayer::Play)
好吧,这里读取是失败了的。。。(突然想到可以自己放一个OP.MPG,楽)
对应的这里应该就是这个ogg:
可以跟ogg文件对比,格式是一样的:
对应的是BGM文件的01ED处:
也就是根本没有压缩,加密。。。服了。。。
提取出来看看。
01ED开始3D7C95字节。
确实能够成功播放:
逆天,小日子真就不加密的。。。
下面就是找一下对应的offset字段和size字段是在header哪里设置的。
现在回来看就很清楚了:
写个脚本全部提取出来。
import struct
filename = r"BGM"
with open(filename,"rb+") as f:
data = f.read()
start1 = 0x50
num = 0
size = []
offset = []
idx = data[start1+4]
while (1):
size.append(struct.unpack('<I', data[start1+0x8:start1+0xC])[0])
offset.append(struct.unpack('<I', data[start1+0xC:start1+0x10])[0])
num += 1
if(idx+8 != data[start1+4+0x10]):
break
start1 += 0x10
idx = data[start1+4]
print(num)
def extract(offset,size,num):
output = "M" + str(num).rjust(2,"0") + ".ogg"
with open(output,"wb+") as f:
f.write(data[offset:offset+size])
print(f"[+] {num} : Done!")
for i in range(1,num+1):
extract(offset[i-1],size[i-1],i)
MSD的格式也是类似的:
只是不知道这MSD是啥。。
像是类似配置文件?
DATA也是一样,只是bmp的size是固定的:
稍微改改脚本就能提取:
import struct
filename = r"DATA"
with open(filename,"rb+") as f:
data = f.read()
start1 = 0x50
num = 0
size = []
offset = []
idx = data[start1+4]
while (1):
size.append(struct.unpack('<I', data[start1+0x8:start1+0xC])[0])
offset.append(struct.unpack('<I', data[start1+0xC:start1+0x10])[0])
num += 1
if num == 7:
break
start1 += 0x10
print(num)
def extract(offset,size,num):
output = "M" + str(num).rjust(2,"0") + ".bmp"
with open(output,"wb+") as f:
f.write(data[offset:offset+size])
print(f"[+] {num} : Done!")
for i in range(1,num+1):
extract(offset[i-1],size[i-1],i)
接下来就提取IMG,结构也是一样的。
只是注意到每个PNG前都有0x60 size的MGD文件(?)
稍微改下脚本:
import struct
filename = r"IMG"
with open(filename,"rb+") as f:
data = f.read()
start1 = 0x50
num = 0
size = []
offset = []
idx = data[start1+4]
while (1):
size.append(struct.unpack('<I', data[start1+0x8:start1+0xC])[0])
offset.append(struct.unpack('<I', data[start1+0xC:start1+0x10])[0])
num += 1
start1 += 0x10
if start1>=0xC50:
break
print(num)
def extract(offset,size,num):
output = "M" + str(num).rjust(2,"0") + ".png"
with open(output,"wb+") as f:
f.write(data[offset+0x60:offset+size])
print(f"[+] {num} : Done!")
for i in range(1,num+1):
extract(offset[i-1],size[i-1],i)
没问题,成功提取:
SE跟前面的BGM没啥区别,一样的提取:
import struct
filename = r"SE"
with open(filename,"rb+") as f:
data = f.read()
start1 = 0x50
num = 0
size = []
offset = []
idx = data[start1+4]
while (1):
size.append(struct.unpack('<I', data[start1+0x8:start1+0xC])[0])
offset.append(struct.unpack('<I', data[start1+0xC:start1+0x10])[0])
num += 1
start1 += 0x10
if start1>=0xf0:
break
print(num)
def extract(offset,size,num):
output = "M" + str(num).rjust(2,"0") + ".ogg"
with open(output,"wb+") as f:
f.write(data[offset:offset+size])
print(f"[+] {num} : Done!")
for i in range(1,num+1):
extract(offset[i-1],size[i-1],i)
至此,游戏资源的提取就弄明白了。(其余的格式不清楚,应该是引擎自己解析罢)
游戏进度存储
接下来我还想知道游戏进度是怎么存储的?
也就是这几个文件的结构:
GAME%d.SAV
打开游戏,点击start,会断在这里:
在sub_415E70
函数里
这里有个MD5校验?
所以每个SAVE开头的0x20就是md5 hash
调试看这里,很显然MD5两者是不匹配的。
是一个循环比对MD5,直至找到相同。
(所以感觉不应该点START,应该点LOAD。。。)
再点一遍LOAD,可以发现逻辑:
先加载第一页的SAV(尝试读取)114,我点了第二页又继续加载1528
我又重新保存了一个,发现这个MD5检测的可能是TIME,太久的SAVE就会不能加载。。。
紧接着后面有4个CFile::Read
CFile::Read_sub_43B610(Buffer, 4u);
CFile::Read_sub_43B610(lpBuffer, 0x40u);
CFile::Read_sub_43B610(&nNumberOfBytesToRead, 4u);
CFile::Read_sub_43B610(v17, 8u);
看SAVE文件像是用位图这种来保存每个场景的选择?
这里做个对比,LOAD 1过后,点击一次再保存。
发现唯一差异点:
但切换一个场景(图片变了)后,就有很大的差异
找找之前extract的bg14a,对应第8张
果然是
那存储逻辑到这里大概就明白了,还有很多细节没必要逆引擎的解析过程了。
CONFIG.SAV
最后关注一下这个CONFIG.SAV的逻辑
如果是START的话是这种:
所以我很感兴趣为什么我每点一个场景,切换都要读取一遍CONFIG.SAV?
这里也采取比较的方式。对于两个场景,比较CONFIG.SAV的区别。
注意到全部是1h大小的差异:
x32调试看看到底读取了CONFIG.SAV的什么?
跟到这里:
对应CONFIG.SAV的这里:
后面就是一堆奇奇怪怪的操作:
这里的v15 += 18也是往后移动72字节。
这里就当在计算一个不知道是什么鬼的值。。。
对于Sxxx这些段计算。
再后面的这里:
由黄色那句,知道v13往后移动72B,
可以看到这样一个结构刚好72字节:
关键是这个函数sub_447D10
里面用了SSE指令集,很难弄清楚在干啥。。。
。。。
但是问问GPT:
这段代码是一个函数
sub_447D10
的实现,功能是将一个__m128
类型的数组从源地址a2
复制到目标地址a1
,并且根据条件采用不同的复制方式。它包括了对内存对齐和性能优化的考虑,尤其是针对 SIMD 操作进行了优化。
该函数实现了一个高效的内存复制功能,支持标准和优化路径,根据内存对齐条件选择适当的复制方法。优化路径利用了 SSE 指令集,进行对齐的数据块复制,并采用流式存储和预取优化,旨在提高性能和减少缓存未命中的影响。
牛逼。。
就是复制。。。
GPT注释的代码:
__m128 *__cdecl sub_447D10(__m128 *a1, __m128 *a2, unsigned int a3)
{
__m128 *result; // eax
__m128 *v5; // edi
unsigned int v6; // ecx
__m128 v7; // xmm1
__m128 v8; // xmm2
__m128 v9; // xmm3
__m128 v10; // xmm4
__m128 v11; // xmm5
__m128 v12; // xmm6
__m128 v13; // xmm7
unsigned int v14; // ecx
__m128 *v15; // esi
__m128 *v16; // edi
unsigned int v17; // ecx
unsigned __int64 v18; // mm1
unsigned __int64 v19; // mm2
unsigned __int64 v20; // mm3
unsigned __int64 v21; // mm4
unsigned __int64 v22; // mm5
unsigned __int64 v23; // mm6
unsigned __int64 v24; // mm7
unsigned int v25; // ecx
result = a2;
// Check if special conditions for optimized copy are met
if ((byte_8347EC & 4) == 0 || a3 < 0x80 || ((unsigned __int8)a2 & 0xF) != 0 || ((unsigned __int8)a1 & 0xF) != 0)
{
if ((byte_8347EC & 1) == 0 || a3 < 0x40 || ((unsigned __int8)a2 & 7) != 0 || ((unsigned __int8)a1 & 7) != 0)
{
// Standard memory copy if conditions are not met
result = (__m128 *)a3;
qmemcpy(a1, a2, a3);
}
else
{
// Optimized memory copy for 64-byte aligned and properly aligned memory
v15 = a2;
v16 = a1;
v17 = a3 >> 6;
do
{
v18 = v15->m128_u64[1];
v19 = v15[1].m128_u64[0];
v20 = v15[1].m128_u64[1];
v21 = v15[2].m128_u64[0];
v22 = v15[2].m128_u64[1];
v23 = v15[3].m128_u64[0];
v24 = v15[3].m128_u64[1];
v16->m128_u64[0] = v15->m128_u64[0];
v16->m128_u64[1] = v18;
v16[1].m128_u64[0] = v19;
v16[1].m128_u64[1] = v20;
v16[2].m128_u64[0] = v21;
v16[2].m128_u64[1] = v22;
v16[3].m128_u64[0] = v23;
v16[3].m128_u64[1] = v24;
v15 += 4;
v16 += 4;
--v17;
}
while (v17);
// Handle any remaining bytes that don't fit into the 64-byte aligned blocks
v25 = a3 & 0x3F;
if ((a3 & 0x3F) != 0)
{
do
{
v16->m128_i8[0] = v15->m128_i8[0];
v15 = (__m128 *)((char *)v15 + 1);
v16 = (__m128 *)((char *)v16 + 1);
--v25;
}
while (v25);
}
_m_empty(); // Ensure all SSE operations are completed
}
}
else
{
// Optimized memory copy with prefetch and streaming stores
_ESI = a2;
v5 = a1;
v6 = a3 >> 7;
do
{
__asm { prefetchnta byte ptr [esi] }
v7 = _ESI[1];
v8 = _ESI[2];
v9 = _ESI[3];
v10 = _ESI[4];
v11 = _ESI[5];
v12 = _ESI[6];
v13 = _ESI[7];
_mm_stream_ps(v5->m128_f32, *_ESI);
_mm_stream_ps(v5[1].m128_f32, v7);
_mm_stream_ps(v5[2].m128_f32, v8);
_mm_stream_ps(v5[3].m128_f32, v9);
_mm_stream_ps(v5[4].m128_f32, v10);
_mm_stream_ps(v5[5].m128_f32, v11);
_mm_stream_ps(v5[6].m128_f32, v12);
_mm_stream_ps(v5[7].m128_f32, v13);
_ESI += 8;
v5 += 8;
--v6;
}
while (v6);
// Handle any remaining bytes that don't fit into the aligned blocks
v14 = a3 & 0x7F;
if ((a3 & 0x7F) != 0)
{
do
{
v5->m128_i8[0] = _ESI->m128_i8[0];
_ESI = (__m128 *)((char *)_ESI + 1);
v5 = (__m128 *)((char *)v5 + 1);
--v14;
}
while (v14);
}
_m_empty(); // Ensure all SSE operations are completed
_mm_sfence(); // Ensure all stores are completed
}
return result;
}
那么就当作复制来看。
a2复制到a1
可以看到复制后就是一些位图的形式
总结来说就是对CONFIG.SAV的文件进行读取,复制,以一种位图的形式保存游戏状态。
到这里,大致也就逆的差不多了,至于游戏文件怎么加载,怎么解析,应该去看游戏引擎的源码,逆向的话就太繁杂了、也没必要。
总结
Nippon真就不加密???
记得以前逆XP3的时候,Nippon也是先加密再压缩。。。然后XP3的加密也仅仅是单字节异或。。😂
总结逆向过程就是:动静结合分析程序逻辑,结合文件结构推测结构体,找出规律后extract游戏资源。