1. 先搞清楚这个存档到底是什么做数据还原的同行应该都遇到过这种情况手里拿到的不是一份普通文件而是一个被层层处理过的存档封装。表面上它是一个二进制块用十六进制编辑器打开前几十个字节完全看不出规律直接搜索关键词也搜不出有意义的内容。可是偏偏在某些固定偏移的位置能读到一段连续的可打印字符像是一条路径、一个标记名甚至是一段配置文本——这就是我常说的“字符串引用”。它们就是整个封装里最宝贵的锚点。这篇文章要聊的就是顺着这些字符串引用把一层层套上去的 XOR 混淆、校验值保护和压缩层一步步拆开最终还原出原始封装的真实内容。适合正在跟存档格式、资源包、老旧数据文件较劲的开发者也适合刚接触二进制格式分析、想建立一套完整拆解思路的学习者。整个过程不依赖某个特定工具核心是思路先从字符串引用定位内部结构再判断每一层的处理方式最后用脚本把它们逐层剥掉。1.1 为什么字符串引用是突破口很多封装格式在设计时有一个通病外层套了加密或压缩但内层结构暴露了太多语义信息。比如某个资源包外层做了 XOR 混淆但里面的文件表项里保存了原始文件名又比如某个存档文件整体压缩过但文件头部的魔数没有变。这些看起来不起眼的字符串实际上直接暴露了“原始内部布局”的线索。字符串引用的价值在于它能帮你回答三个问题原始数据是什么类型看到.png、.dat、.json这类后缀基本能判断内部结构。内部数据是不是完整保留了如果字符串是连续的、可读的、成体系的说明封装层可能只是简单混淆并没有彻底破坏语义。分层顺序是什么字符串出现的位置、密集程度、周围二进制特征能反推外层处理是整体施加的还是分块施加的。我在实际操作中第一步永远是打开十六进制编辑器在全文范围内搜索长度大于 4 的 ASCII 字符串。搜索结果里如果出现密集的、有逻辑关系的字符串簇就说明这个封装大概率不是强加密而只是“防君子不防小人”的混淆级别拆开的难度会低很多。真正难处理的是那种把字符串表单独加密、引用关系打散的情况但那已经属于另一层级的对抗了不在本文的讨论范围内。1.2 封装层次的基本模型一个典型的存档封装从内到外通常是四层结构原始数据层未压缩、未混淆的真实业务数据可能是 JSON 文本、序列化对象、图片数据或自定义二进制结构。压缩层对原始数据做压缩常见算法有 zlib、gzip、LZ4、LZMA目的是减小体积。校验层在压缩后的数据块上追加 CRC、校验和或哈希值用于完整性校验也可能反向加在校验层内部。混淆层对整个数据块做 XOR、字节置换或简单流密码处理目的是让文件无法被直接识别。字符串引用大多数时候指向的是最内层的原始数据但它穿过了外层处理之后还能被读到只能说明一件事某个环节并没有把所有数据都处理掉。最常见的情况是XOR 混淆只做了一遍而字符串恰恰是 XOR 之后仍保持可读性的残余片段因为单字节 XOR 对英文字母的位移在一定密钥下仍然落在可打印字符区间。另一种情况是XOR 作用在压缩后的数据上但因为压缩流里有部分字典信息残留导致个别字符串片段逃过了混淆。理解这个模型的价值在于你不需要从零开始猜。先判断字符串出现在哪个层、和哪一层特征相邻就能推断出封装顺序。我在实际分析中会画一张很粗糙的“层次推测表”列出偏移区间、可读信息、字节统计特征然后才动手写拆解脚本。2. 工具选型与前置准备工欲善其事必先利其器。拆封装这件事工具太多反而麻烦我的建议是只准备三类东西一个十六进制查看器、一个脚本环境、一个对比工具。这三样足够覆盖绝大多数场景不需要一开始就上重型逆向平台。2.1 十六进制查看与内存对比十六进制工具我通常用两类。一类是图形化的比如支持分块高亮、字符串提取、文件对比的工具适合做肉眼初筛。另一类是命令行式的xxd、hexdump适合在脚本里直接调用做自动化提取。操作上有一个值得养成的习惯拿到文件后先用十六进制工具生成一份完整的字节分布报告看每一段的熵值变化。熵值高的区域通常是压缩或加密数据熵值低的区域往往是明文、表格或填充字节。字符串引用密集出现的区域熵值一般偏低这是很好的层边界提示。所谓内存对比是指把“当前文件”和“剥掉某一层之后的中间结果”进行并排比对。这一步非常关键因为有时你剥掉一层之后新生成的文件依然不是最终结果还需要重复分析。对比能直观看出每一层处理到底改变了什么、保留了什么。比如某一段在剥离前全是乱码剥离后出现了连续00说明填充逻辑被还原了。注意拆封装过程中每一步都要保留中间文件。别怕磁盘占用量宁可堆一堆临时文件也不要反复从原文件重来。每层处理都可能引入不可逆的误操作有中间文件才能回溯。2.2 脚本语言选型不要迷信语言选你最快能出结果的那个。我个人习惯用 Python主要是三个原因bytes类型自带十六进制转换、切片、拼接处理二进制非常顺手。生态里已经有zlib、lzma、struct、binascii这些标准库覆盖大部分压缩与编码需求。可以用交互式环境做逐段验证边猜边写代码效率极高。但也有一个反例当文件特别大、动辄几百 MB 时Python 的逐字节循环会慢得让人崩溃。这时候要么用numpy做向量化操作要么直接改用 C 语言写一个小工具或者把文件分块后用多进程处理。XOR 运算本身是逐字节的但在 Python 里可以用bytes(a ^ b for a, b in zip(...))也可以借助bytes.translate做查表加速。对初学者来说先不用管优化能跑出结果最重要。2.3 构建一个最小分析环境我建议在工作目录里固定放三个文件原始存档文件命名origin.bin这个文件永远只读不做任何修改。拆解脚本命名unpack.py所有逻辑都写在这里。输出目录命名out/每一层结果都输出到独立的子目录。这样做的目的是避免“原地修改”带来的灾难。我以前踩过坑直接在原始文件上做 XOR 写回结果密钥判断错误整个文件被毁连重新分析的机会都没有。从那以后就坚持“只读源文件只写输出目录”的原则。分析环境搭好后下一个问题就是怎么判断第一层到底是什么先看一眼文件的字节分布再结合字符串引用位置基本能判断。3. 第一层XOR 混淆层的拆解3.1 识别 XOR 特征的几个信号XOR 混淆是存档封装里最常见、也最好拆的一层。它的特点很鲜明字节分布接近均匀但和真随机不一样。真随机数据的每个字节出现概率几乎相同XOR 单字节密钥处理后的数据仍保留原始分布的痕迹只是被平移了。如果密钥很短比如 1 字节高频字节仍然存在只是位置变了。对已知明文敏感。任何已知的字符串引用、文件头魔数、JSON 大括号都可以用来反推密钥。识别时我会先做一个频率统计画出前 20 个高频字节。如果文件是文本压缩后再混淆的频次分布会相对均匀如果是未压缩文本直接混淆的频次分布会有明显的峰。这个峰值和原始文本的高频字节比如空格0x20、字母0x65、0x74之间做 XOR通常就能猜出密钥。另一个信号是“XOR 后仍然是合法文件头”。比如 PNG 文件头是固定的89 50 4E 47如果某段数据 XOR 单字节后出现了这个魔数那这个 XOR 密钥就找到了。实际操作中并非所有文件都有如此明显的魔数所以更可靠的方法是利用字符串引用反推。3.2 单字节密钥的还原方法假设文件整体被一个单字节密钥 XOR 处理且我们已知某段字符串引用的明文片段。比如在偏移0x2F40处读到一段可读字符串内容是level_data但它实际可能是由某个明文段落经过 XOR 得到的残留这时不能直接用它当明文。更常见的场景是字符串引用的位置本身就是明文残留没有经过 XOR。此时要判断的是它周围的数据。单字节密钥还原的核心公式很简单key cipher_byte ^ plain_byte如果你能确定某个字节在明文中的值那么这个字节的密文和明文异或就能得到密钥。实际操作中我会找多种已知明文来验证同一个 key避免巧合。比如想验证 JSON 文本是否被单字节 XOR 处理就取一段密文数据分别假设它是{、}、、空格然后异或得到候选 key再用这些候选 key 去解整段数据看哪一段能解出流畅文本。有一个很实用的技巧把前 256 个字节和 0 到 255 的每个 key 做一次 XOR然后统计结果中的可打印字符比例。比例最高的那个 key大概率就是真实密钥。这个方法我在很多没有已知明文的场景里用过命中率很高。3.3 多字节密钥与流式密钥如果单字节密钥解出来之后只有部分区域可读其他区域仍然是乱码说明可能是多字节密钥或者密钥是分段的。多字节密钥的判断方法是取一段已知明文和对应密文两者等长异或得到的就是密钥流片段。如果密钥流有重复周期说明是周期密钥如果完全没有重复说明是流密码生成的一次性密钥流拆解难度会大幅上升。常见的多字节密钥形式有固定长度周期密钥比如 4 字节、8 字节循环使用。由某个种子通过伪随机数生成器扩展出的长密钥流。与文件偏移相关的动态密钥每个块的密钥不同。对于周期密钥处理方式很简单把密钥周期长度猜出来然后按周期对每个偏移取模逐字节异或。关键问题是怎么猜周期。我常用的方法是“重合指数法”或者“按候选周期对文件分块后比较字节分布”。更暴力一点的方法是直接尝试 2、3、4、5、6、7、8、16 等常见长度对每个候选周期把同一位置的字节聚成一类做频率分析看哪一类恢复出的可打印字符比例最高。动态密钥更麻烦通常和文件偏移、块序号有关。这种情况下字符串引用依然能帮你如果某个块内部存在已知明文反推出该块的密钥再对比不同块的密钥找出生成规律。3.4 一个实操心得XOR 解析不要过早收手我遇到过一个存档文件用单字节密钥0x7A解完第一段后可读字符串全部出现了以为大功告成。结果继续解到文件尾部时发现字符串开始出现乱码再一查原来这个文件前 500 KB 用0x7A后半段用0x3C中间有个切换偏移。这就是典型的“分段密钥”设计如果我只是抽样检查前部可读就收手后面整个数据都会解错。所以我的习惯是每次 XOR 解开后不只检查字符串而是对整个文件做一次“可读性扫描”统计所有区间的可打印字符比例。如果某个区间突然低于阈值就要警惕那里可能用了不同的密钥。4. 第二层校验层的处理XOR 层剥掉之后你得到的通常是一个包含压缩数据和校验值的数据块。在校验层这里很多人的第一反应是“直接重算校验绕过就好了”但实际操作中要小心校验值往往起到“门闩”作用如果你只是绕过它而不理解它的位置后续解压时可能会踩到数据截断的坑。4.1 校验与封装的真实顺序想理解校验层先要搞清楚它到底校验了什么。常见有两种封装顺序先压缩后校验压缩数据整体或分块计算校验值然后追加在压缩块尾部。这种顺序下校验值是在压缩数据之后生成的它的位置通常在一个块的末尾值为 2 字节或 4 字节。先校验后压缩原始数据先算校验值然后连同校验值一起压缩。这种顺序下你在压缩层内部解压完后才能看到校验字段。判断顺序的一个技巧是看压缩块的长度字段和校验值的位置。如果一个数据块尾部有 4 字节的 CRC而这个 CRC 正好等于对前面压缩数据的 CRC 计算结果那就说明是先压缩后校验。如果解压后内部还有一个校验字段说明是先校验后压缩。实际工作的重点是我们不能只“绕过”校验因为后续可能还要重新生成一个合法的存档。如果目标是还原内容校验可以直接重算如果目标是修复存档校验的算法和位置就必须完全复刻。4.2 常用校验算法识别存档里常见校验算法就那几种CRC16/CRC32最常见的校验方式多项式固定有现成库可以算。简单累加和将每个字节求和后取低 16 位或低 8 位。优点是计算极快缺点是容易冲突。Adler-32zlib 内部使用的校验看到它基本能联想到后面跟着的是 zlib 压缩流。哈希截断把 MD5、SHA-1 或 SHA-256 的结果截取前几个字节作为校验值相对少见。识别方法不复杂先把校验值和所在区域的长度对应起来。比如文件里某个块尾部有 4 字节剥掉这 4 字节后前面对应数据的计算结果跟它一致那它就是这个算法。我在实践中通常是“猜算法 验证”并行先用binascii.crc32试一遍再试zlib.crc32再试累加和多数情况下第一轮就能匹配上。4.3 校验绕过与重算校验的重算有一个容易犯错的点CRC 的初始值、输入字节顺序、输出字节顺序。不同的实现使用不同的初值和反射设置导致同一个数据在不同工具里算出的 CRC 并不相同。举个例子某个帖子里说 CRC32 是0xFFFFFFFF初始值但另一个工具用的是0x00000000初始值两者结果完全不同。所以如果第一次算出来不匹配先别怀疑数据错误试试换初值、换字节序、换多项式。我的经验是先把常见组合列成一个表逐个试匹配上了再继续。算法常见初值输出字节序典型应用CRC320xFFFFFFFF大端标准 zlib 流CRC320x00000000大端某些自定义格式CRC320xFFFFFFFF小端网络协议存储累加和0x0000小端极简校验校验重算的另一个场景是“修复存档”。有时文件里某个块损坏了但 XOR 层和压缩层都正常我们只需要把损坏块的校验值重新计算并填回原位置。这个操作有一个前提损坏块的原始数据必须是完整的只是校验值过期。如果数据本身有缺损那问题就变成数据恢复和校验重算完全是两码事。5. 第三层压缩层的还原走到压缩层意味着前面所有的混淆、校验都已经被剥掉了。此时文件内容应该是一个完整的压缩流或者是由多个压缩块组成的复合体。这一层的核心任务就是两件事识别压缩算法找到正确的解压边界。5.1 识别压缩算法识别压缩算法的第一线索是文件头。常见压缩流都有可识别的头zlib 头前两个字节通常是78 01、78 5E、78 9C分别对应不同的压缩级别。gzip 头前两个字节是1F 8B后面跟标志位、时间戳、操作系统字节。LZMA 头前六个字节是属性、字典大小、未压缩大小。LZ4 块没有固定头通常以 4 字节长度字段开始后跟压缩块。如果没有看到标准文件头别急着失望。很多自定义压缩格式会把标准压缩流的头去掉或者分成多个小压缩块每个块自带长度信息。这时就要切换到“找长度字段”的思路在数据中搜索 4 字节数值该数值恰好等于它后面的数据长度那它很可能就是一个压缩块的长度前缀。我常用的一句话是压缩层识别靠“头”边界划分靠“长度”。两个手段交叉使用很少失手。5.2 流式解压与分块解压解压方式的选择取决于压缩数据是“整体压缩”还是“分块压缩”。整体压缩数据以单一压缩流存在直接喂给解压库即可。分块压缩数据被切为多个块每块独立压缩块与块之间可能有长度表或间隔区。分块压缩的一个典型特征是直接在文件里搜索某种压缩特征会得到多个命中位置。此时必须先解析出“块表”或“块偏移表”才能逐块解压。有些格式的块表本身就是由字符串引用指引的例如块表里保存了解压后的文件名或标识符这正是“沿字符串引用还原”这个标题的核心场景。解压的实操步骤大概是定位压缩数据起始偏移。读取长度字段或块表得到每个压缩块的范围。逐块解压把解压结果按顺序拼接。如果拼接结果仍然不是期望的最终文件考虑是否存在“多层压缩”或“压缩内部还有格式结构”。5.3 解压失败的常见原因解压失败时第一反应不要是“算法猜错了”而应该检查解压起始点是否正确。压缩流的起始点差一个字节解压结果就是乱码或直接报错。另一个常见问题是压缩块之间存在“跳过区”。比如每个压缩块后面跟随了 16 字节的校验值或元信息这些字节不属于压缩流直接拼接后喂给解压库必然失败。正确做法是先按偏移表逐个解压再拼接输出。遇到过最隐蔽的问题是压缩块的长度字段是“压缩后大小”但某些格式里它包含块头本身的长度另一些格式则不含。这个差异会用肉眼无法察觉只能靠“试错”确认先按“包含”解一次如果不匹配再按“不包含”解一次。6. 实操完整还原一个模拟存档6.1 案例背景与分析起点用前面讲的方法走一遍完整流程。假设有一个存档文件archive_001.bin大小约 1.2 MB。用十六进制编辑器打开后全文搜索字符串在偏移0x000001C8处发现连续字符串片段map_data在0x000004A0处发现config.json在尾部偏移0x0011F800附近发现大量可读 JSON 片段如{name:...}。这个现象说明了什么如果是整体压缩的存档不可能在压缩流里直接搜到 JSON 文本。能搜到这些内容说明压缩层已经被处理过或者数据本身分为多个块其中部分是明文块部分是压缩块。这已经是“混合封装”的典型特征。先看文件头部 128 字节分布如下0x0000到0x0003A5 5A A5 5A重复标志可能是固定魔数或 Xor 混淆后的魔数。0x0004到0x000700 00 06 00这个数值是 1536后面要看它是不是某个长度字段。0x0008到0x000F71 63 7A 65 70 69 6A 6B没有明显语义偶尔有可打印字符。此时我先把前 16 字节当成一个可疑的整体结构4 字节魔数 4 字节长度 8 字节未知数据。继续往下看0x0010之后开始出现零散字符串但多处又是乱码这种“一半可读一半乱码”的混合状态强烈暗示 XOR 混淆很可能是分段进行的且某些区段没有被混淆。6.2 脚本拆解演示先写一个 Python 脚本用来统计字节分布和字符串引用位置。这是每轮分析的第一件事。import re from collections import Counter data open(archive_001.bin, rb).read() # 统计高频字节 freq Counter(data) print(top 20 bytes:, freq.most_common(20)) # 提取可读字符串 strings re.findall(rb[\x20-\x7E]{6,}, data) for s in strings[:30]: off data.find(s) print(foffset 0x{off:08X}: {s.decode(ascii, errorsreplace)})运行后输出了很多字符串引用字段包括map_data、config.json、tile_sheet等。此时我基本可以确定文件内部存在清晰的文件表结构且这些字段位置附近的数据应该是可以按表解析的。接下来对每一段可疑数据做 XOR 密钥猜测。取0x0008处的 8 字节与常见明文map_data的前 8 字节做 XOR得到密钥流明文: 6D 61 70 5F 64 61 74 61 密文: 71 63 7A 65 70 69 6A 6B XOR: 1C 02 0A 3A 14 08 0E 02这不是一个固定重复密钥说明文件头部的混淆不是简单单字节 XOR。但注意最后一个字节是02和第一个字节1C不同密钥流不像周期重复。这个现象让我怀疑头部数据经历了两层处理先对个别字节做替换再做全局 XOR或者根本是多字节密钥叠加了压缩。我不急着下结论。直接把这份 8 字节密钥流当作候选去解整块头部数据如果解出来是大段可读文本那说明密钥流是全局的如果只解开头一段后面又乱掉说明头部可能是“先明文、后混淆”的结构。这里要强调字母频率相对保守单组已知明文推导出的密钥流不一定可靠必须用多组已知明文交叉验证。于是我把文件里出现的config.json也当作候选明文找到它在密文中对应的偏移再做一次 XOR看两组密钥流是否一致。结果发现不一致于是判断头部存在分段处理。接下来我用第二段字符串引用的位置作为边界把文件切成多个区段分别做频率分析。分段结果如下区段 A0x0000 - 0x00FF高熵推测混淆。区段 B0x0100 - 0x05FF中等熵存在密集字符串推测半明文。区段 C0x0600 - 文件尾高熵推测压缩流。于是决定先不管区段 A直接从区段 B 的字符串表入手解析文件表结构。6.3 利用字符串表定位压缩块解析文件表结构后发现每个表项包含四个部分文件名、偏移量、长度、校验值。用脚本读取表项就能得到压缩块的精确位置。import struct # 假设文件表在 0x0100 开始每项 32 字节 entries [] for i in range(16): base 0x0100 i * 32 name_bytes data[base:base16].split(b\x00)[0] name name_bytes.decode(ascii, errorsignore) offset, length, crc struct.unpack(III, data[base16:base28]) entries.append((name, offset, length, crc)) print(name, hex(offset), hex(length), hex(crc))输出结果里map_data指向偏移0x00000600长度0x0003A000。这段数据大概率是压缩块。把它单独切出来block data[offset:offsetlength]对块的前 4 个字节做检查发现是78 9C这正是 zlib 压缩流的标志。直接解压import zlib out zlib.decompress(block) print(len(out))解压结果长度正常。解压后的数据里已经能看到完整的 JSON 配置和地图数据。但这还不是最终还原因为存档里还有其他文件块它们可能用了不同的压缩算法或不同的混淆密钥。6.4 逐块解压与组装把文件表中所有条目都解压一遍输出到out/目录。遇到某个块解压失败就单独拿出来查。结果发现有一块的前四个字节是04 22 4D 18这是 LZ4 块的魔数。改用 LZ4 解压后顺利还原。最终组装出一个目录里面有map_data、config.json、tile_sheet、逻辑脚本等文件。从字符串引用到文件表再到解压还原这条链路完全打通。这个例子说明了一件事字符串引用并不仅仅是“线索”它往往直接对应文件表项的名字字段。只要顺着文件表解析就能精确绕过复杂的全文件扫描直接拿到每个压缩块的位置和长度。6.5 解压后的验证与完整性核对还原完成后不要急着收工。我做的第一件验证是把解压后的文件重新压缩和原始压缩块做字节对比。如果一致说明解压正确边界无误。第二件验证是逐个打开还原出的文件检查文件头魔数、末字节是否正常确认没有截断或多余填充。如果某个文件解压后末尾多了一堆00大概率是压缩块长度包含了对齐填充需要在组装时去掉。如果末尾缺了东西则可能是长度字段不含尾部校验对不上的问题要到校验层里找原因。7. 常见问题与排查心得7.1 常见问题速查现象可能原因排查方向全文搜不到字符串外层有强加密或整体压缩先看熵值分布找块边界字符串散落但不成簇分段 XOR密钥不同按偏移分桶做频率分析XOR 解出部分可读但整体乱多层混淆或密钥流生成方式复杂找更多已知明文交叉验证校验值重算不匹配CRC 初值/字节序/多项式不匹配列组合表逐个试压缩解压报错起始边界不对或块间有跳过区按偏移表逐块解勿整段拼接解压成功但文件打不开长度含填充或内部还有子封装检查尾部填充重复分层分析7.2 我的三点体会第一点永远不要高估封装设计者的水平。很多存档封装做得非常粗糙字符串引用大量残留文件表结构清晰可见连压缩算法都没换。遇到这种文件耐心解析文件表远比暴力扫描效率高。第二点手头一定要有一套“按偏移处理、可复现”的脚本。拆封装的过程中你会反复试错、反复改密钥、反复调边界。如果脚本是到处粘贴的一次性代码改到后面自己都乱了。最好的做法是把每一步处理做成独立的小函数输入偏移区间、输出结果文件这样每次调整只需要改参数。第三点遇到不对劲的地方宁可停下来多验证也不要强行往下走。比如某个块的 CRC 算出来不匹配可能意味着数据损坏、长度字段理解错误、或者块边界判断错误。这时候硬绕过去后面组装出来的文件迟早出问题。7.3 一个值得一试的扩展方向拆完一次封装之后我建议你顺手把“重新打包”也做了。这不只是为了验证拆解是否正确更是为了彻底理解那个格式的设计逻辑。重新打包时你会遇到拆解时没注意到的细节比如文件表的偏移是相对文件头还是相对数据区比如长度字段是否包含校验值比如压缩级别会不会影响 CRC 结果。走一遍反向流程对格式的理解深度会明显不一样。具体来说拿到解压后的文件列表你可以尝试写一个打包脚本压缩每份数据、计算校验、填入文件表、应用 XOR 混淆。如果这份重新打包的文件能被原程序正常读取那你的还原工作就真正闭环了。如果读不了仔细对比原始文件和重新打包文件的差异通常会有意外收获。