前阵子有位做存档工具的朋友拿了个文件给我,说他手工翻十六进制翻了三个晚上,能看见开头一段可读字符,再往后全是雪花点一样的乱码,完全找不到任何结构。我拿到样本后,第一件事不是去猜加密算法,而是把文件从头到尾扫了一遍字符串引用,结果十分钟就摸清了封装顺序:外层是 XOR 混淆,中间压了一层校验,内层才是真正的压缩数据块。这种“字符串引用打底、XOR 掩护、校验卡关、压缩收口”的存档封装手法,在各类单机游戏存档、缓存文件、配置文件里太常见了。本文就把这一整条“沿字符串引用还原存档封装”的拆解链路完整写出来,包含每一步的判断依据、实操脚本和踩坑记录。不管你是想做存档修改器、数据恢复,还是单纯想搞懂一个陌生二进制格式,这套方法论都能直接套用。
1. 先摸清文件骨架:字符串引用是最好的切入点
1.1 为什么字符串引用能“定位”封装结构
大多数存档封装在设计时都会保留一些可读字符串,哪怕数据层加密得再死,文件路径、字段名、导出键名、资源编号这些“元信息”总得让人认出来,否则程序自己调试都费劲。这些字符串散落在二进制流里,就是天然的坐标点。沿着它们去推两侧的数据边界,通常能干三件事:确认封装格式是不是自定义、判断有几层容器、估计各段数据的偏移范围。
我在处理这个目标文件时,先用十六进制工具打开了开头 256 字节,看到类似PKTITLE、SAVE_VER=1.3、DATA_OFFSET之类的字符串。这说明最外层至少有一段明文配置区。继续往下扫,在偏移0x2C40附近又发现一段资源路径列表,路径尾部紧跟着一大段高熵数据。高熵就意味着压缩或加密,而路径列表之后直接跟高熵区,说明路径列表是数据区的“目录项”,这也解释了后续数据为什么能靠偏移引用还原。
字符串引用的价值不在于它本身能解开加密,而在于它给你提供了“已知明文”。有了几段已知明文,后续破解 XOR 密钥、判断校验范围、估算压缩块长度,都有了参照物。
1.2 字符串批量提取的三种实用姿势
手工在十六进制界面里翻找字符串是下策,几 MB 文件翻到眼睛花。实用做法有三种:用系统自带工具扫 ASCII 字符串、用 Python 一次性提取 Unicode 与 ASCII、用十六进制编辑器的“分区统计”辅助定位。
我习惯直接在脚本里处理,一段简单的提取逻辑就能把文件里长度大于等于 4 的可读字符串连同偏移全部拉出来:
import re def extract_strings(data, min_len=4): # 同时提取 ASCII 与 UTF-16LE 字符串 ascii_re = re.compile(rb'[\x20-\x7e]{%d,}' % min_len) utf16_re = re.compile(rb'(?:[\x20-\x7e]\x00){%d,}' % min_len) results = [] for m in ascii_re.finditer(data): results.append((m.start(), m.group().decode('ascii', errors='ignore'))) for m in utf16_re.finditer(data): try: s = m.group().decode('utf-16le') if len(s) >= min_len: results.append((m.start(), s)) except ValueError: pass # 结果按偏移排序,方便对照十六进制位置 results.sort(key=lambda x: x[0]) return results with open('target_save.dat', 'rb') as f: buf = f.read() for off, s in extract_strings(buf, min_len=6): print(f'0x{off:08X} {s}')这段代码输出的偏移表,就是整个封装的“锚点表”。我在实际操作时发现,字符串一多很容易把自己绕晕,所以会额外标记三类:文件头标识、路径列表、字段名。这三类字符串往往对应三种不同的封装节点。
1.3 用文件头和字符串列表拼出“封装地图”
有了锚点位置,接下来要把它们串成一张地图。具体做法是列出所有可疑偏移点,观察字符串之间的空隙长度。如果两段字符串之间的空隙只有几个字节,说明这两段属于同一个容器;如果中间夹着大片高熵区,那大概率是前一段是目录,后一段是压缩数据。
以目标文件为例,扫出来三个关键锚点:
| 偏移 | 内容 | 推断 |
|---|---|---|
| 0x0000 | PKTITLE头标识 | 文件外层封装头 |
| 0x0014 | SAVE_VER=1.3等配置项 | 明文元信息区 |
| 0x2C40 | 路径列表,密集可读字符串 | 资源目录区 |
| 0x3000 | 高熵数据,无明显特征 | 疑似压缩数据区 |
从这张表能直接得出结论:文件分四个区域,路径列表结束之处,就是数据区开始之处。紧接着高熵区到底先经过 XOR 还是先经过压缩,单从静态分布看不出,必须用字节统计来辅助判断。
2. 剥掉 XOR 层:识别特征、爆破密钥、快速还原
2.1 XOR 加密的“露馅点”在哪里
XOR 本身并不复杂,但它有个致命弱点:它不改变数据的分布规律,只是把所有字节统一“平移”到另一个值域。如果原数据是高度结构化、分布不均的,XOR 后仍然保留那种分布不均的痕迹。所以拿到高熵区之后,第一步不是想密钥,而是先算字节频次分布。
对于存储类数据来说,压缩后的数据通常每个字节出现概率比较平均,但如果密文里大量出现同一字节,或者所有字节集中在某些区间,就说明这个“高熵”不是真实压缩,而是 XOR 后的伪随机。我在目标文件中取了一段 1024 字节的样本,统计后发现值0x5A出现了 37 次,明显偏高。再对比上下文中的可见字符串片段,几乎可以确定整个数据区先被做了一次单字节 XOR,密钥候选就是0x5A或0x3C之类的常见值。
XOR 层特别喜欢和压缩层叠加使用。先压缩再加密,密文看起来更像随机数据;先加密再压缩,压缩率通常不理想。从实现角度来说,正常封装流程会先压缩后 XOR,因为压缩能去掉冗余。拆解的时候如果发现 XOR 放在外层、压缩放在内层,那是很合理的设计。
2.2 密钥爆破思路:单字节、多字节与已知明文
单字节 XOR 最简单,直接试 256 个可能值,哪个结果看起来最像明文就收工。判断“像不像明文”有两个指标:可打印字符比例和字节频次集中度。明文和压缩数据的可打印字符比例都不会太高,所以用可打印比例判断容易误判,更稳的是看解出来之后的结构特征。如果解出来的数据块头部出现了预期的路径字符串或字段名,那基本就坐实了。
多字节 XOR 要复杂一些,常见方法是把密文按候选密钥长度分列,然后逐列统计字节频次。每一列经过正确 XOR 后,频次最高的字节通常对应明文里最常见的字节。文本类数据常见字节是空格0x20或填充字节0x00,压缩流最常见字节则不确定。这时候“已知明文”就能起大作用:如果锚点表告诉我们数据块开头应该是某几个固定字符,直接把密文开头和明文开头对齐,密钥片段就露出来了。
def xor_candidate(data, key_byte): return bytes(b ^ key_byte for b in data) # 以路径列表尾部与数据区头部的密文字节为例 # 若已知明文开头应是 b'SCVR',则: cipher_start = buf[0x3000:0x3004] known_plain = b'SCVR' key_guess = bytes(c ^ p for c, p in zip(cipher_start, known_plain)) print(key_guess) # 例如 b'FEA9'这个办法对多字节密钥同样成立,只要你能定位任何一段连续已知明文,密钥片段就会逐字节暴露,剩下的就是按长度重复展开。
2.3 实操注意:别把 XOR 当压缩解,更别把压缩当 XOR 解
这是我最初踩过的大坑。有一回我把某配置文件的 CRC 校验字段、内容字节混在一起做 XOR 统计,得到一堆“看似规律”的数据,结果解出来的全是校验字节而非明文。后来想明白了:XOR 层的输入输出必须严格按文件逻辑边界对齐,如果中间拦腰截断,或者把校验字节也算进数据区,统计结果就会被污染。
所以在解 XOR 之前,必须先锚定“加密区间的准确范围”。推荐做法是先用上一节的字符串锚点表,标出明文区和密文区的分界线,再按分界线取密文样本。宁可多取几段相互验证,也不要贪方便一次读一大块。范围错了,密钥爆破结果也会错得离谱。
3. 校验层不是拿来算的,是拿来“同步”的
3.1 识别校验类型:看位置、看长度、看算法特征
拆掉 XOR 层后,很多文件会暴露出校验字段。校验字段通常有两种出现位置:头部或者尾部。出现在头部时,常见于“先校验后数据”的结构;出现在尾部时,多半是对整个数据流做流式校验。常见算法不外乎 CRC32、Adler-32、累加和、取模校验等。
识别方法很直接:先看长度。2 字节可能是简单 SUM;4 字节可能是 CRC32 或 Adler-32。再看数值变化特性。修改数据区一个字节,记录校验值变化。如果校验值在 4 字节内呈线性变化特征,大概率是简单累加;如果变化得完全没有规律,大概率是查表 CRC。用脚本批量试探比对更快:
import binascii, zlib def guess_checksum(chunk, observed): candidates = { 'crc32': binascii.crc32(chunk) & 0xffffffff, 'adler32': zlib.adler32(chunk) & 0xffffffff, 'sum16': sum(chunk) & 0xffff, 'xor8': 0, } for b in chunk: candidates['xor8'] ^= b for name, val in candidates.items(): if val == observed: return name return None3.2 分析期策略:让校验“闭嘴而不是消失”
拆封装做分析时,最忌讳的是强改数据后不对校验做处理。比如你解压完内层数据,修改了某个属性值,打算再压回去,如果不更新外层校验,目标程序加载时会直接拒绝文件。这就产生了一个常见需求:分析期要不要保留原始校验值?我的建议是,分析阶段不要删,也不要随便填零,先记录原始值。因为原始校验值本身也是密码学意义上的“指纹”,在校验算法或密钥不确定时,它能帮你确认封装逻辑是否判断正确。
有一种情况比较特殊:有些封装会在校验失败时走“回退默认值”的逻辑,不直接崩溃。这类程序反而好处理,分析期可以直接填一个不匹配的校验值,观察程序行为。但绝大多数存档加载逻辑是“校验失败即报错”,所以务必备份一份原始文件,每次实验后都能找回原始的校验字节。
3.3 封装期策略:按原逻辑重算校验值
等到实际改动数据时,就得老老实实重算校验。重算的关键不是调用库函数,而是确认四件事:校验的起始偏移、结束偏移、初始值、字节序。起始和结束偏移决定了参与校验的数据范围,差一个字节结果就全错。初始值有些算法固定为0xFFFFFFFF,有些是 0。字节序则决定了低位在前还是高位在前,直接关系到写回文件时的字节排列。
我在目标文件中遇到的是一个典型的 CRC32 校验块,位于数据块头部,长度为 4 字节,校验范围是“本段从第 8 字节到段尾”。一开始我按整个数据块算,算出来总是差一截,后来仔细看结构才发现,文件头还包含一个 4 字节的原始长度字段。把长度字段之后的区域单独切片再计算,校验值就完全对上了。换句话说,校验范围往往不是“整个文件”,而是“某个容器段”,必须按容器边界切片。
4. 压缩层处理:识别算法、对齐容器边界、无损还原
4.1 识别压缩算法:看魔数、看头部、看结构
XOR 层剥掉以后,真正的压缩数据块会显露出格式特征。常见的头部标志非常经典:zlib 流的起始字节多为0x78 0x9C、0x78 0xDA或0x78 0x01;gzip 流以0x1F 0x8B开头;LZ4 块以0x04 0x22 0x4D 0x18开头。如果都没有,就可能是个性化压缩或裸 deflate 流。
裸 deflate 流不带头部很麻烦,但可以用暴力试探法:用不同窗口大小的解压器逐个尝试,能成功解出且输出大小合理的那个就是真实参数。我在实践时写过一个小循环,依次尝试 zlib 解压和 raw-deflate 解压,并比较输出头部是否匹配预期字符串,命中率非常高。
4.2 边界对齐:原始长度字段和分块结构是关键
压缩数据外层一般会记录“原始长度”或“压缩后长度”。原始长度字段通常是一个 4 字节小端整数,紧挨在压缩流之前或之后。解压之前必须先把这个长度值读出来,否则解压器输出多少字节不知道,后续的结构体索引偏移根本无法计算。
还有一种情况是文件把数据切成多个压缩块,每块有独立的长度字段和校验字段。拆这种封装时要按块推进,不能把整段数据一次性喂给解压器。每块的流程是:读块头 -> 读压缩长度 -> 读取压缩数据 -> 解压 -> 拿到原始块数据 -> 按块索引拼接。这里有一个不常被文档提到的细节:块头里的长度字段有些是“从当前块起点算起的偏移”,有些是“压缩数据本身长度”,如果不注意,切片时差一个头长度,所有后续块都会错乱。
4.3 把整条链路串成自动化脚本
当 XOR、校验、压缩三层关系都确认后,就该把它们串成一个“拆封装函数”。我写的脚本核心流程如下:
import zlib, binascii, struct def unpack_container(data): # 假定最外层已剥去文件头 # 第0步:读取校验值与长度字段 checksum_expected = struct.unpack('<I', data[0:4])[0] raw_len = struct.unpack('<I', data[4:8])[0] # 第1步:校验范围从第8字节开始 payload = data[8:] actual_crc = binascii.crc32(payload) & 0xffffffff if actual_crc != checksum_expected: print(f'CRC mismatch: expected {checksum_expected:08X}, got {actual_crc:08X}') # 第2步:XOR还原 xor_key = 0x5A decrypted = bytes(b ^ xor_key for b in payload) # 第3步:调用解压 try: raw = zlib.decompress(decrypted) except zlib.error: raw = zlib.decompress(decrypted, -15) # 尝试裸deflate # 第4步:验证原始长度 if len(raw) != raw_len: print(f'Length mismatch: expected {raw_len}, got {len(raw)}') return raw脚本里故意保留了校验失败的输出分支,因为分析期需要知道有没有关错容器边界。校验不通过不代表解压不能用,但至少提醒你前面的切分有问题。等自动化脚本跑通并成功解出内部结构后,再决定是否在写回时补一套反向封装函数。
5. 实战记录:一条封装链路的完整还原过程
5.1 场景与目标
某单机游戏的存档文件,大小约 2.3MB,目标是把内部角色数据完整读出来,并确认能否改后重新封装。文件没有公开格式文档,只能逆向。
我在十六进制编辑器里打开后,第一眼看到的是一段SAVE_V1头标识和若干行配置文本,路径列表里有char/player和world/time之类的字段。用字符串提取脚本挤出全部锚点后,发现路径列表到0x2F80结束,0x2F80至0x3000之间有 128 字节的疑似头部,0x3000之后开始高熵区。高熵区占了文件剩下的大部分空间。
这个结构让人马上就想到:128 字节头部里可能包含校验、长度、块索引信息,而0x3000之后可能是 XOR + 压缩的混合区。
5.2 关键参数计算与验证
先读 128 字节头部,0x2F80 + 0x00处是 4 字节魔数0x13579BDF,0x04处是 4 字节大端整数0x0001C000,这意味着原始解压后数据长度为 114688 字节。0x08处是 4 字节整数,看起来像 CRC32,但一开始不知道校验范围。
取0x3000往后 16 字节,和头部里的值做异或试探,发现头部0x04处长度字段本身是明文,再对高熵区取样本做单字节 XOR 测试,密钥0x7C解出来的开头字节变成了0x78 0x9C。到这里谜底揭晓:高熵区原本是 zlib 压缩流,被单字节 XOR 掩盖。
解压出来的 114688 字节结果头部包含char/player路径字符串,与路径列表里的引用完全对应。说明整个链路:外层明文配置区 -> 容器头(魔数 + 长度 + 校验)-> XOR 层 -> zlib 层,每一环都验对上了。
5.3 校验范围的最终确认
当时校验始终差一截。我试了三种范围:整个容器、去掉头部、从0x3000到 EOF。最后发现程序实际校验范围是“从容器头之后到自身开头前一个字段”,也就是把前面读取的 8 字节排除在外,从索引0x3008开始算 CRC32。把输入切片改成data[8:]后,CRC 完全匹配。这提醒我,分析封装时最好把头部字段逐个拆开独立验证,不要图省事把整个头部当黑盒。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 字符串提取正常但后续全部乱码 | 密文区从某偏移开始,前面明文区是诱饵 | 把锚点表按“字符串集中度”分段,定位真正的密文起点 |
XOR 爆破后出现0x78 0x9C但仍解压失败 | 密钥周期判断错误,或切片时误把校验字节算入密文 | 重新核对密文区间边界,做多段样本交叉验证 |
| 解压后长度对不上头部长度字段 | 数据流可能分块,长度字段是“块长度”而非总长度 | 搜索整个密文区的重复魔数,找出块边界 |
| CRC 校验每次算都不对 | 校验范围含了长度字段或排除了特定容错字节 | 逐字节二分测试范围,结合已知明文缩小范围 |
| 单字节 XOR 爆破成功但数据仍不可读 | 可能是多字节密钥周期,只是恰好某一段碰巧 | 用已知明文延伸,推导完整密钥流 |
6.2 独家避坑心得
第一点,永远保留原始文件的校验和备份。分析过程中我会多次修改临时副本,但原始文件绝对不动。当脚本输出的 CRC 连续不匹配时,还可以回头确认是“文件被改过”还是“算法参数不对”,减少无意义的排查。
第二点,警惕“假高熵”。有的封装根本没有加密和压缩,只是垃圾填充字节太多,也会导致熵值看起来很高。区分办法是看局部重复模式:真实压缩流里基本不会有长重复字节串,而垃圾填充通常会有0x00或0xFF占主导的区块。
第三点,把“沿字符串引用”贯彻到底。不要拿到 XOR 密钥就把字符串锚点扔到脑后。字符串引用不只在分析阶段有用,在回写阶段同样是校验和压缩是否正确的重要验证标尺。压缩前的原始数据里那些路径和字段名,就是最终封装写回后最直观的“成色检验”。
第四点,XOR 密钥往往不是随机数。实现者通常为了调试方便,会选两字节、四字节的规律性密钥,比如0x5A5A、0x7C7C、0x12345678这类。爆破时先试长度 1、2、4、8 的常见值,再上穷举,能省大把时间。
最后分享一个小经验。这类多层封装其实特别怕“自解析”。只要你把字符串锚点表、密文边界、校验范围、压缩流位置这四个信息对齐,整条链路就只是一套顺序执行的字节变换。处理完之后,我通常会顺手把脚本里所有硬编码偏移改成由文件头字段推导,这样下次遇到同类封装,只要套用同一个流程,十分钟就能把新文件拆开。这就是“沿字符串引用还原存档封装”这件事最值得沉淀的方法论。