1. 存档封装的三层结构拆解
1.1 为什么存档文件总是一团乱码
第一次拿到一个存档文件,用十六进制编辑器打开,满屏都是看不懂的字节,很多人第一反应是“加密了”。其实大多数情况下,存档封装并没有那么神秘,它通常由三层结构叠在一起:最外层是压缩,中间层是校验,最内层才是异或混淆。这三层的顺序不同,拆解的策略也完全不同。
我见过不少存档格式,有的先压缩再异或,有的先异或再压缩,还有的在校验值上做手脚。判断顺序的方法很简单:看文件头几个字节的熵值。如果开头就是高熵数据,大概率是压缩层在最外面;如果开头有明显的规律性重复,那可能是异或层在外面。这个判断直接决定了你后面从哪里下手。
1.2 沿字符串引用还原的核心思路
“沿字符串引用还原”这个说法听起来有点绕,说白了就是:不要一上来就暴力破解,而是从文件里能找到的明文字符串入手,顺着引用关系一层层往回推。比如存档里通常会有玩家名、关卡名、时间戳这些字符串,它们即使经过异或和压缩,在某些位置仍然会留下痕迹。
我的做法是先用strings命令或者十六进制编辑器的字符串搜索功能,把文件里所有可读的 ASCII 和 Unicode 字符串列出来。然后观察这些字符串的位置和上下文,找到它们被引用的地方——比如某个偏移量后面跟着一个长度值,或者某个字符串前面有一个指针。这些引用关系就是还原封装结构的线索。
注意:不要跳过字符串搜索这一步直接去猜异或密钥。我踩过的坑就是一开始就假设密钥是单字节,结果试了几百个值都不对,后来发现密钥是从某个字符串的哈希派生出来的。
1.3 三层结构的常见排列组合
存档封装的层序不是固定的,但根据我处理过的案例,大致有以下几种常见组合:
| 层序 | 外层 | 中间层 | 内层 | 典型特征 |
|---|---|---|---|---|
| A | 压缩 | 校验 | 异或 | 文件头高熵,尾部有固定长度校验值 |
| B | 异或 | 压缩 | 校验 | 文件头有规律,解异或后能看到压缩魔数 |
| C | 校验 | 异或 | 压缩 | 文件头是校验值,后面跟着异或后的压缩数据 |
| D | 异或 | 校验 | 压缩 | 解异或后能看到校验值,再后面是压缩数据 |
判断层序的实操方法是:先尝试对文件头做单字节异或扫描,看能否出现常见的压缩魔数(如1F 8B、78 9C、50 4B等)。如果出现了,说明异或层在外面。如果没有,就尝试直接解压缩,看是否报错。如果解压缩报错但错误信息提示校验失败,那校验层就在压缩层外面。
2. 异或层的拆解与密钥推导
2.1 单字节异或的快速识别
单字节异或是最简单的混淆方式,但也是最容易被误判的。很多人看到文件里有重复的字节模式就以为是单字节异或,其实可能是多字节密钥或者压缩后的残留。识别单字节异或的关键是看字节频率分布:异或后的数据,其字节频率分布会整体平移,但形状不变。
具体操作:用 Python 写个小脚本,统计文件前 256 字节的字节频率,然后和标准 ASCII 文本的频率分布做对比。如果形状相似但整体偏移了一个固定值,那这个偏移量就是异或密钥。我实测下来,这种方法对文本类存档特别有效,准确率能到九成以上。
from collections import Counter def guess_single_byte_xor(data): freq = Counter(data[:256]) # 假设最常见的是空格 0x20 或 0x00 most_common = freq.most_common(1)[0][0] for candidate in [0x20, 0x00, 0x0A]: key = most_common ^ candidate decoded = bytes(b ^ key for b in data[:64]) if all(32 <= c < 127 or c in (9, 10, 13) for c in decoded): return key return None2.2 多字节异或密钥的推导技巧
多字节异或密钥的推导要复杂一些,但核心思路不变:利用已知明文片段反推密钥。存档文件里通常有固定的文件头或者版本号字符串,比如"SAVE"、"V1.0"之类的。把这些已知明文和密文对应位置异或,就能得到密钥片段。
如果已知明文不够长,可以用重合指数法先猜密钥长度。具体做法是:把密文按可能的密钥长度分组,计算每组之间的重合指数。如果某个长度下重合指数明显偏高,那这个长度很可能就是密钥长度。确定长度后,再对每一列单独做单字节异或分析。
实操心得:我通常先用 1 到 32 的所有长度试一遍重合指数,把得分最高的前三个长度记下来,然后分别尝试推导密钥。很多时候密钥长度是 4 或 8 的倍数,因为开发者喜欢用整数对齐。
2.3 密钥派生自字符串的常见模式
有些存档的异或密钥不是固定的,而是从某个字符串派生出来的,比如玩家名、设备 ID 或者时间戳。这种情况下,你需要先找到那个字符串,然后用相同的派生算法算出密钥。常见的派生方式有:
- 直接取字符串的字节:密钥就是字符串本身循环使用。
- 哈希后取模:比如 MD5 后取前 16 字节,再对 256 取模得到单字节密钥。
- 简单累加:把字符串所有字节相加,取低 8 位作为密钥。
我遇到过一个案例,密钥是玩家名的每个字符的 ASCII 值异或后的结果。这种就需要你先从存档里找到玩家名,然后手动算一遍。所以前面说的“沿字符串引用还原”在这里就派上用场了——字符串不仅是线索,有时候直接就是密钥材料。
3. 校验层的识别与绕过
3.1 常见校验算法速查
校验层的目的是防止存档被随意修改,常见的算法有 CRC32、Adler32、MD5、SHA1 以及简单的累加和。识别校验算法的方法是:看校验值的长度和位置。CRC32 和 Adler32 都是 4 字节,MD5 是 16 字节,SHA1 是 20 字节。位置通常在文件头或文件尾。
| 算法 | 长度 | 特征 | 识别方法 |
|---|---|---|---|
| 累加和 | 1/2/4 字节 | 值随数据变化缓慢 | 修改一个字节,看校验值变化量 |
| CRC32 | 4 字节 | 雪崩效应明显 | 修改一个字节,校验值完全变化 |
| Adler32 | 4 字节 | 两个 16 位分量 | 高 16 位和低 16 位变化规律不同 |
| MD5 | 16 字节 | 固定长度,高熵 | 长度匹配即可初步判断 |
| SHA1 | 20 字节 | 固定长度,高熵 | 长度匹配即可初步判断 |
3.2 校验值的定位与验证
定位校验值的位置有个笨办法但很有效:把文件复制一份,修改中间某个字节,然后对比两份文件的差异。如果只有一处差异,那处很可能就是校验值。如果有多处差异,说明校验值可能分散在多个位置,或者校验算法涉及多个字段。
验证校验算法是否猜对的方法是:用你猜测的算法重新计算校验值,和文件里的值对比。如果一致,恭喜你猜对了。如果不一致,可能是算法变种,比如 CRC32 的初始值或多项式不同。这时候可以尝试常见的变种参数,或者用暴力搜索的方式确定初始值和异或输出值。
import zlib def verify_crc32(data, expected): # 标准 CRC32 if zlib.crc32(data) & 0xFFFFFFFF == expected: return "standard" # 初始值为 0 的变种 crc = 0 for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xEDB88320 if crc & 1 else crc >> 1 if crc & 0xFFFFFFFF == expected: return "init_zero" return None3.3 绕过校验的实用策略
如果你只是想把存档改掉,不打算完全还原封装格式,那绕过校验是最快的路径。常见策略有:
- 直接删除校验字段:如果游戏读取存档时不做严格校验,删掉校验值可能就能通过。
- 固定校验值:把校验值改成某个已知能通过的值,比如全零或全 F。
- 重算校验值:如果你已经确定了算法,改完数据后重新算一遍填回去。
注意:有些游戏会把校验值分散在多个位置,或者用多个校验算法交叉验证。这种情况下,只改一处是不够的。我建议先用二分法定位所有校验相关的位置:把文件分成两半,分别修改,看哪一半会导致校验失败,然后继续细分。
4. 压缩层的解包与重打包
4.1 压缩格式的快速识别
压缩层的识别相对简单,因为压缩格式通常有固定的魔数。常见的魔数有:
1F 8B:GZIP78 01、78 9C、78 DA:ZLIB(不同压缩级别)50 4B 03 04:ZIP42 5A 68:BZIP2FD 37 7A 58 5A 00:XZ28 B5 2F FD:Zstandard
如果文件头没有这些魔数,但数据熵值很高,可能是原始 DEFLATE 流(没有 ZLIB 头)。这时候可以尝试用zlib.decompressobj(-15)来解,负的 windowBits 表示无头 DEFLATE。
4.2 解压后的数据布局分析
解压之后,你会得到一层新的数据。这时候不要急着往下拆,先观察数据的布局。通常会有以下几种情况:
- 纯文本:直接可读,可能是 JSON、XML 或自定义的键值对格式。
- 二进制结构:有固定的字段偏移,比如前 4 字节是版本号,接着 4 字节是数据长度,然后是数据体。
- 嵌套压缩:解压后还是压缩数据,说明有多层压缩。
我处理过一个存档,解压后得到的是一个自定义的二进制格式,里面有一个字段是“下一个数据块的偏移量”。这种就需要你顺着偏移量链一路跟下去,把每个块都提取出来。
4.3 重打包时的对齐与填充
如果你修改了数据,重打包时需要注意对齐和填充。很多游戏在读取压缩数据时,会假设数据是按某个边界对齐的(比如 4 字节或 16 字节)。如果你压缩后的数据长度不是对齐边界的整数倍,可能需要在末尾填充零字节。
另外,压缩级别也会影响输出。有些游戏对压缩后的数据长度有校验,如果你用不同的压缩级别,即使解压后内容一样,压缩后的字节也可能不同,导致校验失败。我的经验是:尽量用和原文件相同的压缩级别。如果不确定,可以尝试所有级别,看哪个的输出长度和原文件最接近。
import zlib def repack(data, original_compressed): # 尝试所有压缩级别,找最接近原始长度的 best = None best_diff = float('inf') for level in range(0, 10): compressed = zlib.compress(data, level) diff = abs(len(compressed) - len(original_compressed)) if diff < best_diff: best_diff = diff best = compressed return best5. 完整还原流程与实操案例
5.1 从零开始拆解一个存档文件
假设你拿到一个名为save.dat的文件,大小 2KB。第一步,用十六进制编辑器打开,观察文件头。假设前 16 字节是A3 5F 2B 9C ...,看起来是高熵数据。这时候先别急着下结论,用strings命令搜一下可读字符串。
如果搜到了"player_name"这样的字符串,记下它的偏移量。然后看这个偏移量附近有没有长度字段或指针。通常字符串前面会有 2 或 4 字节的长度值。顺着这个长度值往前推,可能会找到一个数据块的起始位置。
接下来,尝试对文件头做单字节异或扫描。如果扫描到某个值时出现了78 9C,那说明异或密钥就是这个值,而且异或层在压缩层外面。先用这个密钥解异或,然后解压缩,得到中间层数据。
中间层数据可能是校验值加实际数据。校验值通常在开头或结尾。用前面说的修改对比法定位校验值,确定算法后就可以绕过或重算了。
5.2 实操案例:一个模拟存档的完整拆解
我构造了一个模拟存档来演示完整流程。原始数据是一个 JSON 字符串,经过以下步骤封装:
- 用 ZLIB 压缩 JSON 数据。
- 计算压缩数据的 CRC32,附在末尾。
- 对整个数据(压缩数据 + CRC32)做单字节异或,密钥为 0x5A。
拆解过程如下:
import zlib import struct # 读取文件 with open('simulated_save.dat', 'rb') as f: raw = f.read() # 第一步:解异或 key = 0x5A deobfuscated = bytes(b ^ key for b in raw) # 第二步:分离 CRC32 和压缩数据 compressed = deobfuscated[:-4] expected_crc = struct.unpack('<I', deobfuscated[-4:])[0] # 验证 CRC32 actual_crc = zlib.crc32(compressed) & 0xFFFFFFFF print(f"CRC32 匹配: {actual_crc == expected_crc}") # 第三步:解压缩 json_data = zlib.decompress(compressed) print(json_data.decode('utf-8'))这个案例的关键点是:异或层在最外面,校验层在中间,压缩层在最里面。拆解顺序和封装顺序正好相反。
5.3 还原后的数据验证与修改
还原出 JSON 数据后,你可以随意修改,然后按相反顺序重新封装:
- 压缩修改后的 JSON。
- 计算新压缩数据的 CRC32。
- 拼接压缩数据和 CRC32。
- 用相同密钥异或。
重打包后,把文件放回游戏目录,启动游戏验证是否读取成功。如果失败,检查压缩级别是否一致、CRC32 算法是否正确、异或密钥是否用对。
实操心得:我习惯在每一步都保存中间结果,比如
step1_deobfuscated.bin、step2_decompressed.json。这样如果某一步出错,可以快速回退到上一步重新尝试,不用从头再来。
6. 常见问题与排查技巧实录
6.1 解压报错但数据看起来没问题
这种情况通常是压缩格式的变种。比如 ZLIB 的头部两个字节中,第二个字节的低 5 位表示压缩级别,但有些实现会把这个字段改成其他值。如果你用标准 ZLIB 解压报错,可以尝试跳过前两个字节,直接用原始 DEFLATE 解压。
另一个可能是数据被截断或填充。检查压缩数据的长度是否和头部声明的长度一致。如果不一致,可能需要根据长度字段截取正确的数据段。
6.2 校验值对不上但算法确定没错
先检查校验的范围。有些存档的校验值只覆盖数据的一部分,比如跳过文件头或者只校验数据体。你可以尝试不同的范围组合:从头到尾、从某个偏移到末尾、排除某些字段等。
还要注意字节序。CRC32 和 Adler32 通常是小端序存储,但有些实现用大端序。如果你算出来的值和文件里的值字节顺序相反,那就是字节序问题。
6.3 异或密钥推导失败的可能原因
如果单字节扫描和多字节推导都失败,可能是以下原因:
- 密钥是动态的:比如每次保存时随机生成,存在文件某个位置。
- 异或不是简单的逐字节异或:可能是逐位异或、加法模 256、或者更复杂的混合运算。
- 数据经过了多次异或:比如先异或 A 再异或 B,等效于异或 A^B,但如果你不知道这个关系,就会推导失败。
我的建议是:先用已知明文攻击。找到文件里最长的可读字符串,假设它是明文,和密文对应位置异或,得到密钥片段。如果密钥片段有重复模式,说明密钥是循环的;如果片段看起来随机,可能是从某个哈希派生的。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 解压报错 | 压缩格式变种 | 尝试无头 DEFLATE 或其他 windowBits |
| 校验失败 | 校验范围不对 | 尝试不同偏移和长度组合 |
| 异或推导失败 | 密钥动态或多次异或 | 用已知明文攻击,检查密钥来源 |
| 重打包后游戏不读 | 压缩级别或对齐问题 | 尝试所有压缩级别,检查填充字节 |
| 字符串搜索无结果 | 字符串被压缩或异或 | 先解异或或解压缩再搜索 |
6.5 独家避坑技巧
技巧一:用二分法定位校验字段。把文件分成两半,分别修改,看哪一半会导致校验失败。然后继续细分,直到定位到具体字节。这个方法比盲目猜测快得多。
技巧二:保存所有中间状态。每拆一层就存一个文件,命名清晰。这样即使后面走错了路,也能快速回到某个已知正确的状态。
技巧三:用 Python 的zlib.decompressobj而不是zlib.decompress。前者可以处理流式数据,而且能告诉你解压过程中消耗了多少字节,对于定位压缩数据的边界很有帮助。
技巧四:注意文件末尾的填充字节。很多存档在末尾会有零填充,这些填充字节可能不参与校验,也可能参与。如果不确定,先假设它们不参与,如果校验失败再尝试包含它们。
技巧五:异或密钥可能是字符串的哈希。如果你找到了一个可疑的字符串,尝试用 MD5、SHA1、CRC32 等常见哈希算法处理它,取结果的前几个字节作为密钥。我遇到过好几次密钥就是玩家名的 CRC32 低 8 位。
7. 工具选型与自动化脚本
7.1 十六进制编辑器怎么选
我常用的十六进制编辑器有 HxD、010 Editor 和 ImHex。HxD 胜在轻量快速,适合快速查看和修改。010 Editor 有强大的模板功能,可以定义结构体来解析二进制格式。ImHex 是开源免费的,支持模式语言和脚本,适合复杂分析。
如果你只是偶尔拆个存档,HxD 足够了。如果你要批量处理或者写自动化脚本,建议用 Python 的struct和binascii模块,灵活性和可复现性都更好。
7.2 自动化拆解脚本框架
我写了一个通用的拆解框架,把每一层封装定义为一个函数,然后按顺序调用。这样换一个存档格式,只需要改几个参数就行。
class SaveUnpacker: def __init__(self, data): self.data = data self.layers = [] def add_layer(self, name, func, **kwargs): self.layers.append((name, func, kwargs)) def unpack(self): result = self.data for name, func, kwargs in self.layers: result = func(result, **kwargs) print(f"完成层: {name}, 输出长度: {len(result)}") return result def repack(self, modified_data): result = modified_data for name, func, kwargs in reversed(self.layers): result = func(result, reverse=True, **kwargs) return result这个框架的好处是层与层之间解耦,你可以单独测试每一层的函数,确认无误后再串联起来。
7.3 批量处理时的注意事项
如果你要处理多个存档文件,注意以下几点:
- 密钥可能因文件而异:如果密钥是从玩家名派生的,每个存档的密钥都不同。
- 压缩级别可能不同:不同时间保存的存档可能用了不同的压缩级别。
- 校验算法可能升级:游戏更新后可能换了校验算法,需要重新分析。
我的做法是:先拿一个存档完整拆解一遍,确定所有参数,然后写脚本批量处理。处理过程中如果某个文件失败,单独拿出来分析,不要影响其他文件。
8. 从存档封装看数据还原的通用方法论
拆存档这件事,表面上是逆向工程,实际上是一套通用的数据还原方法论。核心就三步:观察特征、假设结构、验证假设。观察特征包括熵值分析、字符串搜索、字节频率统计。假设结构就是猜层序和算法。验证假设就是实际拆一遍,看能不能得到可读数据。
这套方法论不仅适用于游戏存档,也适用于任何封装过的数据格式,比如某些配置文件、缓存文件、日志文件。我甚至用类似的方法拆过一些自定义的二进制协议数据包,思路完全一样。
关键是要有耐心,不要一上来就暴力破解。先花十分钟做观察和假设,能省下后面几个小时的盲目尝试。另外,记录每一步的操作和结果,这样即使走错了路,也能快速回溯。我习惯用 Markdown 记笔记,把每个偏移量、每个密钥、每个算法的猜测都写下来,最后整理成一份完整的分析报告。
最后分享一个小技巧:如果你实在推不出异或密钥,可以试试把文件里所有字节异或一遍,得到一个单字节值。这个值有时候就是密钥,因为开发者可能用“所有字节异或”作为简单的校验或密钥派生方式。我遇到过两次这种情况,都是靠这个笨办法破的局。