一次 ZIP 密码恢复实验:从已知明文到高速枚举

一次 ZIP 密码恢复实验:从已知明文到高速枚举

这次最终找回了密码:7 位小写字母。原始 ZIP 可以正常打开,完整 TXT 已提取,另外生成的去密码副本也通过了完整 CRC 检查和解密内容的逐字节比对,原文件没有改动。

最后一轮搜索在 M1 Max 的 CPU 上用了 29.3738 秒,检查了 5,481,468,352 个候选后命中。但整个探索远不止这半分钟。此前我试过密码变体、纯数字枚举,又拿着文本预览反复尝试已知明文攻击,其中一轮 175 个位置组合就花了约 32 分钟,全都失败。

密码找回后,我又在确认过的正确偏移上做了已知明文攻击,不输入已知密码或内部状态,也独立恢复出了相同状态,全文解密一致。前期的问题确实出在位置搜索上;枚举突然变快,则是因为我终于开始检查每个错误候选到底浪费了多少计算。下面只记录方法和匿名实验数据,不保留真实文件内容、密码或内部密钥。

先弄清楚这个 ZIP 里装的是什么

目标是一个约 70KB、内含单个中文 TXT 的加密 ZIP。检查结构后,我确认了两个决定后续路线的条件:加密方式是传统 ZipCrypto,压缩方式是 Store,也就是没有压缩。

ZIP 是容器,扩展名本身并不说明加密算法。同样叫加密 ZIP,ZipCrypto 和 AES ZIP 的处理方式不能混用;同样装着 TXT,Store 和 Deflate 的已知明文条件也不同。

这次 Store 特别有利:文件内容没有先变成压缩流,解密后的内容字节可以直接与文本字节对应。若使用 Deflate,加密前的“明文”是压缩后的字节流,仅有原始文本并不等于拿到了匹配的明文。bkcrack 的官方教程也区分了解密与解压这两个步骤。

传统加密数据区前面还有一个 12 字节加密头,不能把它和 TXT 正文混为一谈。本目标没有设置数据描述符标志,因此使用 CRC32 的高字节做密码头初筛。这个选择要结合 flags 判断,其他情形可能使用修改时间的检查字节,不能看到 ZIP 就固定取 CRC 高字节。格式细节见 PKWARE APPNOTE 的传统加密章节。

我始终保留原件,让提取文件和去密码 ZIP 写到新的输出文件里。

第一轮先排除容易想到的密码

最早试了少量常见密码,再扩展成约 1.4 万个相关变体,没有命中。接着用一个比较朴素的本地 C 程序,排除了 1~8 位纯数字,包括前导零。

这里的“包括前导零”很容易在实现中漏掉。固定长度的数字密码是字符串,0001 和 1 不是同一个候选,不能把它们都转换成整数后当作已经覆盖。

枚举规模按字符集大小和长度增长:

1
2
3
4
固定长度候选数 = 字符集大小 ^ 密码长度
9 位数字 = 10^9 = 1,000,000,000
10 位数字 = 10^10 = 10,000,000,000
7 位小写字母 = 26^7 = 8,031,810,176

照旧程序当时的速度,9 位、10 位数字和字母空间都显得不太划算。我于是转去尝试另一条路:既然能拿到部分文本,能不能绕开对密码本身的猜测?

已知明文恢复的首先是内部状态

ZipCrypto 用三组 32 位内部状态生成密钥流。密码逐字节更新初始状态;之后每处理一个明文字节,状态还会继续变化。解密过程可以概括为:

1
2
3
4
state = 用密码逐字节更新初始状态
对加密头以及后续密文的每一个字节:
plain_byte = cipher_byte XOR 从 state 生成的密钥流字节
用 plain_byte 更新 state

如果准确知道一段明文及其对应的密文,二者 XOR 就给出了那段密钥流。Biham–Kocher 类型攻击进一步利用 ZipCrypto 的旧算法结构恢复内部状态,而不是直接把 XOR 的结果当成密码。bkcrack 官方 README说明了这条路线:至少需要 12 字节已知明文,其中至少 8 字节连续。

恢复状态和恢复实际密码是两种结果。状态可以用于解密、生成去密码副本;要得到原密码,还需要额外恢复步骤。我的实际目标最终是通过枚举找到了密码,并不是已知明文攻击先恢复了状态。

这条路线对“知道内容”的要求比阅读意义上的相同严格得多:字节和位置都要对。UTF-8 的中文字数不是字节数,常见汉字通常占 3 字节;换成 GB18030,长度又可能不同。LF 与 CRLF、缩进和 BOM 也都会改变偏移。这里的 offset 指文件内容中的字节位置,不是第几个汉字,更不是整个 ZIP 文件中的物理地址。

