CRC32碰撞可构造:线性方程组与高斯消元实战解析

CRC32碰撞可构造:线性方程组与高斯消元实战解析 简介CRC32碰撞主题的Python实现与实验代码包面向需要理解CRC循环冗余校验原理、32位校验码碰撞概率及测试方法的开发者与信息安全方向学习者。压缩包内共打包6个文件整体仅24KB轻量易用以3个Python脚本为核心分别承担CRC32算法实现、测试数据生成与碰撞测试任务另有Markdown格式的README说明文档、许可证文本以及CI配置文件涵盖从源码阅读到验证运行的完整链路。通过逐行阅读源码和运行测试脚本可以实际观察不同原始数据产生相同CRC32值的碰撞场景还可以复用测试数据生成器来模拟短文件名或6位字符加密压缩包特定情境下的碰撞可能性从而深入掌握完整性校验在实际应用中的局限性和碰撞应对思路。已有727人学习下载适合对数据校验、压缩包安全、底层算法实现以及碰撞测试感兴趣的入门到进阶用户。1. crc32碰撞不是撞运气是解一个线性方程组一个会让运维很头痛的例子是文件 A 和文件 B 内容明显不同长度也能差出几个字节但它们的crc32校验和完全一致。下载端把 CRC32 对上之后默认文件没问题解压出来的却是完全另一套东西。这不是“巧合”也不是用超算撞出来的而是 CRC32 本身的结构决定了这种碰撞可以被非常便宜地构造出来。CRC32 的数学基础是模 2 多项式除法不是随机散列。它没有“雪崩效应”数据某几位的变化对校验值的影响是线性的、可叠加的这也就意味着只要把追加在文件末尾的 8 个字节当成未知量列出一个 32 位的线性方程组就能用高斯消元把这个碰撞块直接解出来。整个过程只需要几十行 Python耗时在毫秒级。下面按三条线展开先看 CRC32 校验到底是怎么算的接着写出碰撞方程并给出可直接运行的构造脚本最后讨论在 ZIP 下载、秒传去重这类场景里crc32碰撞会产生什么真实影响以及已经上线的系统应该怎么把校验分层。2. 先看CRC32校验怎么算再谈“碰撞”从哪来2.1 用 zlib 复现一次 crc32 校验在 Python 里做 CRC32 计算是最直接的标准库zlib已经封装好了查表实现。import zlib data bhello crc32 crc zlib.crc32(data) print(hex(crc)) print(crc 0xFFFFFFFF)这段代码有两个值得注意的点第一zlib.crc32返回的是一个有符号整数打印出来的hex()可能是0x9b...但在某些环境下会看到负数所以跨系统比较时一定要 0xFFFFFFFF转成无符号值第二zlib.crc32的第二个参数是上一块的 CRC 状态这正是做分块校验、断点续传校验时最常用的增量接口。在真实工程里我一般不会把整个大文件一次性读进内存而是按分块累加def crc32_file(path, chunk_size1 20): crc 0 with open(path, rb) as f: while True: chunk f.read(chunk_size) if not chunk: break crc zlib.crc32(chunk, crc) return crc 0xFFFFFFFF参数说明chunk_size是每次读取的字节数这里取 1MBcrc初始值为 0但这个 0 并不代表 CRC 计算从 0 开始而是 zlib 内部会用0xFFFFFFFF做初值、在结束时再做一次异或。因此分块累加时必须把上一次的返回值传给下一次调用不能每块独立算完再加总。2.2 CRC32 本质是一次模 2 线性变换CRC32 很多人只记得“查表很快”但真正决定碰撞可行性的是它背后的代数结构。表里的每个uint32_t值本质上都是“某个 8 位数据对 CRC 寄存器的增量”而寄存器更新是 GF(2) 上的多项式除法没有进位加法就是异或减法也是异或。线性带来的一个重要性质是对于数据块A和B如果长度相同那么crc32(A XOR B)可以写成crc32(A) XOR crc32(B)再加上一个常数偏移。zlib 版 CRC32 开头和结尾都有0xFFFFFFFF的预处理所以这个常数偏移来自初始化与收尾不会破坏线性关系。工程上的推论是修改文件某一位或某几个字节时CRC 的变化量只取决于“改了什么”以及“改动位置之后的数据”跟改动位置之前的内容没有依赖关系。更直白地说我可以预先算出每个字节位的“影响向量”再把多个修改影响做异或叠加。下表概括了不同修改方式对构造碰撞的难度影响修改方式对 CRC32 的影响能否直接碰撞只翻转一个 bit增量可预先计算通常很难命中目标值同时翻转多个 bit各 bit 增量的异或可以列方程求解在文件末尾追加一个块等价于注入一段线性变化最稳妥推荐这个“线性叠加”的结论很多人第一次听会觉得反直觉但它是后面构造脚本的核心。没有这个性质CRC32 碰撞就只能靠暴力枚举而暴力枚举一个 32 位目标值在常规工程里完全不可接受。2.3 为什么“碰撞”不是靠运气有人说 CRC32 只有 32 位任意多个文件必然存在碰撞这是鸽笼原理。鸽笼原理只能证明“存在”却不能告诉你碰撞文件长什么样。真正让构造成为可能的是线性映射的另一个结论当一个未知量的维度大于结果维度时方程通常有解而且可以在 GF(2) 上直接解。具体到 CRC32输出约束只有 32 位。如果在文件末尾追加 8 个字节未知 bit 数是 64约束是 32方程会非常宽裕。只要影响向量之间不出现灾难性的列相关就能找到一组 bit使追加后的文件 CRC32 精确等于目标值。这个过程不需要理解 CRC 表的具体多项式只需要把影响向量测出来然后做一次高斯消元。提示这里说的“碰撞”不是跑脚本随机生成两个文件然后碰巧遇到相同校验值而是完全可控地构造出两个不同文件。本文后续所有讨论都基于这种可复现的构造方式。3. 动手造一个 CRC32 碰撞追加 8 字节并反解校验值3.1 先想清楚要解什么方程组假设有两个文件一个是合法文件LEGAL一个是想要替换它的文件EVIL。我们的目标是不修改LEGAL只在EVIL末尾追加 8 个字节让crc32(EVIL patch)等于crc32(LEGAL)。把patch看成 64 个未知 bit第j个 bit 从 0 变成 1 时CRC 的变化记为effect_j。这个效果可以通过对比“追加全 0 块”和“追加只有第 j 位为 1 的块”来实测得到。根据线性性质最终实际追加的patch产生的总影响等于所有置 1 bit 对应影响向量的异或。于是构造碰撞变成一个标准的 GF(2) 线性方程xor(effect_j for j in patch_bit_set) crc32(LEGAL) XOR crc32(EVIL zero8)右边是 32 位目标差左边是 64 个未知量。这里有一个隐含关键基准状态不是crc32(EVIL)而是crc32(EVIL zero8)。因为即使追加 8 个全零字节数据的长度变了CRC 也一定会变所以基准必须先把长度变化算进去。3.2 一个可以直接跑的 Python 构造脚本下面的脚本完整实现了上述思路按顺序完成影响向量测量、GF(2) 高斯消元、生成patch和最终验证。import zlib LEGAL biam-a-good-attachment-abcdef EVIL biam-an-evil-attachment-xyzzy ZERO8 b\x00 * 8 def crc_of(data: bytes) - int: return zlib.crc32(data) def effect(data: bytes, bit: int) - int: 将追加块的第 bit 位置 1测量 CRC 变化 block bytearray(ZERO8) block[bit 3] ^ 1 (bit 7) return crc_of(data bytes(block)) ^ crc_of(data ZERO8) # 高斯消元把每个列向量化为主元表 basis {} for bit in range(8 * 8): vec effect(EVIL, bit) combo 1 bit while vec: top vec.bit_length() - 1 if top in basis: vec ^ basis[top][0] combo ^ basis[top][1] else: basis[top] (vec, combo) break # 求解 target crc32(LEGAL) xor crc32(EVIL zero8) target crc_of(LEGAL) ^ crc_of(EVIL ZERO8) sol 0 while target: top target.bit_length() - 1 if top not in basis: raise SystemExit(无解把 ZERO8 换成 16 字节即可) vec, combo basis[top] target ^ vec sol ^ combo # 把解写回字节块 patch bytearray(ZERO8) for bit in range(8 * 8): if (sol bit) 1: patch[bit 3] ^ 1 (bit 7) c1 crc_of(LEGAL) c2 crc_of(EVIL bytes(patch)) assert c1 c2, (hex(c1), hex(c2)) print(LEGAL crc32 :, hex(c1)) print(EVIL crc32 :, hex(c2)) print(patch :, bytes(patch).hex())逻辑说明effect函数在“数据尾部追加全零块”的基准上单独把某个 bit 置 1再测量 CRC 的变化量这个变化量就是列向量basis是 GF(2) 行消元后的主元表vector存列向量combo存该列向量由哪些原始 bit 组合而成最后求解target时把每个命中主元的组合异或回sol就得到了置 1 的 bit 集合。参数说明LEGAL和EVIL的长度不需要一致但脚本里追加的是 8 字节如果某些数据恰好让 64 个列向量不满秩可以把ZERO8改成 16 字节的ZERO16未知量变成 128 个几乎不可能无解。脚本把“无解”显示为异常实际使用中直接调大追加长度即可。3.3 让碰撞更隐蔽的两个做法第一patch不一定非要在文件最末尾。很多文件格式允许尾部追加数据例如 ZIP 的注释区、JPEG 的 APPn 段、PNG 的自定义 chunk都可以把这 8 字节藏进去。只要校验方计算 CRC 时覆盖了这些字节碰撞依然成立。第二如果业务系统同时校验文件长度可以在EVIL内容后先补一段普通字节把长度补到和目标一致再在补的字节后面放全零块和patch。这样最终两个文件不仅 CRC 相同长度也一样只凭“文件大小 crc32”的组合完全看不出来。提示patch的字节顺序没有大小端概念它只是逐 bit 解的产物。直接以bytes.hex()输出两边按同一份 hex 还原即可。3.4 验证脚本输出在命令行执行python3 crc_collision.py正常输出类似LEGAL crc32 : 0x9e45a24f EVIL crc32 : 0x9e45a24f patch : 4b8f1d2c7a9e30f6验证时不要用cksum命令POSIX 的cksum算法和 zlib 的 CRC32 不是同一个多项式。统一用 Python 判断assert zlib.crc32(LEGAL) zlib.crc32(EVIL patch)4. 真实场景中的 crc32 碰撞校验通过但文件变了4.1 ZIP 包下载校验防损坏不等于防替换ZIP 文件格式里本地文件头和中央目录都记录了 crc32 字段解压工具会在解压完成后用该字段校验解压内容。这个设计的初衷是检测磁盘损坏、网络传输中随机 bit 翻转而不是检测内容被有意替换。问题出在很多下载工具把“解压后 CRC 正确”当成“这个包可信”的证据。攻击者拿到官方包的 crc32 值之后把第三节脚本的LEGAL设成官方内容、EVIL设成自己的替换内容生成碰撞包。解压时zip工具对内容计算 crc32发现与头部记录一致于是正常输出。对用户来说文件确实“校验通过”了但内容已经完全不同。这段校验代码如果写成if zlib.crc32(content) ! expected_crc: reject那么expected_crc只是攻击者的目标参数不构成任何防护。真正要区分的是“数据在传输中没坏”和“数据确实是我想要的数据”CRC32 只能承担前者。4.2 秒传与去重场景中的误判很多网盘、制品库系统会用 crc32 作为文件唯一标识做“秒传”客户端不传整个文件只传 crc32 和文件长度。CRC 碰撞在此类场景中会造成三种具体问题用户上传 A 文件服务端按 crc32 命中已有的 B 文件直接返回“上传成功”但用户拿到的是 B 文件CDN 按 crc32 做缓存 key两个不同安装包可能被映射到同一份缓存导致版本错乱安全扫描器按 crc32 加白名单碰撞文件可以借用白名单身份进入系统。有人觉得把比较条件改成crc32 size就安全了但第三节已经证明可以同时控制长度先在EVIL里补字节对齐长度然后再生成patch。所以crc32 size只是提高了一点点门槛并没有改变问题的本质。4.3 CRC32、MD5、SHA-256 的定位差异对需要做完整性校验的工程师来说清楚这张表的差别比记住具体算法更有用算法输出位数设计目标碰撞是否可构造建议使用位置CRC3232检测随机位错误几行脚本可构造传输校验、ZIP 内部保护、磁盘块校验MD5128完整性校验已被实际构造老系统兼容不建议安全判定SHA-256256抗碰撞哈希当前不可行软件发布、下载验证、去重主键MD5 虽然有 128 位但已经存在构造碰撞的成熟方法因此我不建议任何新系统用它做最终完整性判断。SHA-256 的碰撞成本目前仍然高得离谱这才是可以作为“最终裁决”的校验层。4.4 线上系统已经中招怎么办如果系统里已经有大量数据只记录了 crc32第一件事不是删字段而是立刻加“快照校验”。常见做法是把每个文件的首 4KB、尾 4KB、文件长度和 sha256 一起存进元数据。头部和尾部是攻击者最容易忽略的位置即使他们能构造出同样的 crc32也很难同时保证两段固定位置的字节摘要也一致。更直接的处理方式是对存量数据做一次全量 sha256 回填在首次读取时计算并更新元数据表后续所有校验逻辑都改成“crc32 快速粗筛sha256 最终通过”。5. 在正在运行的系统里把 CRC32 当粗筛而不是判决5.1 三步混合校验crC32 先挡坏包sha256 最终确认在下载服务和制品库中我一般这样组织校验逻辑# 第一步crc32 快速粗筛只挡传输损坏 crc_local$(python3 -c import zlib;print(hex(zlib.crc32(open(app.bin,rb).read())))) crc_expected0x9e45a24f if [ $crc_local ! $crc_expected ]; then echo crc32 mismatch, fast reject exit 1 fi # 第二步crc32 通过后再用 sha256 做最终判定 echo 9e45a24f6b7c8d9012345678abcdef1234567890abcdef1234567890abcdef app.bin | sha256sum -c - || exit 1逻辑说明crc_expected是从发布系统下发的期望值crc_local是本地文件实际计算值。第一层用 crc32 的成本几乎可以忽略能快速拦截大部分网络层的随机损坏。只有 crc32 通过后才执行 sha256sum 做全文件摘要校验。参数说明sha256sum -c -表示从 stdin 读取校验文件格式必须是hash filename两列文件名与当前目录下文件一致。这里-是 stdin 的占位符不能省略。5.2 实在跑不动全量 SHA-256 时的最低配置完整 256 位哈希对超大文件也有开销如果性能预算不够至少要把校验强度从“单一 crc32”提升到“多段快照”。建议保留 crc32 之外再统计文件大小、前 4KB 的 sha256、后 4KB 的 sha256import hashlib import os def quick_fingerprint(path, head4096, tail4096): size os.path.getsize(path) with open(path, rb) as f: head_bytes f.read(head) f.seek(max(0, size - tail)) tail_bytes f.read() return { size: size, crc32: zlib.crc32(open(path, rb).read()), head: hashlib.sha256(head_bytes).hexdigest(), tail: hashlib.sha256(tail_bytes).hexdigest(), }参数说明head和tail默认为 4096 字节。这个方法比全文件 sha256 快很多但注意它不能防住知道策略的攻击者只能作为性能受限时的过渡方案。如果服务端对安全性要求高最终还是要整文件哈希。5.3 把“校验键”从 crc32 换成组合指纹在去重和秒传接口里唯一键不要再用crc32一个字段可以把 crc32、文件长度、头尾 sha256 组装成一个组合键key f{size}:{crc32}:{head_hash}:{tail_hash}这样 crc32 碰撞文件会在头尾摘要这一层被拦截。真正决定“是否同一文件”的仍应是整体 sha256组合键只是用来降低哈希计算频率的索引。把上面这个组合键作为上传幂等判断的 primary key碰撞文件就会先死在“没有头部和尾部指纹”这一层而不再有机会进入后续的业务逻辑。本文还有配套的精品资源点击获取