CRC32碰撞可构造:线性方程组与高斯消元实战解析
2026/9/16 15:35:58 网站建设 项目流程

简介: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 = b"hello crc32" crc = zlib.crc32(data) print(hex(crc)) print(crc & 0xFFFFFFFF)

这段代码有两个值得注意的点:第一,zlib.crc32返回的是一个有符号整数,打印出来的hex()可能是0x9b...,但在某些环境下会看到负数,所以跨系统比较时一定要& 0xFFFFFFFF转成无符号值;第二,zlib.crc32的第二个参数是上一块的 CRC 状态,这正是做分块校验、断点续传校验时最常用的增量接口。

在真实工程里,我一般不会把整个大文件一次性读进内存,而是按分块累加:

def crc32_file(path, chunk_size=1 << 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是每次读取的字节数,这里取 1MB;crc初始值为 0,但这个 0 并不代表 CRC 计算从 0 开始,而是 zlib 内部会用0xFFFFFFFF做初值、在结束时再做一次异或。因此,分块累加时必须把上一次的返回值传给下一次调用,不能每块独立算完再加总。

2.2 CRC32 本质是一次模 2 线性变换

CRC32 很多人只记得“查表很快”,但真正决定碰撞可行性的,是它背后的代数结构。表里的每个uint32_t值本质上都是“某个 8 位数据对 CRC 寄存器的增量”,而寄存器更新是 GF(2) 上的多项式除法:没有进位,加法就是异或,减法也是异或。

线性带来的一个重要性质是:对于数据块AB,如果长度相同,那么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 = b"iam-a-good-attachment-abcdef" EVIL = b"iam-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 集合。

参数说明:LEGALEVIL的长度不需要一致,但脚本里追加的是 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_expected="0x9e45a24f" 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 的 sha256:

import hashlib import os def quick_fingerprint(path, head=4096, tail=4096): 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(), }

参数说明:headtail默认为 4096 字节。这个方法比全文件 sha256 快很多,但注意它不能防住知道策略的攻击者,只能作为性能受限时的过渡方案。如果服务端对安全性要求高,最终还是要整文件哈希。

5.3 把“校验键”从 crc32 换成组合指纹

在去重和秒传接口里,唯一键不要再用crc32一个字段,可以把 crc32、文件长度、头尾 sha256 组装成一个组合键:

key = f"{size}:{crc32}:{head_hash}:{tail_hash}"

这样 crc32 碰撞文件会在头尾摘要这一层被拦截。真正决定“是否同一文件”的仍应是整体 sha256,组合键只是用来降低哈希计算频率的索引。

把上面这个组合键作为上传幂等判断的 primary key,碰撞文件就会先死在“没有头部和尾部指纹”这一层,而不再有机会进入后续的业务逻辑。

本文还有配套的精品资源,点击获取

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

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

立即咨询