有了完整预览,为什么还是没跑出来

我先得到部分预览,后来拿到了约 7,400 字的完整预览,使用 bkcrack v1.8.1 的 macOS arm64 版做匹配。

那时最怀疑的是预览和原文件之间存在格式差异,于是尝试了 UTF-8、GB18030/GBK,也试了繁体和 Big5;换行有 LF、CRLF,段间空行、Tab、全角空格、普通空格和无缩进都考虑过,还检查了 Markdown 星号标记、姓名分隔符等差异。正文前面可能有标题,因此还得猜正文起点。

为了排除工具用错,我做了一个已知密码的测试 ZIP,确认同样的方法能够恢复内部密钥,也验证了偏移参数的含义。工具和基本调用方式能工作,可实际目标仍然没有结果。

其中一次,光第一段的位置组合就试了 175 个,耗时约 32 分钟,全失败。那会儿我不断扩展编码和排版假设,觉得总有一处细节没对上,却没有及时发现最直接的问题。

成功解密后回看,预览第一段与原文件完全一致,编码就是 UTF-8。真正的正文起点是文件内容的零基偏移 392,前置内容占掉了这段空间。而此前围绕这一段的主要连续位置扫描只覆盖了前 256 字节,根本没有走到正确位置。

这不是“已知明文攻击无效”,而是我的搜索范围没有包含答案。也不能说全部历史测试都只做到 256:一些格式试探计划覆盖更大范围,但切换策略前没有完成。实际目标的这条路线在当时未成功;后来密码已经找回,才有条件确认真实偏移,并重新验证。

以后做这类测试,我会先记下明确的字节区间,再逐步扩大范围,而不是只凭“正文大概就在标题后面”判断。看起来已经试了很多次,不代表真正覆盖了最重要的假设。

枚举的瓶颈藏在错误密码后面

转折来自重新检查旧枚举程序。

它先解密密码头,检查一个字节;通过之后,就把约 70KB 的内容全部解密,再计算 CRC。流程能判断结果,但初筛太弱:随机错误候选大约有 1/256 的概率碰巧通过一个字节检查。

如果搜索十亿个候选,按这个近似概率,就会有约 390 万个错误候选进入下一步。每个再处理约 70KB,累计就是数百 GB 量级的数据处理。这里说的是重复解密和计算量,不是磁盘真的读写了这么多数据。

大部分工作其实花在明明很快就能看出不对的乱码上。既然目标已经确认是未压缩的中文 TXT,就没必要让这些候选全部走到文件末尾。

新的 C++ 程序用了 8 个 CPU 工作线程,按密码前缀分配任务,并递归复用共享前缀的状态计算。筛选流程改成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
遍历分配到的密码前缀及后缀
复用前缀对应的状态,完成当前候选的初始化
解密 12 字节加密头
检查字节不匹配 → 拒绝

最多继续解密 128 个内容字节
跟踪 UTF-8 与 GB18030 编码是否仍可能成立
容许带 BOM 的 UTF-16 分支
所有允许的编码分支都失败 → 拒绝

解密全文,检查完整 CRC
通过后输出待复核结果,停止其他搜索任务

编码检测是有状态的。一个多字节字符在当前截断点尚未收齐,并不等于编码错误,不能要求 128 字节刚好结束在字符边界上。UTF-8 和 GB18030 中只要还有一种可能成立,就继续保留候选。

这个过滤器有明确前提:文件是采用这些编码的文本。本次 Store 让它特别合适;二进制文件、其他编码或 Deflate 压缩流都不能直接套用。编码合法也不等于密码正确,它只是让错误候选尽早退出,最终仍要解密全文。

再把 CPU 真正擅长的指令用起来

完成前面的筛选优化后,我又把 CRC 表查找换成 Apple Silicon 的 ARM CRC32 指令,并把工作拆成更多前缀任务,改善线程间的负载分配。整个搜索用的是 M1 Max 的 CPU,没有用 GPU。

ARM 的 ACLE CRC32 intrinsics 文档提供了 __crc32b 等接口。这里要区分 CRC32 和 CRC32C:名字相似,多项式不同,不能随便换。硬件指令处理的是 CRC 状态更新,初始化与最终处理仍要与原算法保持一致。

