☰
CRC32碰撞爆破:从ZIP文件头恢复短内容与压缩包密码的CTF实战
2026/10/21 8:36:57 网站建设 项目流程

1. 先搞清楚CRC32为什么能“反推原文”

1.1 CRC32到底是什么,它和隐藏密码有什么关系

CTF里有个很经典的操作:题目塞给你一个压缩包,你解压发现里面有个文件打不开——或者打开是空的,但压缩包的信息里却明晃晃写着这个文件的CRC32校验值。再配合一句提示“密码藏在CRC32里”,很多人当场就懵了:CRC32不是用来校验数据完整性的吗?怎么还能藏密码?

实际上CRC32确实不能“解密”,它不是加密算法,给不出什么密钥。它就是一个32位的循环冗余校验值——你可以把它理解成数据的“短指纹”。问题在于,这个指纹只有2的32次方种可能,也就是大约43亿个值。听起来数量不小,但如果被校验的原始内容本身非常短,比如4到6个字节,那事情的性质就变了:4字节的可打印字符组合数大约95的4次方,约8千多万种;5位纯数字只有10万种。枚举完这些组合,逐一计算CRC32,再和目标值比对,总能找到对应的原文。这个思路叫碰撞爆破,本质上是“已知哈希值,暴力枚举明文空间”。

那这和压缩包有什么关系?ZIP格式在设计时,会在每个文件的文件头里记录原始数据的CRC32值,用作解压后的完整性校验。更关键的是,即使压缩包本身是加密的,或者文件内容被从压缩包里“挖掉”只留一个空壳,这个CRC32字段依然存在、依然可以被读取。CTF出题人正是利用这一点:把一个小文件的内容藏起来,只留下CRC32,让你通过碰撞把它“找回来”。找回来的往往就是下一层压缩包的解压密码,或者是直接作为flag的一部分。

1.2 为什么爆破CRC32在“短内容”场景下特别靠谱

要判断这个方案是否可行,只需要估算一个数:枚举空间有多大。枚举空间等于字符集的字符数乘以长度的次方。

举个例子,如果题目告诉你密码是5位纯数字,那么候选总数就是10的5次方,也就是10万种。这个数量级在普通Python脚本里几乎是一瞬间完成的事,你甚至不需要考虑性能优化。如果密码是4位大小写字母加数字组合,候选总数是62的4次方,约1470万种,单进程跑也就几十秒内完成。如果是4位全部可打印ASCII字符(95种),候选空间是95的4次方,约8145万种,单进程需要十几分钟,但用上多进程可以压缩到两三分钟。

真正让爆破变得不可行的是长度变长。6位可打印字符的组合数是95的6次方,约7.35亿亿,这个空间用枚举就完全不现实了。所以你在实战中看到能用CRC32爆破压出来的题目,原始内容通常被限制在4到6字节。这是出题人可控的范围,也是解题人的机会。

我的经验是:拿到题目先不要急着跑脚本,先用数学估算一下枚举空间。如果空间小于1亿,放心爆破;如果超过10亿,大概率是方向不对,应该换个思路,比如去找题目里别处的提示,或者看看是否给出了字符集约束。这个判断意识比脚本本身重要得多。

1.3 ZIP结构里CRC32藏在哪里

了解ZIP文件结构对后续操作有实际帮助。ZIP文件由一系列文件条目组成,每个条目的开头是本地文件头,偏移0处是文件头签名(PK\x03\x04),偏移14处有一个4字节的CRC32字段。稍后还有一个中央目录,里面同样保存了CRC32。正常用Python的zipfile读取时,每个ZipInfo对象的CRC属性就是以一个整数形式表示的这个值,已经帮你做了大小端转换,直接用即可。

但如果题目故意把文件头破坏、或者只给你一段CRC32的十六进制字符串,你可能需要手工解析二进制文件。这时候用struct.unpack('<I', data[14:18])就能从本地文件头中取出CRC32。注意这里必须用小端格式'<I',因为ZIP规范里CRC32是以小端存储的。很多人在这里踩坑,直接读出来一个大到离谱的数字,那就是没做大小端处理。

2. 动手前先判断题目是不是“这类型”

2.1 四类典型特征,命中两条以上就可以动手

不是所有压缩包相关题目都适合CRC32爆破。我在实战里总结了一套快速判断标准,满足两条以上,基本就可以锁定方向。

第一,压缩包内存在体积极小的文件,通常只有几个字节。因为CRC32碰撞的前提就是内容短,如果文件是几百KB,那说明CRC只是辅助信息,不是解题路径。第二,文件内容无法正常读取——解压报错、内容为空、或者解压需要密码但密码未知。第三,题目文本里出现了“CRC”“校验”“压缩包信息”之类的关键词。第四,压缩包存在多层嵌套,内层的密码看起来像是很短的字符串。

