ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

逆向工程实战:从字符串引用拆解存档封装中的XOR、校验与压缩

逆向工程实战:从字符串引用拆解存档封装中的XOR、校验与压缩

1. 从一个存档文件说起:为什么需要拆解封装层

手里拿到一个二进制存档文件,双击打不开,用十六进制编辑器看是一堆乱码,但文件头几个字节又隐约有规律——这种场景在逆向分析、数据恢复、老旧系统迁移里太常见了。标题里说的"沿字符串引用还原存档封装",本质上就是一条逆向工程的分析路径:你手上只有一堆字节,但通过观察文件里出现的字符串引用(比如某个字段名、某个路径片段、某个固定标记),反推出这个存档是怎么被封装的,然后一层层剥开XOR混淆、校验和、压缩这三层外壳,最终拿到原始数据。

这件事能解决什么问题?举个实际场景:某款老工具生成的工程存档,原程序已经找不到了,但你需要把里面的配置数据导出来迁移到新系统。或者你在做安全分析,拿到一个可疑的二进制文件,需要判断它内部是否藏了压缩数据。再或者你在做数据恢复,一个损坏的存档文件,需要先搞清楚它的封装结构才能定位损坏位置。这些场景的共同点是:你没有源码,没有文档,只有一个二进制文件和你的分析能力。

适合谁来参考?有一定二进制分析基础、会用十六进制编辑器、了解基本压缩格式(zlib、gzip、lz4之类)和校验算法(CRC32、Adler32、简单累加和)的人。如果你完全没接触过二进制分析,这篇文章也能看懂思路,但实操部分需要你补一些基础知识。我会尽量把每一步的"为什么"讲清楚,而不是只丢一堆命令。

整篇文章围绕一条主线:从字符串引用入手,识别封装层次,逐层拆解XOR、校验、压缩,最终还原出可读的原始数据。这条主线在逆向分析里是通用方法论,不局限于某一个具体格式。下面我按实际操作的顺序,把每个环节拆开讲。

2. 封装结构的整体设计思路与识别方法

2.1 为什么存档要加这三层

先说清楚一个前提:不是所有存档都会同时用XOR、校验、压缩这三层。但一旦你遇到一个"看起来有规律但读不懂"的文件,这三层是最常见的组合。它们的职责分工很明确。

压缩层解决的是体积问题。原始数据可能是JSON、XML、或者结构化的二进制记录,直接存体积大,用zlib或lz4压一下能省很多空间。压缩后的数据有个特征:熵值高,字节分布接近随机,但通常开头几个字节是固定的魔数(比如zlib的78 9C、78 01、78 DA)。

校验层解决的是完整性验证。存档在传输或存储过程中可能损坏,加一个CRC32或Adler32,读取时先校验再解压,能提前发现损坏而不是解压到一半报错。校验值通常紧挨着被校验的数据存放,长度固定(CRC32是4字节,Adler32也是4字节)。

XOR层解决的是"不想让你直接看懂"的问题。它不算真正的加密,因为密钥往往很短且固定,但足以让直接打开文件的人看到乱码。XOR的特点是:同一个密钥异或两次就还原,所以加密解密是同一个操作。密钥可能是一个字节、一个固定字符串、或者一段循环使用的字节序列。

三层叠加的顺序通常是:原始数据 → 压缩 → 加校验 → XOR混淆 → 写入文件。读取时反过来:读文件 → XOR还原 → 校验验证 → 解压 → 原始数据。但实际顺序可能变,比如先XOR再压缩,或者校验放在XOR之前。这就是为什么不能想当然,必须通过分析确认。

2.2 字符串引用为什么是最好的切入点

二进制文件里如果全是压缩或XOR后的数据,你确实什么都看不出来。但封装结构本身往往留有"线索"——这些线索就是字符串引用。

具体来说,一个存档文件通常包含:文件头(标识版本、类型)、元数据区(记录各个数据块的位置和长度)、数据区(实际内容)。文件头和元数据区往往不压缩也不XOR,因为程序需要快速读取它们来定位数据。这些区域里就可能出现字符串,比如:

  • 固定的魔数标记,如"SAVE"、"ARCH"、"PK\x03\x04"(zip的标记)
  • 字段名,如"version"、"checksum"、"offset"、"length"
  • 路径片段,如"config/"、"data/"、".json"、".bin"
  • 错误信息或提示文本