我没有只看速度数字。验证使用自建、知道密码的 ZipCrypto ZIP,内容覆盖 UTF-8、GB18030、带 BOM 的 UTF-16,也测试了前导零密码。随后故意翻转密文末尾的一个字节,确认前面即便能通过初筛,损坏文件也不会被程序报告成成功。

这一项负例很有必要:如果程序只检查开头,看起来再快,也可能是在快速接受错误结果。

最后一轮只用了半分钟,但不是整个实验只用了半分钟

几轮实际运行的结果如下:

搜索范围 实际检查候选数 耗时 结果与版本
全部 9 位数字 1,000,000,000 14.5702 秒 未命中,快速筛选版
全部 10 位数字 10,000,000,000 142.021 秒 未命中,快速筛选版
7 位小写字母 5,481,468,352 29.3738 秒 命中,加入 ARM CRC 与进一步任务切分的版本

9 位和 10 位数字的测试发生在后续 ARM 指令、任务切分优化之前,不能把三行速度当成同一程序对不同字符集的公平比较。

7 位小写字母的完整空间是 8,031,810,176,约 80.3 亿;命中前检查了约 54.8 亿,并没有遍历完全部空间。上面的 29 秒只属于最后这一轮,前面的字典尝试、位置匹配、失败搜索、编写程序和验证都不在里面。

这次能成功,依赖短密码、ZipCrypto 较低的单次验证成本,以及未压缩文本提供的快速拒绝条件。字符集扩大或长度增加后,指数增长仍然在那里。AES ZIP 也不适用这套 ZipCrypto 状态攻击,需要另看具体格式和密码派生的成本。

我最后怎样确认真的恢复了

命中后,我先用找到的实际密码打开原始 ZIP,提取完整 TXT,再通过恢复的内部状态生成一个去密码 ZIP。两个路径得到的内容做了逐字节比对,结果一致,完整 CRC 也通过;预览第一段同样与恢复内容一致。剩余搜索进程全部停止,原始 ZIP 保持原样。

CRC32 是误码检测,不是加密认证,也不是密码学散列。它适合发现这次实验里的解密错误和文件损坏,但不能提供“文件没有被恶意修改”的保证。一个密码头检查字节、看起来合法的编码,甚至一小段能读懂的开头,都不够让我宣布恢复完成。

这也是为什么我保留了正常解压、全文检查和内容复核几个步骤,而没有把程序的一行“命中”当作终点。

事后回看:正确偏移下,已知明文也恢复了相同状态

确认正文从第 392 字节开始后,我又回到了 bkcrack。这次使用先前保存的原始加密字节流,以及预览中第一段后部的 606 字节连续明文。片段比正文起点晚 40 字节,所以对应的零基偏移是 392 + 40 = 432。

这次攻击的输入只有密文、已知明文、正确偏移和 ZIP 头检查字节,没有把已经知道的密码或三组内部状态作为输入。结果独立恢复出了与密码枚举相同的三组状态。随后用它解密保存的密文,整篇字节与之前验证过的输出完全一致。

这次复核把前面的解释补实了:已知明文路线对这个目标确实有效,前期重点连续扫描没有覆盖正确位置。它也不是一个纯粹从未知条件重新开始的实验,因为正确偏移来自成功解密后的确认。最先找回密码的仍然是高速枚举;事后攻击验证了另一条路线能够恢复状态,没有改写第一次成功的过程。

下次我会按这个顺序做

  1. 保留原件,先检查条目、加密方式、压缩方式和 flags。可以先用 bkcrack -L sample.zip 查看信息,不急着猜密码。
  2. 用自建样本验证工具和解析方式,特别是加密头与正文偏移的区别;任何快速过滤都要同时测正常样本和损坏样本。
  3. 先试有依据的小规模候选。开始大空间搜索前,测一小段真实性能,并记录程序版本、字符集、长度和完成范围。
  4. 有已知明文时,按字节保存和比较,分别管理编码、格式、位置三个变量。把已覆盖的位置区间写下来,不把“尝试次数很多”当成覆盖充分。
  5. 枚举慢时,先看候选通过初筛后的成本。依照目标格式增加便宜的拒绝条件,之后再考虑并行、共享前缀和硬件指令。
  6. 命中后正常解压并做全文复核,输出新文件,停掉剩余任务。失败也留下明确的范围,避免下次重复跑同一片空间。

这次最想留给以后的提醒很具体:先确认偏移单位,再确认搜索范围;先看错误候选做了什么,再决定枚举是不是太慢。第 392 字节和那批不该进入全文验证的错误密码,比最后的半分钟更能解释这次实验。

作者

Khan

发布于

2026-10-11

更新于

2026-10-11

许可协议