判断正确与否直接决定你的时间花得值不值。我有一次在某内部交流赛里碰到一个压缩包,里面有个readme.txt,明文写着“密码是隐藏文件的校验值”,另一个文件只有2字节且内容为空。当时我第一反应是看文件信息,发现2字节文件的CRC32还在,于是直接套用爆破脚本,几秒就把2字节内容跑出来了,用这个字符串解开了下一层的加密压缩包。

2.2 一个典型题目的完整链路长什么样

这种题目的结构其实很固定,反复出现。最常见的一种是两层设计:外层压缩包里面有两个文件,一个是提示文件,明文写着类似“zip密码为hidden.bin的内容”的话;另一个文件非常小,但内容在压缩包内部被清空了,或者被故意加密。解题时你要从这个“空壳文件”的CRC32里恢复出它的原始内容,这个内容就是内层压缩包的密码。

另一种常见变体是压缩包里只有一个文件,且文件内容确实存在,但它本身就是被压缩的,你解压之后发现文件里是一串看起来完全随机的数据。这时候就要看文件大小,如果只有3到5个字节,且压缩包信息里的CRC32是可见的,说明这个文件可以用CRC碰撞的方式恢复成可读字符串。

无论哪种变体,核心链路是一致的:读出CRC32,确认字符集和长度边界,跑枚举脚本,把匹配的字符串作为密码或flag使用。

2.3 怎么确定字符集和长度边界

字符集和长度是爆破脚本的两个输入参数,也是最需要动脑判断的地方。题目一般有三种情况:直接给出提示,比如“密码由5位小写字母组成”;从文件名或readme里能推出字符集范围;完全没有提示。

有提示的时候没有悬念,直接指定。没有提示时我的做法是:先从纯数字开始试,再试小写字母+数字,再试大小写字母+数字,最后才考虑全部可打印ASCII。为什么要这样排序?因为CTF出题人设置密码时,为了不让自己也忘掉,倾向于选简单好记的组合。纯数字5位只要10万次枚举,小写字母+数字5位大约是6040万次,也还好。一上来就跑95字符全集,反而容易因为耗时长而误判方向。

长度边界也更讲究。如果zipfile读到的file_size是4,那就说明原文件长度为4字节,直接限定candidate长度等于4即可。这个约束不仅缩小枚举空间,还能大幅减少碰撞误报的概率,后面我会专门讲这个坑。

3. 完整Python脚本,从读取CRC到多进程爆破

3.1 用一条命令拿到ZIP里文件的CRC32

先解决读取CRC32的问题。用标准库zipfile就够了。

import zipfile with zipfile.ZipFile("target.zip") as zf: for info in zf.infolist(): print(info.filename) print("size:", info.file_size) print("crc32 hex:", hex(info.CRC)) print("crc32 int:", info.CRC)

如果目标压缩包里有很多文件,而你不知道该看哪一个,那就把file_size最小、且内容无法读取的那个文件当作爆破对象。如果题目只给了一个CRC32字符串,也可以用int(crc_str, 16)转成整数再传给脚本。

还有一种手工解析方式,用来理解CRC字段在文件里的位置。下面这段代码直接从二进制里读取第一个本地文件头的CRC32:

import struct with open("target.zip", "rb") as f: data = f.read() # 本地文件头签名 PK\x03\x04 sig = b"PK\x03\x04" offset = data.find(sig) crc = struct.unpack("<I", data[offset+14:offset+18])[0] print(hex(crc))

这里的offset+14就是CRC32字段相对文件头的偏移。这种方式在zipfile读不了损坏文件时很管用,算是给自己留一条后路。

3.2 基础版爆破脚本:思路清晰,适合理解和调试

先把最朴素的脚本放出来。它的核心就是一个双层循环:遍历长度、遍历所有组合、计算CRC32、比对。

import zlib import itertools import string target_crc = 0x5B0E2B23 charset = string.ascii_letters + string.digits max_len = 4 for length in range(1, max_len + 1): for candidate_tuple in itertools.product(charset, repeat=length): candidate = "".join(candidate_tuple).encode() if zlib.crc32(candidate) & 0xffffffff == target_crc: print("found:", candidate.decode()) exit()

注意我在比对前对zlib.crc32的结果做了& 0xffffffff操作。因为Python的zlib.crc32在某些平台或某些输入长度下返回的是有符号整数,如果不统一成无符号32位,直接和十六进制目标值比较时会莫名其妙地不相等。这个细节虽然小,但非常坑。