用十六进制编辑器打开文件,切到ASCII视图,扫一眼有没有可读字符串。更高效的方法是用strings命令(Linux/macOS自带,Windows可以用Sysinternals的strings或者Git Bash里的)提取所有长度≥4的可打印字符串,然后观察哪些像是结构标记。

strings -a -t x savefile.bin | head -50

-a表示扫描整个文件(不只是数据段),-t x表示输出字符串在文件中的十六进制偏移。这样你能看到每个字符串出现在文件的什么位置,位置信息非常关键——如果某个字符串出现在文件开头附近,它很可能是文件头的一部分;如果出现在中间,可能是某个数据块的标记。

2.3 从字符串位置反推结构布局

拿到字符串列表和偏移后,下一步是推断结构。假设你看到这样的输出:

00000000 SAVE 00000010 version 00000020 checksum 00000030 data_offset 00000040 data_length 00000050 ...(后面是乱码)

这说明文件头至少到0x50左右,包含了版本、校验值、数据偏移和长度这些字段。每个字段后面跟着的实际值需要你去看十六进制。比如version后面4个字节可能是01 00 00 00(小端序的1),checksum后面4个字节是校验值,data_offset后面4个字节是数据区起始位置,data_length后面4个字节是数据区长度。

这里有个关键技巧:字符串本身往往不是数据,而是"标签"。程序读取时先匹配标签,再读标签后面的值。所以你在十六进制里看到的是"标签+值"的交替排列。标签是ASCII,值是二进制。找到这个规律,结构就清晰了一半。

注意:有些格式的字符串是定长字段(比如固定16字节,不足补零),有些是长度前缀(前面1或2字节表示字符串长度)。观察字符串后面跟的是00还是长度值,能判断是哪种。

2.4 判断是否存在XOR层的三个信号

不是所有文件都有XOR层。怎么判断?看三个信号。

信号一:字符串区域之外的数据熵值异常高但又不是压缩格式。压缩数据的熵值也很高,但压缩数据通常有魔数。如果你看到一段数据熵值高、没有已知压缩魔数、而且长度和某个字段(比如data_length)对得上,那可能是XOR后的数据。

信号二:文件里有两段相似但不完全相同的数据。比如两个数据块,长度一样,内容看起来都是乱码,但逐字节对比发现差异很小。这可能是同一份数据用不同密钥XOR,或者一份是XOR后的、一份是原始的(用于对比分析)。

