好友
阅读权限 80
听众
最后登录 1970-1-1
烟99
发表于 2024-10-30 17:24
本帖最后由 烟99 于 2025-1-2 22:14 编辑
本帖不反对转载,但是转载请注明出处及本帖链接,谢谢!
首先特别鸣谢版主@爱飞的猫 在我的另一篇分析帖 中提供了分析思路,今天就学以致用,举一反三,分析一下其他游戏。
由于这次研究的游戏的题材属于历史类题材,再加上其他某些原因,故本教程对研究对象不便透漏,游戏名称已进行打码处理。
有坛友问我,干嘛非要解包游戏?难道只为了做mod版?之前在楼层里回复过他,现在我再公开答复一次:做解包是为了研究游戏程序的安全性,解包工具和分析文章只不过是研究游戏软件安全的副产品,而非让某些人取搞什么mod版,这也是我为什么拒绝透露游戏名称的原因。在此我要提醒大家:
本文仅限技术交流和软件安全性评估,游戏资源文件版权归游戏公司所有,切勿用于非法目的!因而造成的一切后果概不负责!
第一次写技术文章,技术有所欠缺,请见谅!大佬勿喷!
好了,文章开始!
20年前的一个暑假,我的父亲像平常一样下班回到家。我看到父亲提着一个纸袋,好奇地问:“爸爸,这里面是什么东西啊?”父亲从纸袋里拿出一个软件盒子,盒子上印着100年前的中国,一位中国战士在华东地区重要城市英勇抗击敌人的画面。父亲说:“给你,快打开放到电脑光驱里安装吧,这是从同事那儿借来的枪战游戏。”接着,父亲又递给我一张纸条,告诉我这是游戏的作弊码,要想看剧情但是卡关了就用作弊码。我启动了游戏,里面逼真的场景、激烈的交火画面,尤其是启用作弊码后能够零伤害杀敌,这让9岁的我感到无比快乐,无限释放释放多巴胺,这一切都深深地烙印在我的记忆里,终生难忘……
三年以后,我小学六年级,又多次重温了这款游戏。当时,玩完游戏后的我,很想听游戏的特效声音文件,于是对游戏根目录翻来覆去的查找,根本就没有音频,突然,一个200MB的文件引起了我的注意,我想,资源文件肯定压缩在了这个大文件里。接着,我就到某度上搜索如何解压,有没有解压工具,搜索了很久,一无所获……
20年后的今天,我自制了一款个人账号台账管理软件《吾爱记账号》 ,其中账号数据的存储层就用到了文件首尾特征字符+Zlib压缩的存储方案,这也是现在市面上的应用软件最常用的压缩算法之一。
于是乎,我心想:既然我现在才知道用Zlib来保存数据,那么20年前的国产游戏会不会把Zlib作为一种高水平技术的游戏Pack文件打包方式呢?
那好,别从这儿傻站着纸上谈兵,开始行动!
需要准备的工具:
1、游戏本体(必备)
2、010 Editor(或者与其功能相同的Hex十六进制字节编辑软件,本文以010 Editor为例)
3、Visual Studio 2022(只要支持.NET Framework 4.5及以上的开发框架,其他版本的IDE也可)
4、IDA(如果要修改EXE实现加载解包后的PCK文件就需要用到)
首先,我们要获取游戏本体,随着全社会对破解 版软件的打击力度的持续加大,再加上这款游戏年代久远,很多下载站已经把下载链接删除了,找的我好艰辛。最终,我从某云游戏平台的云电脑上预装的单机游戏提取到了游戏本体,打包压缩,用微信传回自己的电脑。
打开游戏安装文件夹
懂游戏懂电脑的同学们已经看出来了,我们不难发现,游戏公司只把背景音乐和过场动画视频留在了外面(如下图蓝框所示),而游戏的角色mod、地图、音效、UI界面都打包在了一个与游戏EXE文件同名的PCK文件中(如下图红框所示),这就是我们今天要研究的重点。
打开软件010 Editor,将文件sxxxxxai.pck拖入其中
我们发现,文件前面部分几乎都是乱码,不要着急,因为许多游戏Pack文件喜欢压缩数据,并把内部文件目录放在后面,也有的喜欢对其十六进制字节进行异或运算(即XOR)后存储(至于与什么数值异或运算就需要动态调试游戏EXE了)。
不过这都不是重点,接下来我们进行一个大胆的猜想,这款国产射击游戏诞生于20年前国人对数据安全意识相对薄弱、计算机技术刚刚起步、家用计算机的硬件性能普遍低下的时代,那么这个游戏不太可能使用很先进很高端的加密,甚至谈不上加密。刚才我提到过,Zlib是一种在应用软件中非常常用的压缩算法,鉴于此,这个Pack文件不排除会使用Zlib压缩算法的可能。
先来了解Zlib头部特征,通过借助AI大数据模型,向AI提问,AI回答告诉我,Zilb头部可能有三种,并提供了参考资料,AI给的参考资料在文章后面给出。
这三种头部如下表所示
文件头 压缩方式
78 01 No Compression/low
78 9C Default Compression
78 DA Best Compression
第一种,表示无压缩或低压缩率压缩;第二种,表示保持默认压缩;第三种,表示最好的压缩率压缩。
咦?第一个怎么这么眼熟?这不就是PCK文件头部吗?
此时相信大家的思路已经有点眉目了,这个Pack文件的压缩算法正是Zlib!
然后我们再做一个猜想,我们知道,计算机程序在加载数据的时候如果有压缩就得先解压缩才能加载数据,所以我们可以判断即便使用的是Zlib压缩也不能先拼和数据再压缩,这样会大大消耗内存资源,更何况在当年计算机技术水平不发达的年代,它一定是先压缩再拼接数据。
为了验证这个想法的真实性,我们先做个实验。
按Ctrl + F,如图所示,调出搜索框,将搜索类型设置为“Hex Bytes”,输入文件头部前两个十六进制字节78 01,按回车搜索。
这时候,010 Editor为我们列出了在当前PCK文件中十六进制字节78 01出现的次数及所在位置。共出现了5129次。
我们不妨尝试一下,从文件的0000h位置开始,到第二次出现十六进制字节78 01前面的地方选中复制,这时010 Editor的底部状态栏告诉我们选中了664个字节,十六进制数量为298h,还告诉我们起始、终止位置等信息,我说这些虽然现在没用,但是在后面是要派上用场的。
按快捷键Ctrl + Shift +N,新建一个Hex形式的文件,并将刚才选中的内容复制过来,如图所示。
保存文件,因为我们现在还不知道文件是什么格式,所以拓展名可以随便写,以保存到游戏根目录下为例,文件命名为Untitled1.bin。
保存后,我们来尝试解压这个文件,解压Zlib的方法有很多,我们这里就以C#为例进行解压。尽管C#的系统自带库支持Zlib解压,不过我感觉78 01开头的Zlib拿到系统自带的类库来解压会有兼容性风险,解压不出来,所以为了稳妥起见,决定使用免费开源、兼容稳定的第三方NuGet包——DoNetZip。
打开Visual Studio 2022,新建一个C#项目,引入NuGet包DoNetZIP,写入如下代码:
[C#] 纯文本查看 复制代码
using Ionic.Zlib;// 此为第三方NuGet包DotNetZip的引用,请自行安装
using System.IO;
namespace UnZlib
{
internal static class Program
{
/// <summary>
/// 程序主函数
/// </summary>
static void Main()
{
// 输入和输出文件名
string infilePath = "Untitled1.bin";
string outfilePath = Path.GetFileName(infilePath)+ "_Decompresed"+Path.GetExtension(infilePath);
// 将欲解压的文件载入到字节集数组
byte[] inputByte = File.ReadAllBytes(infilePath);
// 调用Zlib并解压数据
byte[] outputByte = Zlib.DeZlibcompress(inputByte);
// 写到文件
File.WriteAllBytes(outfilePath, outputByte);
}
/// <summary>
/// Zlib操作类(本文章只涉及解压,压缩操作略)
/// </summary>
public static class Zlib
{
/// <summary>
/// Zlib解压缩
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
public static byte[] DeZlibcompress(byte[] data)
{
// 创建数据输入流和输出流
using (var compressedInput = new MemoryStream(data))
using (var decompressedOutput = new MemoryStream())
{
// 执行解压操作
using (var decompressor = new ZlibStream(compressedInput, Ionic.Zlib.CompressionMode.Decompress))
{
decompressor.CopyTo(decompressedOutput);
}
//返回已处理的数据
return decompressedOutput.ToArray();
}
}
}
}
}
编写后,运行,生成了文件Untitled1_Decompresed.bin
将这个文件拖入到010 Editor。我们看到,解压后文件的Hex内容已经不是那么杂乱无章的了,很有顺序。显然,这大概率是一个图像文件,我们尝试修改拓展名为JPG,并使用图片浏览器来查看。
然而…现实给了我一记耳光。
图片无法读取,但不能就这么快下结论,来问一下AI吧。
问了AI,告诉我说这是TGA格式的图片。
改了文件拓展名,用第三方图片浏览器查看,一个游戏光标浮现在眼前,完美解压!
有的同学积极地回答道:这个Pack文件包里面有5129个文件!
我不是喜欢给别人泼冷水的人,但是我可以告诉你,它到底是不是有5129个文件真的不一定。
毕竟Zlib在压缩解压操作时受算法、配置等多重因素的影响,必出现偶然的状况,字节数据分割不对是无法解压的。
这个时候怎么办?
我们就要寻找它的文件目录区,看看目录区是否记录了文件偏移、大小这些信息。
回到010 Editor,继续查看sxxxxxai.pck,将文件拉到最后面。
发现了这里有文件目录数据,还有游戏厂商自定义的文件特征,我们的解包成败与否就取决于这里。
我们看到,每个文件前面都带着完整的文件夹路径,文件路径与文件路径之间基本上都是乱码。
再次猜想,文件路径的长度是不一样的,如果游戏EXE在根据文件名读取对应的数据的时候,那就一定要知道文件名的长度,并记录到相关的区域中,文件路径与文件路径之间的乱码或为这些信息。
那就尝试一下随机选中一个完整的路径名,看看他的长度
此时010 Editor告诉我们当前选中了32个字节,十六进制长度为20h,我们向下推理,后面可能会存在字节20h,然而直到下一个字节也没有发现。
那好,那我们就向前找,这时发现文件名数据部分的向前第四个字节为21h,这与我们要寻找的20h非常相近,而且刚才选中的文件名与文件名之间的字节是17个,众所周知,计算机通常都是以2的N次方来计数,但是17个字节就不太符合常理,文件名最后一个字节76h后面填充了一个00h,基于上面的信息可以确认,文件名数据占用若干字节,将占用的长度写在文件名前面,并占用四个字节,后面的16个字节中有12个字节记录的或许为文件偏移、文件压缩前后大小的信息,后四个字节为下一个文件名的长度。
如图将25 B3 3E 0D拼在一起,即0D3EB325h,用Windows自带的的计算器转换成十进制。
十进制结果为632503821,如果转换成MB的话,那就是600多MB,而整个PCK文件也只有200多MB,所以这个计算不成立。
那么把这四个字节顺序倒过来计算呢?
我们得出了222212901的数值,换算成MB为211.23MB,当然一个WAV音频文件封装在PCK文件包里不可能211MB,但数值符合文件偏移值。
用上述方法把25 B3 3E 0D后面的八字节每四字节为一组,分成B8 34 00 00和07 9D 00 00 ,从右往左输入转换为十进制的结果为47156和40199,符合压缩前后大小值。
这四个字节记录数据方法为从右往左的方向进行记录,因此,我们将这种记录方式称为『低位编址(little endian) 』。
综上所述,初步得出的结论为是:
一条文件信息由五个部分组成
1、文件名长度(四字节)
2、文件名及完整路径(字节数以文件名长度为准)
3、四字节文件偏移地址(四字节)
4、压缩前大小(四字节)
5、压缩后大小(四字节)
第一条的说法已经得到了验证,现在验证一下第二条和第三条。
按快捷键Ctrl +G,打开跳转到指定字节对话框,将刚才的25 B3 3E 0D从右往左输入,也就是0D3EB325h并回车。
你会发现,010 Editor定位的地方的字节值也是78 01!
我们刚才分析到25 B3 3E 0D是文件偏移,07 9D 00 00是文件压缩后大小,那就尝试选定文件偏移+压缩后大小,把它复制到空白的文件里。
按快捷键Ctrl + Shift +A,调出选中指定字节对话框,点击“Opinions”,选择第一个选项,选中方式为选中起始地址+要选中的大小,从Start和Size两个项目分别填入0D3EB325h和00009D07h
选中字节后,可以看到最后一个字节的后两个字节还是78 01
随后单独复制出来,用刚才写的C#程序进行解压,发现文件大小正是刚才十六进制34B8h所对应的十进制值
把解压后的文件拖进010 Editor,文件头部为WAV音频的头部
将拓展名改为WAV,播放正常
文件目录区和压缩方式我们搞清楚了,但是还有两个棘手的问题:数据区到底多长?文件数量到底多少个?不知道数据区或者文件目录区的大小,无法定位目录区的字节地址,不知道文件数量,批量解压操作的for循环次数不知道写多少,这两个问题不解决,我们只能解出无目录和文件名的文件。所以还要到文件尾部寻找线索。
再一次回到文件尾部,找到最后一个文件的文件路径名,然后按文件路径长度四字节、文件路径内容若干字节、文件偏移四字节、文件压缩前后大小个四字节选中。
在最后一个文件的压缩大小信息结束后,紧接着的四字节是03 00 01 00,再往后的四个字节为5D 13 7B 0D,然后就是PCK文件特征签名了。
这时候,我们发现03 00 01 00在文件的最后四字节中再次出现,这里的数据长度为272个字节,且可以被4整除,那么就可以判定这个游戏的Pack文件的特征区在文件尾部,首尾字节为03 00 01 00。
知道了数据区的规则,知道了目录表的组成和方法,又知道了Pack文件的特征区长度,并且还知道里面有一串明文的签名,那就不难猜出5D 13 7B 0D和C5 06 00 00是干什么的了。
没错!5D 13 7B 0D为数据区长度,因为数据区的首个文件数据从0h开始,转到地址0D7B0Dh后发现这个区域的字节内容完全符合前期的推理
而C5 06 00 00转为000006C5h并换算成十进制为1733,符合一个游戏的资源文件数量。
最后用表格来捋一下整体算法,以表格的形式列出
算法我们捋完了,现在开始写代码吧。
如果要写代码,要分两个大步骤来进行,先获取包内文件信息,生成带有文件名、文件偏移、压缩前后大小的列表,然后再执行解包。
而两个大步骤又分成若干个小步骤。
先说关于第一大步的操作流程:
(1)创建一个FileStream文件流,并seek到记录数据区总长度信息的位置,可以通过PCK文件大小减去272所得的值来seek。
(2)得到了数据区总长度后,用PCK文件大小减去数据区总长度再减去272,获得文件目录区总长度。
(3)随后seek到PCK文件大小减去8的位置,得到包内文件数量信息的十六进制字节值。
(3)创建一个for循环,循环次数为包内文件数量,依次获取单个文件目录长度、偏移、压缩前后大小,并将处理好的数据加入到List中。
(4)将这个List返回出来,等待使用。
第二大步就简单了
用for循环按照List里的每个所对应的数据进行拷贝、创建即可。
在运行调试时,Zlib突然在某一次循环抛出了异常,说数据无法识别
先前的分析思路完全正确,但是突然遇到了不能识别的数据,这就要从返回的文件列表来寻找答案
在解压函数开始的位置下断点,提取到了文件列表。
经查看,第164行发现了问题,文件解压前后大小一样,这似乎意味着这个文件并没有压缩,为了印证这个说法是否正确,我们到010 Editor跳转到这个文件的所在偏移位置。
果不其然,很明显的明文在这里,完全能够证明这个文件没有压缩。
那么就要在处理解压后数据的时候附加条件,什么情况下调用Zlib解压在返回数据,什么情况下原封不动的返回数据。
加了判断条件后,没再出现错误,于是编写了GUI界面,完美解压!
文件数量为1733
这里给出基于C#编写的代码
由于BitConverter方法要求数据必须是八字节,我们还不能提前拷贝,这样的话把与操作无关的其他字节也拷进去了,所有要拷完后补四个00,鉴于该操作比较多,先封装到一个函数中
[C#] 纯文本查看 复制代码
using System;
using System.Reflection;
namespace PCKExtractetor
{
/// <summary>
/// 公共函数
/// </summary>
internal class PublicFunction
{
/// <summary>
/// <字节集型> 8字节补零
/// <param name="bytes">(字节集 欲填充0x00的8字节字节集数组)</param>
/// <returns><para>返回处理后的8字节数组</para></returns>
/// </summary>
public static byte[] EightByteConverter(byte[] bytes)
{
// 补到8字节用于Bit转换
byte[] newByees = new byte[8];
Array.Copy(bytes, 0, newByees, 0, bytes.Length);
for (int i = bytes.Length; i < 8; i++)
{
newByees[i] = 0x00; // 补充0x00
}
// 将处理完的字节集数组交还给bytes变量
return newByees;
}
}
}
随后引入第三方NuGet包DotNetZip,这是解压PCK数据的必备类库,下方代码是实现解压缩功能的核心代码
[C#] 纯文本查看 复制代码
using Ionic.Zlib;
using System.IO;
using System.Security.Cryptography;
namespace PCKExtractetor
{
/// <summary>
/// Zlib类
/// </summary>
internal class ZLibHelper
{
/// <summary>
/// <字节集型> 使用Zlib算法压缩数据到字节数组
/// <param name="data">(字节集型 要压缩的数据 ,</param>
/// <returns>成功返回压缩后的字节集数组,失败返回NULL。</returns>
/// </summary>
public static byte[] ZlibCompress(byte[] data)
{
byte[] ReturnData;
//进行Zlib压缩
using (var input = new MemoryStream(data))
using (var output = new MemoryStream())
{
using (var compressor = new ZlibStream(output, Ionic.Zlib.CompressionMode.Compress))
{
input.CopyTo(compressor);
}
ReturnData = output.ToArray();
return ReturnData;
}
}
/// <summary>
/// <字节集型> 使用Zlib算法解压缩数据到字节数组
/// <param name="data">(字节集型 欲解要压缩的数据 ,</param>
/// <returns>成功返回压缩后的字节集数组,失败返回NULL。</returns>
/// </summary>
public static byte[] DeZlibcompress(byte[] data)
{
using (var compressedInput = new MemoryStream(data))
using (var decompressedOutput = new MemoryStream())
{
using (var decompressor = new ZlibStream(compressedInput, Ionic.Zlib.CompressionMode.Decompress))
{
decompressor.CopyTo(decompressedOutput);
}
//返回已处理的数据
return decompressedOutput.ToArray();
}
}
}
}
最后是PCK解压代码,分三个部分,第一部分为文件列表获取,第二部分为按文件顺序号单独解压文件,第三部分为解压全部文件
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
/*
* PCK解压算法代码
* 注意事项:
* 1、本代码使用了C#的五元组,故目标框架必须使用.NetFramework4.5及以上的版本,否则会认定语法错误
* 2、必须使用第三方NuGet包DotNetZip,否则Zlib数据无法操作
*
* 解压流程简述:
* 取PCK数据区大小→取PCK内部文件的总数量→取内部文件目录区每个文件的偏移、大小等信息→开始解压
*
* 本代码版权归吾爱破解@烟99所有,转载请注明出处,谢谢!
*/
namespace PCKExtractetor
{
/// <summary>
/// PCK解压操作类
/// </summary>
internal class PCKExtractetor
{
/// <summary>
/// <五元List> 取PCK内部文件列表
/// <param name="pckfilePath">(文本型 欲获取内部文件列表的PCK文件) </param>
/// <returns><para>成功返回PCK文件的单个文件的目录长度、完整文件路径、、文件偏移、实际大小、压缩大小,并封装在 <List>中</para></returns>
/// </summary>
public static List<Tuple<int, string, long, long, long>> GetPCKInformation(string pckfilePath)
{
// 定义一个五元组的<List>,用于返回文件信息,Tuple元素内容依次为:文件路径长度,文件路径,文件偏移,文件真实大小,文件物理大小
List<Tuple<int, string, long, long, long>> returnList = new List<Tuple<int, string, long, long, long>>();
// 取PACK文件大小
FileInfo fileInfo = new FileInfo(pckfilePath);
long pckfileSize = fileInfo.Length;
// 调试输出PCK文件大小
Console.WriteLine("PCK文件大小:" + fileInfo.Length + " 字节(十六进制:" + fileInfo.Length.ToString("X") + ")");
// 定义PCK文件特征区大小
long pckSignatureSize = 272;
// 开始解析文件
using (FileStream fs = new FileStream(pckfilePath, FileMode.Open))
{
// 处理数据区物理大小
byte[] dataSizeBit = new byte[4]; // 字节集状态下的数据区物理大小数值
long dataSize; // 长整型的数据区物理大小数值
// 定位到文件长度减去PCK文件特征区大小再减去4个字节的位置
fs.Seek(pckfileSize - pckSignatureSize + 4, SeekOrigin.Begin);
// 取数据区物理大小数值信息
fs.Read(dataSizeBit, 0, dataSizeBit.Length);
// 将取到的物理大小数值信息补满八字节,然后转换成长整型数值
dataSize = BitConverter.ToInt64(PublicFunction.EightByteConverter(dataSizeBit), 0);
// 调试输出数据区物理大小
Console.WriteLine("该文件的数据区物理大小为 " + dataSize.ToString() + " 字节(十六进制:" + dataSize.ToString("X") + ")");
// 处理PCK内部文件文件数量
byte[] fileCountBit = new byte[4]; // 字节集状态下的数据区物理大小数值
long fileCount; // 长整型的数据区物理大小数值
// 定位到文件长度减去PCK文件特征区大小的位置
fs.Seek(pckfileSize - 8, SeekOrigin.Begin);
// 取PCK内部文件数量信息
fs.Read(fileCountBit, 0, fileCountBit.Length);
// 将取到的PCK内部文件信息补满八字节,然后转换成长整型数值
fileCount = BitConverter.ToInt64(PublicFunction.EightByteConverter(fileCountBit), 0);
// 调试输出包内文件数量
Console.WriteLine("该PCK文件包应有 " + fileCount.ToString() + " 个文件(十六进制:" + fileCount.ToString("X") + ")");
// 开始处理目录区
byte[] fileIdxByte = new byte[pckfileSize - dataSize - pckSignatureSize]; // 文件索引数据
// 定位到文件索引区的首个字节,即数据区物理大小的值
fs.Seek(dataSize, SeekOrigin.Begin);
// 开始填充文件索引数据
fs.Read(fileIdxByte, 0, fileIdxByte.Length);
// 调试输出文件索引数据大小
Console.WriteLine("该PCK文件包的文件索引大小为 " + fileIdxByte.Length.ToString() + " 字节(十六进制:" + fileIdxByte.Length.ToString("X") + ")");
// 开始创建文件列表
int byteOffset = 0; //字节偏移记录
// 开始for循环,循环次数为文件数量
for (int i = 0; i < fileCount; i++)
{
// 取文件目录文本长度
byte[] filePathLengthBit = new byte[4];
// 从当前偏移位置开始拷贝四个字节
Array.Copy(fileIdxByte, byteOffset, filePathLengthBit, 0, filePathLengthBit.Length);
// 四字节补到八字节,再转换成整型数值,因为所有目录文本字节后面都填充了一个00,因此先减去1,后续再补上
int filePathLength = BitConverter.ToInt32(PublicFunction.EightByteConverter(filePathLengthBit), 0) - 1;
//字节偏移移到目录的第一个字符
byteOffset += 4;
// 取文件目录
byte[] filePathStringBit = new byte[filePathLength];
// 从当前的字节偏移位置拷贝文件目录文本字节数据,文本长度变量filePathLength来决定
Array.Copy(fileIdxByte, byteOffset, filePathStringBit, 0, filePathLength);
// 将文件目录文本字节数据转换为文本型数据
string filePathString = Encoding.Default.GetString(filePathStringBit);
//字节偏移移到当前文件偏移的字节,由于刚才目录长度被减掉了1,所以要补回来
byteOffset += filePathLength + 1;
// 取文件偏移
byte[] fileOffsetBit = new byte[4];
// 从当前偏移位置开始拷贝四个字节
Array.Copy(fileIdxByte, byteOffset, fileOffsetBit, 0, fileOffsetBit.Length);
// 四字节补到八字节,再转换成长整型数值,
long fileOffset = BitConverter.ToInt64(PublicFunction.EightByteConverter(fileOffsetBit), 0);
//字节偏移到文件实际大小的字节
byteOffset += 4;
// 取文件实际大小
byte[] fileActualsizeBit = new byte[4];
// 从当前偏移位置开始拷贝四个字节
Array.Copy(fileIdxByte, byteOffset, fileActualsizeBit, 0, fileActualsizeBit.Length);
// 四字节补到八字节,再转换成长整型数值,
long fileActualsize = BitConverter.ToInt64(PublicFunction.EightByteConverter(fileActualsizeBit), 0);
//字节偏移到文件压缩后大小的字节
byteOffset += 4;
// 取文件压缩大小
byte[] filecompressionsizeBit = new byte[4];
// 从当前偏移位置开始拷贝四个字节
Array.Copy(fileIdxByte, byteOffset, filecompressionsizeBit, 0, filecompressionsizeBit.Length);
// 四字节补到八字节,再转换成长整型数值,
long filecompressionsize = BitConverter.ToInt64(PublicFunction.EightByteConverter(filecompressionsizeBit), 0);
//字节偏移到下一个文件长度信息字节,为下一次循环做准备
byteOffset += 4;
// 将获取到的信息添加到五元组的<List>returnList
returnList.Add(Tuple.Create(filePathLength, filePathString, fileOffset, fileActualsize, filecompressionsize));
}
// 关掉文件流以节约内存
fs.Close();
}
// 循环结束将五元组的<List>returnList返回
return returnList;
}
/// <summary>
/// 解压单独文件
/// <param name="pckfilePath">(文本型 欲单独解压的PCK文件, </param>
/// <param name="fileList">List 已处理好的文件列表, </param>
/// <param name="targetNum">欲解压的文件顺序号, </param>
/// <param name="savePath">欲保存的路径)</param>
/// </summary>
public static void ExtractFileSingle(string pckfilePath, List<Tuple<int, string, long, long, long>> fileList, int targetNum, string savePath)
{
// 取单独的文件名
string fileName = Path.GetFileName(fileList[targetNum].Item2);
// 开始获取指定文件的物理数据
long pckOffset = fileList[targetNum].Item3; //PCK文件偏移
long actualDataSize = fileList[targetNum].Item4; // 指定文件的真实大小
long compressionDataSize = fileList[targetNum].Item5; // 指定文件的物理大小
byte[] actualData; // 指定文件的实际大小数据字节集数组
byte[] compressionData = new byte[compressionDataSize]; // 指定文件的物理大小数据字节集数组
// 将物理数据拷贝到文件的物理数据字节集数组中
using (FileStream fs = new FileStream(pckfilePath, FileMode.Open))
{
fs.Seek(pckOffset, SeekOrigin.Begin);
fs.Read(compressionData, 0, (int)compressionDataSize);
// 由于极少数文件未压缩,将导致ZLib抛出数据无法识别的异常,因此需要加判断,判断依据为检查压缩前后大小
if (actualDataSize == compressionDataSize)
{
actualData = compressionData;
}
else
{
actualData = ZLibHelper.DeZlibcompress(compressionData);
}
File.WriteAllBytes(savePath, actualData);
// 关掉文件流以节约内存
fs.Close();
}
}
/// <summary>
/// 解压所有文件
/// <param name="pckfilePath">(文本型 欲单解压的PCK文件, </param>
/// <param name="fileList">List 已处理好的文件列表, </param>
/// <param name="savePath">欲保存的路径)</param>
/// </summary>
public static void ExtractAllFile(string pckfilePath, List<Tuple<int, string, long, long, long>> fileList, string savePath)
{
// 取文件数量,即List成员数
int fileCount = fileList.Count;
// 设置解压文件夹,保存到文件名+_PCKUnpacked中
string extractFolder = Path.GetFileNameWithoutExtension(pckfilePath) + "_PCKUnpacked";
Directory.CreateDirectory(extractFolder);
//开始解压操作
using (FileStream fs = new FileStream(pckfilePath, FileMode.Open))
{
// 进入for循环
for (int i = 0; i < fileCount; i++)
{
// 取文件名和文件路径
string filePath = fileList[i].Item2;
string fileDir = Path.GetDirectoryName(filePath);
string fileName = Path.GetFileName(filePath);
// 置该文件最终绝对路径
string finalPath = savePath + "\\" + extractFolder + "\\" + filePath;
long pckOffset = fileList[i].Item3; //PCK文件偏移
long actualDataSize = fileList[i].Item4; // 指定文件的真实大小
long compressionDataSize = fileList[i].Item5; // 指定文件的物理大小
byte[] actualData; // 指定文件的物理数据字节集数组
byte[] compressionData = new byte[compressionDataSize]; // 指定文件的物理数据字节集数组
fs.Seek(pckOffset, SeekOrigin.Begin);
fs.Read(compressionData, 0, (int)compressionDataSize);
// 由于极少数文件未压缩,将导致ZLib抛出数据无法识别的异常,因此需要加判断,判断依据为检查压缩前后大小
if (actualDataSize == compressionDataSize)
{
actualData = compressionData;
}
else
{
actualData = ZLibHelper.DeZlibcompress(compressionData);
}
// 检查目录是否存在,不存在则创建
if (!Directory.Exists(Path.GetDirectoryName(finalPath)))
{
Directory.CreateDirectory(Path.GetDirectoryName(finalPath));
}
// 此轮工作已结束,写到文件
File.WriteAllBytes(finalPath, actualData);
}
// 关掉文件流以节约内存
fs.Close();
}
}
}
}
GUI程序源码就不放了,论坛禁止发布破解相关的成品,大家根据上面的代码基本上都能顺利解包。
为了保证数据能正确的解压,原则上应该加一个文件特征区的验证,以确保不是这款游戏的PCK不能加载,但是没有写,但其实写法很简单,就是用文件流把特征区的签名读成Byte字节集数组,然后进行比对,比对结果不通过则抛出异常,大家本着这个思路去写,我就不举例子了。
本来到这里文章应该要结束的,但是有些同学想知道游戏能不能加载解包后的数据?
答案是否定的!
经过亲测,删除sxxxxxai.pck后运行游戏会闪退,这时你打开游戏根目录下的Logs文件夹中Errors.log的会看到有无法加载sxxxxxai.pck的记录
不过,这不代表就不可以加载解包后的数据,凡事都有个为什么,接下来就来聊关于让游戏加载解包后的数据的话题。
打开IDA,将游戏EXE文件sxxxxxai.exe拖进去。
我猜测,这个游戏不太可能有壳、压缩、混淆代码等一切妨碍调试的措施,那就尝试搜索pck
皆大欢喜,发现了不得了的东西
看到了"-nousepck"字样,它酷似应用程序的附加命令,顾名思义,就是告诉EXE不使用PCK来加载游戏。现在试一试在CMD里加上这条参数运行游戏。
顺利进入游戏!
不用想,这条命令是为了让游戏开发工程师调试游戏的时候省去打包的繁琐步骤而设计的。
我们也不能为了实现加载解包后的数据来玩游戏调出命令行输入附加参数来启动游戏,太麻烦了,所以只能将加载PCK的相关操作nop掉,以达到任何情况下都不执行加载PCK的操作。
按Ctrl + T,继续搜索,经过多次搜索,0x041ECA8处发现了一个疑似操作函数的代码
它被0x0041EC9F处调用,其操作为
[Asm] 纯文本查看 复制代码
jz short loc_41ECA8
这是一条汇编命令,意思是上一个操作的零标志位ZF为1时,程序会跳转到名为“loc_41ECA8”的代码位置。如果零标志位为0(即上一个操作的结果不为零),则程序将顺序执行下一条指令。这里的ZF标记位可能是对是否调用了"-nousepck"的结果,不管那么多了,只要实现最终目的就可以完成任务。不懂汇编的看参考资料。
修改有两种方法:
1、直接将0x0041EC9F处改成空操作,即nop,使EXE根本就不存在这条操作,后续按正常流程执行。
2、将0x0041EC9F的跳转条件由jz改成jnz,即零标志位ZF不是0的时候则跳转,这样实现了不加命令参数"-nousepck"也会加载包外文件的目的,但是这样做就意味着加了命令参数后依然要加载PCK,有严重的弊端,因此第一条方案才是完美解决方法。
最后说说如何学好游戏资源文件逆向吧,说说我的经验
首先,要弄明白ASCII码,要了解常见的图片和音频格式的文件头特征,这是最基本的,因为很多游戏是把多个数据拼合在一个文件的,根本谈不上加密和压缩,懂这两个后,静态分析Pack文件会很快。
其次,要会汇编语言,还要会用IDE、OD等常用的分析工具,因为很多游戏的Pack文件的字节是经过异或运算的,需要通过跑EXE,下断点来确定数据处理流程,一些加壳的有混淆的还要掌握脱壳 技术。
还有一点,就是多看别人的案例,你会发现各大游戏厂商的Pack加密方式形形色色。
最后,就是要善用AI,因为无论是搜文件头部特征的资料亦或是代码查错亦或是分析思路,AI能够帮你回答。
以上是给初学者的建议,大家可以根据自己的学习习惯来酌情制定方案。
以上就是对这款游戏PCK资源文件的分析,文章结束
Thank you for reading!
2024.11.15更新
解包代码进行了优化调整,优化了文件流处理方式,追加了PCK特征识别机制,由于之前读取文件目录偏移的时候是整体减去1,风险太大,故改为从byte里减1,更新代码请移步:
https://www.52pojie.cn/thread-1983120-1-1.html
最后给大家个留个课后作业
如图所示,这是单机小游戏《极乐浪子》的PFP包文件,其结构原理与本次分析的游戏大致相同,只有三次不同:没有压缩、文件特征区在前面、文件目录长度只占了一个字节,试分析解包算法,并写出解包代码,C、Java、Python均可,语言不限。过段时间我会在编程区发布答案。根据论坛规定,论坛禁止发布游戏,所以游戏本体需自己百度,这个游戏是一家叫PlayFirst的游戏厂商出品的游戏,找到这家厂商的其他游戏的PFP文件来解包也是可以的,因为算法一模一样。
课后作业答案揭晓:
https://www.52pojie.cn/thread-1977704-1-1.html
自己去看看,你做对了吗?
参考资料:
1、Ascii(256个) 编码表 完整码表 ASCII编码 ASCII表 ASCII码 二进制 十进制 八进制 十六进制
https://blog.csdn.net/u011149152/article/details/139168143
2、杂项 - zlib
https://www.cnblogs.com/ainsliaea/p/15780903.html
3、[汇编语言]基础知识
https://blog.csdn.net/weixin_51304981/article/details/126492628
4、汇编语言指令大全 附指令详解
https://blog.csdn.net/luxiaol/article/details/134921043
免费评分
查看全部评分