这个版本有两个明显问题:没有长度下限约束的话,如果密码是8位,脚本会先白白跑一遍1到7位的所有组合;没有进度提示,跑大空间时你会怀疑它是不是卡死了。所以它只适合理解原理,实战中我建议直接用下一节的升级版。

3.3 生产级脚本:多进程加速、字符集可调、命令行传参

实测下来,把枚举任务拆到多进程后,速度提升非常明显。下面这个脚本是我在日常比赛中常用的版本,全部基于标准库,无需pip安装任何依赖。

import zlib import itertools import string import sys import time from multiprocessing import Pool from functools import partial def check_candidates(args, target_crc, charset, length): found = [] for candidate_tuple in itertools.product(charset, repeat=length): candidate = "".join(candidate_tuple).encode() if (zlib.crc32(candidate) & 0xffffffff) == target_crc: found.append(candidate.decode(errors="replace")) return found def brute_crc(target_crc, charset, lengths, workers=4): pool = Pool(processes=workers) results = [] for length in lengths: partial_check = partial(check_candidates, target_crc=target_crc, charset=charset) results.append(pool.apply_async(partial_check, (charset, length))) pool.close() pool.join() all_found = [] for r in results: all_found.extend(r.get()) return all_found if __name__ == "__main__": target_crc = int(sys.argv[1], 0) if len(sys.argv) > 1 else 0x5B0E2B23 charset = sys.argv[2] if len(sys.argv) > 2 else (string.ascii_letters + string.digits) max_len = int(sys.argv[3]) if len(sys.argv) > 3 else 5 start = time.perf_counter() result = brute_crc(target_crc, charset, range(1, max_len + 1), workers=4) print("results:", result) print(f"elapsed: {time.perf_counter() - start:.2f}s")

用法示例:

python crc_brute.py 0x5B0E2B23 "abcdefghijklmnopqrstuvwxyz0123456789" 4

第一个参数是CRC32目标值,支持0x开头的十六进制,也支持纯十进制;第二个参数是字符集,按需传入;第三个参数是最大长度。全部走命令行,方便批量换参数测试。

这个脚本的输出会列出所有匹配的字符串。因为CRC32在数学上不是一一映射,理论上是存在碰撞的,所以列出全部匹配然后人工判断,比我之前那个脚本遇到一个就退出要稳妥得多。

3.4 不同场景下脚本参数怎么选

我把常见的CTF场景和推荐参数整理了一下,方便直接对照参考:

场景特征推荐字符集最大长度预期耗时(4核)
提示为纯数字01234567895秒级
提示为小写字母+数字小写字母+数字51分钟内
无提示但文件大小为4字节可打印ASCII45分钟左右
无提示且文件大小为5字节可打印ASCII5不建议直接跑,优先找字符集提示

这个表格是我以自己的机器为基准粗略估算的,实际耗时取决于CPU性能。总之记住一个原则:字符集越少、长度越短,爆破越快;如果估算空间超过一亿,说明你得先回头找找题目里的其他线索。

4. 实操复盘:一次模拟题的全过程

4.1 拿到一个看起来无解的压缩包

我把一次模拟题的素材当作案例来讲。先说明一下,题目是我自己在本地构造的,结构和CTF里出现的题目一模一样。构造过程是:新建一个文本文件secret.txt,写入内容“AbC1”,然后把它压缩成secret.zip。

然后我模拟题目组常见操作,把这个文件的可见内容从压缩包里“抹掉”——实际就是只保留文件头和CRC32,文件数据区清空。此时用普通解压软件去解压,会得到一个空文件或者直接报错,但zipfile依然能读出CRC32信息。

运行时脚本读取到的结果是:

  • 文件名:secret.txt
  • 文件大小:4字节
  • CRC32值:0x5B0E2B23

有了file_size=4这个信息,爆破时就不需要再跑1到3字节的长度了,直接把枚举长度锚定在4。

4.2 读取和爆破阶段的完整输出

我先用zipfile读取元数据,再按字符集“大小写字母+数字”跑脚本。因为字符集62、长度4,枚举空间是62的4次方约1470万,4进程跑大概几秒。

实际输出如下(这段是运行日志,保持原样展示):

filename: secret.txt file_size: 4 crc32 hex: 0x5b0e2b23 results: ['AbC1'] elapsed: 6.13s

从结果可以看出,AbC1的CRC32计算值和题目给出的目标值完全一致。到这里,你已经从一个“无内容”的文件里恢复出了原始内容。如果题目告诉你这个字符串是下一层压缩包的密码,那接下来直接用AbC1解压就行。

