
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字段在文件里的位置。下面这段代码直接从二进制里读取第一个本地文件头的CRC32import struct with open(target.zip, rb) as f: data f.read() # 本地文件头签名 PK\x03\x04 sig bPK\x03\x04 offset data.find(sig) crc struct.unpack(I, data[offset14:offset18])[0] print(hex(crc))这里的offset14就是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, repeatlength): 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, repeatlength): candidate .join(candidate_tuple).encode() if (zlib.crc32(candidate) 0xffffffff) target_crc: found.append(candidate.decode(errorsreplace)) return found def brute_crc(target_crc, charset, lengths, workers4): pool Pool(processesworkers) results [] for length in lengths: partial_check partial(check_candidates, target_crctarget_crc, charsetcharset) 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), workers4) print(results:, result) print(felapsed: {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_size4这个信息爆破时就不需要再跑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(bAbC1)打印出来在某些环境里返回的是一个负整数和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秒出结果的标准爆破脚本在赛场上比现场写代码省太多时间。遇到类似题目直接传参、收工、拿分。这个经验比任何花哨的技巧都实用。