信号三:已知明文攻击。如果你知道原始数据里应该包含某个固定字符串(比如JSON开头的{"或XML开头的<?xml),而文件里对应位置是乱码,把乱码和已知明文XOR,就能得到密钥。这是最直接的确认方法。

举个例子:假设data_offset指向的位置,文件里是9A 8B 8C 8D,而你预期原始数据开头是7B 22 0A 20({"加换行加空格),逐字节XOR:

9A ^ 7B = E1 8B ^ 22 = A9 8C ^ 0A = 86 8D ^ 20 = AD

得到密钥片段E1 A9 86 AD。如果这个密钥在文件其他位置重复出现,或者用它对其他数据块XOR后能得到可读内容,那XOR层就确认了。

3. 核心细节解析:XOR、校验、压缩的拆解要点

3.1 XOR层的密钥识别与还原实操

XOR还原的核心是找到密钥。密钥的形态有几种,按难度从低到高排列。

单字节密钥是最简单的。整个数据区所有字节都异或同一个值。怎么找?如果数据区里有一段你已知的明文(比如文件末尾可能有固定的结束标记),拿密文和明文XOR就能得到密钥。或者用频率分析:如果原始数据是文本,空格(0x20)出现频率最高,密文里出现频率最高的字节异或0x20就是密钥。

固定多字节密钥稍复杂。密钥是一个固定长度的字节序列,循环使用。比如密钥是"KEY123"(6字节),那么数据第1字节异或K,第2字节异或E,第7字节又异或K,以此类推。识别方法是:如果数据里有两段相同明文被加密,密文的差异会呈现周期性。或者用已知明文攻击,得到密钥片段后观察周期。

变长密钥最麻烦,密钥可能根据位置变化(比如密钥是位置索引的函数)。这种情况通常需要结合程序逻辑分析,纯静态分析很难。但实际遇到的存档格式里,变长密钥很少见,因为实现复杂且没必要。

实操还原的步骤:

  1. 确定数据区的起始偏移和长度(从文件头字段读取)。
  2. 提取数据区字节到单独文件。
  3. 用猜测的密钥做XOR,输出结果。
  4. 检查结果是否有已知压缩魔数或可读文本。
  5. 如果没有,调整密钥或密钥周期,重复。
def xor_decrypt(data, key): """key可以是bytes或int""" if isinstance(key, int): return bytes(b ^ key for b in data) else: key_len = len(key) return bytes(data[i] ^ key[i % key_len] for i in range(len(data))) # 示例:单字节密钥0x5A with open('data_section.bin', 'rb') as f: encrypted = f.read() decrypted = xor_decrypt(encrypted, 0x5A) with open('decrypted.bin', 'wb') as f: f.write(decrypted)

实操心得:XOR还原后先别急着解压,用xxd或十六进制编辑器看一眼开头。如果看到78 9C、1F 8B、04 22 4D 18这些魔数,说明压缩层就在里面。如果看到PK,那是zip。如果看到可读文本,可能没有压缩层,直接就是原始数据。

3.2 校验层的识别与验证方法

校验层的识别相对直接,因为校验算法就那么几种,而且校验值长度固定。

CRC32:4字节,多项式0xEDB88320(反射)或0x04C11DB7(非反射)。zlib、gzip、zip都用它。特征是:校验值通常放在被校验数据的后面或前面,小端序存储。

Adler32:4字节,zlib的默认校验(和CRC32二选一)。计算比CRC32快,但碰撞概率稍高。特征是:两个16位部分,低16位是累加和,高16位是累加和的累加。

简单累加和:1、2或4字节,所有字节相加取模。老式格式常用。特征是:值通常不大,且改变一个字节对结果影响是线性的。

MD5/SHA1:16或20字节,用于强校验。特征是:长度明显比CRC长,且看起来完全随机。

验证方法:假设文件头里有个checksum字段,值是X。你把数据区提取出来,用各种算法算一遍,看哪个结果等于X。如果都不等,可能校验范围不是整个数据区,而是数据区的一部分,或者校验前先做了XOR。

import zlib import hashlib data = open('data_section.bin', 'rb').read() crc32_val = zlib.crc32(data) & 0xFFFFFFFF adler32_val = zlib.adler32(data) & 0xFFFFFFFF md5_val = hashlib.md5(data).hexdigest() sha1_val = hashlib.sha1(data).hexdigest() print(f"CRC32: {crc32_val:08X}") print(f"Adler32: {adler32_val:08X}") print(f"MD5: {md5_val}") print(f"SHA1: {sha1_val}")

注意:校验值的字节序很关键。CRC32和Adler32在文件里通常是小端序存储,但有些格式用大端序。算出来的值要按两种字节序都试一下。另外,有些格式的校验值是对"XOR之前"的数据算的,有些是对"XOR之后"的算的。如果直接算不对,试试先XOR再算。

3.3 压缩层的格式判断与解压

压缩层的判断主要靠魔数和试解压。

常见压缩格式的魔数:

格式魔数(十六进制)说明
zlib78 01/78 9C/78 DA最常见,78后面的字节表示压缩级别
gzip1F 8B带文件头和CRC
lz404 22 4D 18帧格式
zstd28 B5 2F FD现代格式
bzip242 5A 68BZh
lzma/xzFD 37 7A 58 5A 00xz格式
deflate raw无固定魔数需要试解压

如果看到78开头的两字节,基本可以确定是zlib。zlib的第二个字节有规律:01表示最快压缩,9C表示默认,DA表示最大压缩。这三个值覆盖了绝大多数情况。

解压实操:

import zlib import gzip import lz4.frame import zstandard as zstd data = open('decrypted.bin', 'rb').read() # 试zlib try: result = zlib.decompress(data) print("zlib解压成功,长度:", len(result)) except Exception as e: print("zlib失败:", e) # 试gzip try: result = gzip.decompress(data) print("gzip解压成功,长度:", len(result)) except Exception as e: print("gzip失败:", e) # 试lz4 try: result = lz4.frame.decompress(data) print("lz4解压成功,长度:", len(result)) except Exception as e: print("lz4失败:", e) # 试zstd try: dctx = zstd.ZstdDecompressor() result = dctx.decompress(data) print("zstd解压成功,长度:", len(result)) except Exception as e: print("zstd失败:", e)

如果所有标准格式都失败,可能是raw deflate(没有zlib头)。用zlib.decompress(data, -15)试,-15表示raw deflate。还不行的话,可能是自定义压缩或者数据还有一层XOR没剥干净。

实操心得:解压失败时,先检查数据长度。zlib解压需要完整的流,如果数据被截断,会报Error -5(不完整)。这时候可以试试zlib.decompressobj(),它能解压部分数据。另外,有些格式在压缩数据前后加了额外字节(比如长度前缀),需要先去掉。

3.4 三层顺序的判断逻辑

三层顺序不是固定的,但可以通过以下逻辑判断:

如果XOR在压缩之后:XOR还原后能看到压缩魔数。这是最常见的情况,因为先压缩再XOR,XOR的密钥可以很短(甚至单字节),因为压缩数据本身已经高熵了。

如果XOR在压缩之前:XOR还原后看到的是原始数据(可读文本或结构化二进制),没有压缩魔数。这种情况少见,因为先XOR再压缩,压缩率会下降(XOR后的数据熵值高,压缩算法效果差)。

校验的位置:校验通常在XOR之前计算(对压缩后的数据算),然后校验值和XOR后的数据一起存储。读取时先XOR还原,再校验,再解压。但也有格式先校验再XOR,读取时先XOR再校验。判断方法:用两种顺序各试一次,看哪个能通过校验。

判断流程:

  1. 读文件头,提取data_offset、data_length、checksum。
  2. 提取数据区。
  3. 尝试XOR还原(用已知或猜测的密钥)。
  4. 对还原后的数据算校验,和checksum对比。
  5. 如果校验通过,尝试解压。
  6. 如果校验不通过,尝试对原始数据(未XOR)算校验。
  7. 如果还不通过,检查校验范围是否只是数据区的一部分。

4. 完整实操流程:从二进制到原始数据

4.1 环境准备与工具清单

工欲善其事,必先利其器。这套流程需要的工具不多,但每个都要用熟。

十六进制编辑器:010 Editor(有模板功能,能自动解析结构)、HxD(Windows免费)、ImHex(开源,支持模式语言)。我个人常用010 Editor,因为它的模板功能可以写脚本自动解析文件头。

命令行工具:xxd(十六进制dump)、strings(提取字符串)、file(识别文件类型)、binwalk(自动识别嵌套格式)。binwalk特别有用,它能自动扫描文件里的压缩流和文件系统。

Python环境:需要zlib(标准库)、gzip(标准库)、lz4(pip install lz4)、zstandard(pip install zstandard)、crcmod(可选,用于自定义CRC)。

调试工具:如果存档有配套程序,用x64dbg或gdb附加到程序上,在读取存档的函数下断点,观察它怎么处理数据。这是最直接的方法,但需要程序还能运行。

# 安装Python依赖 pip install lz4 zstandard crcmod # binwalk基本用法 binwalk savefile.bin # 提取binwalk识别的内容 binwalk -e savefile.bin

4.2 第一步:文件头解析与字段提取

假设我们已经用strings和十六进制编辑器确认了文件头结构。现在写一个Python脚本自动提取字段。

import struct def parse_header(filepath): with open(filepath, 'rb') as f: data = f.read() # 假设文件头结构(根据实际分析调整): # 0x00: magic (4 bytes) "SAVE" # 0x04: version (4 bytes, uint32 LE) # 0x08: checksum (4 bytes, uint32 LE) # 0x0C: data_offset (4 bytes, uint32 LE) # 0x10: data_length (4 bytes, uint32 LE) # 0x14: flags (4 bytes) magic = data[0x00:0x04] version = struct.unpack('<I', data[0x04:0x08])[0] checksum = struct.unpack('<I', data[0x08:0x0C])[0] data_offset = struct.unpack('<I', data[0x0C:0x10])[0] data_length = struct.unpack('<I', data[0x10:0x14])[0] flags = struct.unpack('<I', data[0x14:0x18])[0] print(f"Magic: {magic}") print(f"Version: {version}") print(f"Checksum: 0x{checksum:08X}") print(f"Data offset: 0x{data_offset:X}") print(f"Data length: {data_length}") print(f"Flags: 0x{flags:08X}") # 提取数据区 data_section = data[data_offset:data_offset + data_length] print(f"Data section extracted: {len(data_section)} bytes") return { 'magic': magic, 'version': version, 'checksum': checksum, 'data_offset': data_offset, 'data_length': data_length, 'flags': flags, 'data_section': data_section } result = parse_header('savefile.bin')

注意:字段偏移和类型必须根据实际分析确定,上面的代码只是示例。实际分析时,先用十六进制编辑器手动确认每个字段的位置和长度,再写脚本。不要一上来就写脚本,容易因为偏移猜错而浪费时间。

4.3 第二步:XOR还原与密钥确认

假设通过已知明文攻击,我们猜测密钥是单字节0x5A。写脚本还原并验证。

def try_xor_keys(data_section, candidate_keys): """尝试多个候选密钥,输出看起来最像压缩数据的""" results = [] for key in candidate_keys: if isinstance(key, int): decrypted = bytes(b ^ key for b in data_section) else: decrypted = bytes(data_section[i] ^ key[i % len(key)] for i in range(len(data_section))) # 检查是否有压缩魔数 magic_score = 0 if decrypted[:2] in [b'\x78\x01', b'\x78\x9C', b'\x78\xDA']: magic_score = 10 elif decrypted[:2] == b'\x1F\x8B': magic_score = 10 elif decrypted[:4] == b'\x04\x22\x4D\x18': magic_score = 10 # 检查可打印字符比例 printable = sum(1 for b in decrypted[:100] if 32 <= b < 127) printable_ratio = printable / min(100, len(decrypted)) results.append({ 'key': key, 'magic_score': magic_score, 'printable_ratio': printable_ratio, 'decrypted': decrypted }) # 按magic_score和printable_ratio排序 results.sort(key=lambda x: (x['magic_score'], x['printable_ratio']), reverse=True) return results # 候选密钥:单字节0x00-0xFF candidates = list(range(256)) results = try_xor_keys(result['data_section'], candidates) for r in results[:5]: key_repr = f"0x{r['key']:02X}" if isinstance(r['key'], int) else r['key'] print(f"Key: {key_repr}, Magic score: {r['magic_score']}, Printable: {r['printable_ratio']:.2f}") print(f" First 16 bytes: {r['decrypted'][:16].hex()}")

如果单字节密钥都不对,试试多字节。多字节密钥的候选可以来自:文件里其他位置的字符串、程序二进制里的常量、常见弱密钥(如"12345678"、"password")。

4.4 第三步:校验验证与数据完整性确认

XOR还原后,用还原后的数据算校验,和文件头里的checksum对比。

import zlib def verify_checksum(data, expected_checksum, algorithm='crc32'): if algorithm == 'crc32': calculated = zlib.crc32(data) & 0xFFFFFFFF elif algorithm == 'adler32': calculated = zlib.adler32(data) & 0xFFFFFFFF else: raise ValueError(f"Unknown algorithm: {algorithm}") # 尝试两种字节序 expected_le = expected_checksum expected_be = struct.unpack('>I', struct.pack('<I', expected_checksum))[0] match_le = (calculated == expected_le) match_be = (calculated == expected_be) print(f"Calculated: 0x{calculated:08X}") print(f"Expected (LE): 0x{expected_le:08X}, match: {match_le}") print(f"Expected (BE): 0x{expected_be:08X}, match: {match_be}") return match_le or match_be # 用XOR还原后的数据验证 best_result = results[0] decrypted_data = best_result['decrypted'] verify_checksum(decrypted_data, result['checksum'], 'crc32')

如果校验通过,说明XOR密钥正确,数据完整。如果不通过,可能:密钥不对、校验算法不对、校验范围不对(可能只校验数据区的一部分)、或者校验是在XOR之前算的(那就用原始数据区算)。

实操心得:校验不通过时,先别急着换密钥。试试用原始数据(未XOR)算校验,如果通过了,说明校验是在XOR之前算的,XOR层在校验层之后。这个顺序判断能省很多时间。

4.5 第四步:解压与原始数据导出

校验通过后,解压就是标准操作了。

import zlib def decompress_data(data): # 先试zlib try: result = zlib.decompress(data) print(f"zlib解压成功,原始长度: {len(result)}") return result, 'zlib' except zlib.error as e: print(f"zlib失败: {e}") # 试raw deflate try: result = zlib.decompress(data, -15) print(f"raw deflate解压成功,原始长度: {len(result)}") return result, 'deflate' except zlib.error as e: print(f"raw deflate失败: {e}") # 试gzip import gzip try: result = gzip.decompress(data) print(f"gzip解压成功,原始长度: {len(result)}") return result, 'gzip' except Exception as e: print(f"gzip失败: {e}") return None, None original_data, format_used = decompress_data(decrypted_data) if original_data: with open('original_data.bin', 'wb') as f: f.write(original_data) print(f"原始数据已导出,格式: {format_used}") # 如果是文本,打印前500字符 try: text = original_data.decode('utf-8') print("前500字符:") print(text[:500]) except UnicodeDecodeError: print("二进制数据,前64字节:") print(original_data[:64].hex())

4.6 第五步:结果验证与迭代

导出原始数据后,验证是否合理:

  • 如果是JSON/XML,用解析器试解析。
  • 如果是结构化二进制,检查字段是否符合预期。
  • 如果是图片/音频,用对应查看器打开。
  • 如果还是乱码,可能还有一层没剥(比如原始数据本身也XOR了,或者压缩格式是自定义的)。

迭代方向:

  1. 换XOR密钥(如果校验没通过)。
  2. 换校验算法(CRC32换Adler32,或换自定义多项式)。
  3. 换压缩格式(试lz4、zstd、bzip2)。
  4. 检查是否有长度前缀或后缀需要去掉。
  5. 检查数据区是否被分成了多个块,每块单独处理。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象可能原因排查方法
找不到可读字符串文件整体被XOR或压缩用binwalk扫描,试常见XOR密钥
字符串位置不连续结构中有变长字段或对齐填充检查字符串间的字节,可能是长度或偏移
XOR还原后无压缩魔数XOR密钥不对,或压缩格式无魔数试更多密钥,试raw deflate
校验总是不通过算法不对、字节序不对、范围不对试CRC32/Adler32,试大小端,试部分范围
解压报"不完整"数据被截断或有多余字节用decompressobj,检查长度字段
解压后还是乱码还有一层XOR或自定义编码检查解压后数据的熵值和字符串
多个数据块长度不一致每块独立压缩或XOR分别处理每块,检查块头
文件头字段值明显不合理字段偏移猜错或类型不对手动在十六进制编辑器里核对

5.2 独家避坑技巧

技巧一:先静态后动态,别急着上调试器。很多人一上来就开调试器附加程序,结果在几千个函数里迷路。先用strings和十六进制编辑器做静态分析,把结构猜个大概,再用调试器验证。静态分析能告诉你"是什么",动态分析能告诉你"怎么用"。

技巧二:保存每一步的中间结果。XOR还原后的数据、解压后的数据,都存成单独文件。这样如果后面步骤出错,不用从头再来。而且中间结果本身可能包含线索(比如XOR还原后的数据里可能有新的字符串)。

技巧三:注意字节对齐。很多格式的字段是4字节或8字节对齐的。如果你发现字段偏移总是差1-2字节,可能是对齐填充。检查字符串后面是否有00填充到4字节边界。

技巧四:校验值可能是对"部分数据"算的。有些格式只校验数据区的前N字节,或者跳过某些字段。如果全量校验不通过,试试排除文件头、排除末尾的填充、或者只校验数据区的前半部分。

技巧五:XOR密钥可能是文件里某个字段的值。比如文件头里有个seed字段,XOR密钥就是seed的某种变换(取反、加1、循环移位)。检查文件头里所有看起来像随机数的字段,它们可能是密钥来源。

技巧六:压缩数据可能有多层。有些格式先zlib压缩,再整体XOR,XOR还原后是zlib,解压后又是另一层zlib。用binwalk能自动识别嵌套,或者手动解压后检查结果是否还有压缩魔数。

技巧七:别忽略文件末尾。有些格式在文件末尾追加了额外数据(比如签名、填充、第二个数据块)。文件头里的data_length可能只覆盖第一个块,末尾还有东西。用十六进制编辑器跳到文件末尾看看。

技巧八:用已知样本对比。如果你能生成一个已知内容的存档(用原程序保存一个简单配置),然后和未知存档对比,差异部分就是数据区,相同部分就是文件头和固定结构。这是最快的方法,但前提是原程序还能用。

5.3 一个完整的排查案例

假设你拿到一个存档,strings输出如下:

00000000 SAVE 00000010 v1.0 00000020 crc32 00000030 offset 00000040 size 00000050 ...(乱码开始)

十六进制查看0x50之后的数据,发现熵值很高。文件头里offset字段值是0x00000050,size字段值是0x00001000。crc32字段值是0x12345678。

第一步:提取0x50到0x1050的数据区。

第二步:试单字节XOR。用0x00到0xFF逐个试,检查还原后是否有78 9C。假设试到0x5A时,还原后开头是78 9C。

第三步:用0x5A还原整个数据区,算CRC32。假设算出来是0x87654321,和文件头里的0x12345678不等。试大端序,还是不等。试Adler32,不等。

第四步:怀疑校验是在XOR之前算的。用原始数据区算CRC32,得到0x12345678,匹配!说明校验层在XOR层之前。

第五步:用0x5A还原数据区,然后zlib解压。解压成功,得到JSON文本。

第六步:验证JSON格式正确,字段符合预期。完成。

这个案例的关键点是第四步——校验顺序的判断。如果没想到这一点,可能会在密钥上反复试,浪费大量时间。

6. 工具选型与自动化脚本

6.1 工具对比与选择建议

工具适用场景优点缺点
010 Editor手动分析结构模板功能强,可视化好收费
HxD快速查看免费,轻量功能少
ImHex开源分析模式语言灵活,插件多学习曲线陡
binwalk自动扫描识别嵌套格式快误报多
x64dbg动态调试能看程序实际行为需要程序可运行
Python脚本批量处理灵活,可自动化需要编程基础

我的建议:手动分析用010 Editor或ImHex,自动扫描用binwalk,批量处理用Python。动态调试作为最后手段,因为很多老程序已经跑不起来了。

6.2 自动化脚本框架

把前面的步骤整合成一个脚本,输入文件路径,输出原始数据。

#!/usr/bin/env python3 """ 存档封装拆解工具 自动尝试XOR、校验、压缩的常见组合 """ import struct import zlib import gzip import sys import os class SaveFileUnpacker: def __init__(self, filepath): self.filepath = filepath with open(filepath, 'rb') as f: self.raw = f.read() self.header = {} self.data_section = None self.decrypted = None self.original = None def parse_header(self, magic_offset=0, fields=None): """根据提供的字段定义解析文件头""" if fields is None: # 默认字段定义(需根据实际调整) fields = [ ('magic', 0x00, 4, 'bytes'), ('version', 0x04, 4, 'uint32'), ('checksum', 0x08, 4, 'uint32'), ('data_offset', 0x0C, 4, 'uint32'), ('data_length', 0x10, 4, 'uint32'), ] for name, offset, size, ftype in fields: raw = self.raw[offset:offset+size] if ftype == 'bytes': value = raw elif ftype == 'uint32': value = struct.unpack('<I', raw)[0] elif ftype == 'uint16': value = struct.unpack('<H', raw)[0] else: value = raw self.header[name] = value # 提取数据区 offset = self.header.get('data_offset', 0) length = self.header.get('data_length', len(self.raw) - offset) self.data_section = self.raw[offset:offset+length] return self.header def try_xor(self, keys=None): """尝试XOR密钥""" if keys is None: keys = list(range(256)) best = None best_score = -1 for key in keys: if isinstance(key, int): decrypted = bytes(b ^ key for b in self.data_section) else: decrypted = bytes(self.data_section[i] ^ key[i % len(key)] for i in range(len(self.data_section))) score = self._score_data(decrypted) if score > best_score: best_score = score best = (key, decrypted) if best: self.decrypted = best[1] return best[0], best_score return None, -1 def _score_data(self, data): """给数据打分:压缩魔数+可打印字符比例""" score = 0 if len(data) >= 2: if data[:2] in [b'\x78\x01', b'\x78\x9C', b'\x78\xDA']: score += 100 elif data[:2] == b'\x1F\x8B': score += 100 if len(data) >= 4: if data[:4] == b'\x04\x22\x4D\x18': score += 100 elif data[:4] == b'\x28\xB5\x2F\xFD': score += 100 # 可打印字符比例 sample = data[:200] printable = sum(1 for b in sample if 32 <= b < 127) score += (printable / len(sample)) * 50 return score def verify_checksum(self, data, expected, algorithm='crc32'): """验证校验和""" if algorithm == 'crc32': calculated = zlib.crc32(data) & 0xFFFFFFFF elif algorithm == 'adler32': calculated = zlib.adler32(data) & 0xFFFFFFFF else: return False # 尝试两种字节序 expected_le = expected expected_be = struct.unpack('>I', struct.pack('<I', expected))[0] return calculated == expected_le or calculated == expected_be def decompress(self, data): """尝试多种解压格式""" # zlib try: return zlib.decompress(data), 'zlib' except: pass # raw deflate try: return zlib.decompress(data, -15), 'deflate' except: pass # gzip try: return gzip.decompress(data), 'gzip' except: pass # lz4 try: import lz4.frame return lz4.frame.decompress(data), 'lz4' except: pass # zstd try: import zstandard as zstd dctx = zstd.ZstdDecompressor() return dctx.decompress(data), 'zstd' except: pass return None, None def run(self): """完整流程""" print(f"处理文件: {self.filepath}") print(f"文件大小: {len(self.raw)} bytes") # 1. 解析文件头 print("\n[1] 解析文件头...") self.parse_header() for k, v in self.header.items(): if isinstance(v, bytes): print(f" {k}: {v}") elif isinstance(v, int): print(f" {k}: 0x{v:X} ({v})") # 2. 尝试XOR print("\n[2] 尝试XOR还原...") key, score = self.try_xor() if key is not None: key_repr = f"0x{key:02X}" if isinstance(key, int) else key print(f" 最佳密钥: {key_repr}, 得分: {score:.2f}") print(f" 还原后前16字节: {self.decrypted[:16].hex()}") else: print(" 未找到合适的XOR密钥") self.decrypted = self.data_section # 3. 验证校验 print("\n[3] 验证校验和...") expected = self.header.get('checksum', 0) for algo in ['crc32', 'adler32']: for data_name, data in [('XOR后', self.decrypted), ('原始', self.data_section)]: if self.verify_checksum(data, expected, algo): print(f" 校验通过: {algo} on {data_name}数据") break # 4. 解压 print("\n[4] 尝试解压...") original, fmt = self.decompress(self.decrypted) if original: self.original = original print(f" 解压成功: {fmt}, 原始长度: {len(original)}") # 导出 output_path = self.filepath + '.unpacked' with open(output_path, 'wb') as f: f.write(original) print(f" 已导出到: {output_path}") # 尝试显示文本 try: text = original.decode('utf-8') print(f" 前200字符:\n{text[:200]}") except: print(f" 前32字节: {original[:32].hex()}") else: print(" 解压失败,可能还有未识别的层") # 导出XOR还原后的数据供手动分析 output_path = self.filepath + '.decrypted' with open(output_path, 'wb') as f: f.write(self.decrypted) print(f" 已导出XOR还原数据到: {output_path}") if __name__ == '__main__': if len(sys.argv) < 2: print("用法: python unpacker.py <savefile>") sys.exit(1) unpacker = SaveFileUnpacker(sys.argv[1]) unpacker.run()

这个脚本是一个框架,实际使用时需要根据具体格式调整parse_header里的字段定义。它的价值在于把"试错"过程自动化了——自动试256个单字节XOR密钥、自动试多种校验算法、自动试多种解压格式。你只需要提供文件头结构,剩下的交给脚本。

实操心得:脚本跑完后,如果解压失败,别急着改脚本。先看导出的.decrypted文件,用十六进制编辑器手动检查。有时候脚本的评分函数没选对最佳密钥,但手动看能发现线索。自动化是辅助,最终判断还是要靠人。

7. 影响范围与扩展思考

这套拆解方法不只适用于存档文件。任何"二进制+封装"的场景都能用:网络协议的数据包、嵌入式设备的固件、游戏资源包、数据库的导出文件。核心思路是一样的——找字符串引用、识别封装层、逐层剥离。

从影响范围看,掌握这套方法意味着你能处理一类问题,而不是一个具体问题。今天拆的是存档,明天遇到一个未知格式的配置文件,同样的流程走一遍:strings看线索、十六进制看结构、试XOR、试校验、试解压。大部分格式都逃不出这几层。

扩展方向有几个。一是自动化程度可以更高,比如用符号执行或模糊测试自动推断字段边界。二是支持更多压缩格式,比如brotli、lzo、snappy。三是结合动态分析,如果程序能运行,用调试器直接看它怎么读文件,比静态猜快得多。四是处理加密而非XOR,如果遇到AES或RSA,就需要密钥提取或侧信道分析,那是另一个领域了。

我个人在实际操作中的体会是:耐心比技术更重要。很多时候不是不会,而是试了几次没成功就放弃了。XOR密钥有256种可能,校验算法有几十种,压缩格式有十几种,组合起来确实多。但通过字符串引用和结构分析,能把可能性缩小到几个,剩下的就是逐个验证。每次成功拆开一个格式,那种"原来如此"的感觉,就是做这行的乐趣所在。

最后分享一个小技巧:建一个自己的格式库。每次拆开一个新格式,把文件头结构、XOR密钥规律、校验算法、压缩格式记下来。下次遇到类似的,直接查库,能省大量时间。我自己的库里已经存了几十种格式,很多是同一个软件的不同版本,结构大同小异。这个习惯让我处理新格式的速度越来越快。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进