这个复盘过程看着平淡,但背后有个重要细节:为什么我不把字符集直接选成95个可打印ASCII字符?因为那样会慢很多,而且碰撞概率更高。先按更小的字符集跑,命中就收工,没命中再扩大。这是CTF里通用的逼近策略。

4.3 中途报错与参数调整记录

我刚开始写基础版脚本时犯过一个低级错误:没有限制长度上限,默认从1跑到8。结果等了半天没出结果,我才意识到问题,因为8位可打印字符的枚举空间是天文数字,根本跑不完。

后来改成根据file_size动态设定长度范围,问题立刻解决。这个调整看似简单,但体现的是一条重要原则:能用已知信息缩小搜索空间的地方,绝对不要偷懒。file_size就是用来限定长度的,题目提示的字符集就是用来限定候选字符的。

还有一次踩坑是把CRC读成了负数。当时我直接用zlib.crc32(b"AbC1")打印出来,在某些环境里返回的是一个负整数,和0x5b0e2b23直接比较永远不相等。后来统一用& 0xffffffff做无符号化处理,才彻底解决了这个对不上的问题。

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

5.1 为什么爆破结果有好几个候选,哪个才是正确答案

CRC32只有32位,数学上注定存在碰撞,也就是说两个不同的短字符串完全可能算出同一个CRC32值。当你发现结果列表里有多个候选时,别慌,这不是脚本坏了。

处理办法是按优先级筛选:首先,如果file_size已知且为N,优先保留长度为N的候选。其次,结合题目上下文判断——哪个候选更像解压密码、更像英文单词、更像某个标记格式。最后,如果候选数量还是多,那就把它们全部试一遍,用候选去解压下一层压缩包,能解开的就是正确答案。实际比赛中,我把一个4字节文件的CRC爆破后得到过两个候选,其中一个拼起来毫无意义,另一个恰好是“pass”,答案一目了然。

5.2 为什么读完CRC32没法读取文件内容

常见的报错是Bad CRC-32,或者解压时提示文件损坏。这通常不是脚本问题,而是题目故意把文件数据段改掉了。你要做的不是修复文件,而是利用可读的CRC32元数据去做爆破。

还有一种情况是压缩包使用了ZIP AES加密,这时部分工具读不出CRC字段,zipfile可能也会报错。我的建议是先用7-Zip查看压缩包信息,如果7-Zip能看到CRC字段,就直接拿那个值;如果连7-Zip都看不到,那题目大概率不是走CRC32思路,你需要换别的方向。

5.3 字符集里包含了大写字母、小写字母、数字,为什么还跑不出来

跑不出来的原因基本集中在三种:目标长度比设定的max_len大;字符集漏了什么特殊符号;CRC值本身读错了。其中第三种最隐蔽——如果你是用手工解析二进制的方式读CRC,一定要确认用的struct格式是'<I'而不是'>I',大小端反了读出来的值完全不对。

调试技巧是构造一个已知的自测:自己创建一个内容为“abc”的zip,读它的CRC32,再用脚本爆破,如果能爆破出“abc”,说明你的脚本链路是通的,问题出在题目数据上;如果爆破不出来,优先检查CRC值读取是否正确。

5.4 爆破速度太慢,有哪些立竿见影的提速手段

最直接的是上多进程。CRC32计算是纯CPU密集任务,8核机器开8进程,理论上有接近8倍的加速。我的脚本里用multiprocessing.Pool实现,注意Windows平台需要ifname== 'main'保护。

其次是预筛字符集。如果题目提示“密码是5位大写字母”,就别把数字加进去,这能让搜索空间缩小一个数量级。最后,尽量利用file_size锁定长度,避免在错误长度上浪费算力。这三个手段组合起来,绝大多数CTF中的CRC32爆破都可以做到一分钟内出结果。

6. 最后分享一条实战经验

这个技巧我在真实比赛里用过不止一次,最深的体会是:CRC32爆破不难,难的是判断这道题“应不应该用CRC32爆破”。很多时候选手不是不会写脚本,而是方向判断错了,在一个根本不适用CRC32爆破的题目上白白浪费时间。所以拿到压缩包题目,我现在的第一反应永远是先看文件信息:有哪些文件、文件多大、CRC是否可见、是否有字符集提示。这些信息加起来,足够在30秒内决定下一步动作。

另外建议把这套脚本沉淀成自己的工具,放到常用工具目录里,参数化设计,随时可调用。CTF比赛拼的不仅是知识点,还有工具熟练度。一个能30秒出结果的标准爆破脚本,在赛场上比现场写代码省太多时间。遇到类似题目,直接传参、收工、拿分。这个经验,比任何花哨的技巧都实用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询