资讯动态

CRC32碰撞并非偶然:从仿射映射原理到工程防护

发布时间:2026/9/16 12:09:47 来源:尧图企业网站定制
简介围绕CRC32校验与碰撞问题整理的一份微型项目资源面向需要理解循环冗余校验原理、从事数据完整性检测或研究短文件名下CRC碰撞现象的开发者与学习者。资源聚焦“如何计算CRC32”“不同数据为何可能产生相同校验值”以及“6位字符以内加密压缩包场景中的碰撞可能性”以代码加文档的形式降低上手门槛。压缩包共6个文件以3个Python脚本为主体涉及CRC32计算、测试数据与碰撞测试另含1份说明文档、1份项目说明和1份CI配置整体仅24KB结构紧凑适合快速阅读与运行调试。目前已有727人浏览学习内容虽然精简但脚本与数据文件相互配合可直接观察CRC32的具体输出并通过修改测试数据进一步验证碰撞概率与边界条件。对希望结合实例掌握CRC32算法、排查压缩包校验异常或开展碰撞测试的读者而言是一份轻量实用的参考实现。1. CRC32 碰撞不是偶然是数学上必然下载一个 4GB 的安装包CRC32 校验通过解压后程序却崩溃——这种事不常见但绝对存在。CRC32 所在的生态位是“完整性校验”不是“安全校验”它输出的 32 位余数只是 GF(2) 域上多项式除法的余数既不抗随机碰撞更挡不住蓄意构造。生日悖论下约 7.7 万条数据就有 50% 的概率出现一对碰撞而在攻击者手里利用 CRC 的仿射结构构造两个内容不同但 CRC32 相同的数据开销可以低到几十次矩阵运算。下面把 CRC32 碰撞拆开讲碰撞从哪儿来、怎么构造出来、线上哪些系统会被波及以及不换哈希的前提下还能做什么补救。2. CRC32 的结构从多项式除法到 GF(2) 仿射映射2.1 CRC32 的数学骨架32 位余数的多项式除法把一段数据看成 GF(2) 上的大多项式CRC32 就是它除以生成多项式后的余数。标准 CRC-32/IEEE 802.3 的生成多项式是x^32 x^26 x^23 x^22 x^16 x^12 x^11 x^10 x^8 x^7 x^5 x^4 x^2 x 1十六进制记法是 0x04C11DB7。实际工程里几乎没人直接做多项式除法表驱动或位循环用的是 0xEDB88320这是对 0x04C11DB7 做位反射后的结果配合 refin/refouttrue 的参数搭配。二者算出的 CRC32 值一致只是内部位序不同。参数标准 CRC-32 / IEEE 802.3poly0x04C11DB7反射多项式查表用0xEDB88320init0xFFFFFFFFrefintruerefouttruexorout0xFFFFFFFF别名zlib.crc32、Java CRC32 等最小实现甚至不用查表用位循环即可复现上面参数def crc32_reflected(data: bytes, poly0xEDB88320, init0xFFFFFFFF, xorout0xFFFFFFFF) - int: crc init for byte in data: crc ^ byte # 反射模式下字节先与低8位异或 for _ in range(8): # 每比特反馈一次 crc (crc 1) ^ poly if crc 1 else crc 1 return crc ^ xorout这段代码里poly必须是反射多项式 0xEDB88320每次迭代取寄存器最低位作为反馈位等价于原始多项式逐位除法的镜像过程。init和xorout都是 0xFFFFFFFF目的是让全零输入也有非零输出避免“空数据校验值恒为 0”的退化。把它和zlib.crc32(babc)对拍结果一致就说明参数没配错。2.2 为什么 CRC32 是“线性”的碰撞的根源观察上面的循环每次操作只有异或、右移和按位常数选择没有进位。异或在 GF(2) 上是加法右移、异或反馈都是线性算符于是整个 CRC 映射天然满足“异或分配律”。严格地说因为有 init 和 xoroutCRC 是一个仿射映射C(x) L(x) xor c0其中 L 是 GF(2) 上的线性变换c0 是由 init 和 xorout 决定的常量。对于任意两条等长消息 m 和 dC(m xor d) C(m) xor L(d)如果 d 是非零向量且落在 L 的核kernel里即 L(d)0那么 m 与 m xor d 就有完全相同的 CRC32。这就是碰撞的构造入口。仿射性把“找碰撞”这个通常需要暴力搜索的问题转化成了“解一个 GF(2) 线性方程组”的问题。同理消息越长输入维度越高而输出只有 32 维核必然非空。所以 CRC32 的碰撞不是小概率事件而是结构性的必然。任何一个消息长度超过 4 字节的 CRC 映射都存在可构造的非零碰撞差。2.3 自然碰撞与蓄意构造概率和能力的差距被动场景下随机数据产生 CRC32 碰撞遵循生日界限对于 32 位输出碰撞概率达到 50% 所需的样本量约 sqrt(2^33 * ln 2) ≈ 77163 条。如果数据量只有几千条概率很低这也是大多数网络传输校验敢用 CRC32 的原因。但蓄意场景完全不同。线性方程组在 32 维空间求解代价是大约 32^3 次布尔运算对现代 CPU 连 1 毫秒都用不上。也就是说只要内容可以被人为调整碰撞就不再是“遇到”的问题而是“想造就有”的问题。理解这一点才能解释后续章节里那些看似诡异的工具和攻击。这一章建立了两个核心认知CRC32 是 GF(2) 上的仿射映射碰撞源于线性算子 L 的核。接下来就把这两个认知落到代码里。3. 构造 CRC32 碰撞从 Z3 到线性方程组3.1 跑通第一对碰撞用 Z3 约束求解如果只是想要一对“能用”的碰撞最直接的方法是用 SMT 求解器 Z3把 CRC32 的位循环原样写成 BitVec 约束再让求解器找两个不同消息。8 字节消息的自由度是 64 位CRC 相等只有 32 个约束解空间存在。from z3 import * POLY BitVecVal(0xEDB88320, 32) ZERO BitVecVal(0, 32) def crc32_z3(buf): crc BitVecVal(0xFFFFFFFF, 32) for b in buf: crc crc ^ ZeroExt(24, b) for _ in range(8): crc LShR(crc, 1) ^ If(crc 1 1, POLY, ZERO) return crc ^ BitVecVal(0xFFFFFFFF, 32) s Solver() m1 [BitVec(fm1_{i}, 8) for i in range(8)] m2 [BitVec(fm2_{i}, 8) for i in range(8)] # 前4字节固定为 ABCD便于肉眼对照 for i in range(4): s.add(m1[i] BitVecVal(ord(ABCD[i]), 8)) s.add(m2[i] BitVecVal(ord(ABCD[i]), 8)) # 后4字节至少有一位不同 s.add(Or([m1[i] ! m2[i] for i in range(4, 8)])) s.add(crc32_z3(m1) crc32_z3(m2)) if s.check() sat: model s.model() a bytes([model.eval(x).as_long() for x in m1]) b bytes([model.eval(x).as_long() for x in m2]) import zlib print(m1:, a.hex(), hex(zlib.crc32(a))) print(m2:, b.hex(), hex(zlib.crc32(b))) else: print(unsat)代码里的LShR是逻辑右移Z3 中必须用它而不是后者对 BitVec 是算术右移。ZeroExt(24, b)把 8 位字节扩展到 32 位寄存器宽度。跑出来的两个 8 字节消息前四字节相同后四字节不同但zlib.crc32的结果完全一致。Z3 适合教学和一次性任务它的缺点是代码被塞进 SMT 编码后很难扩展到更长消息而且求解时间不稳定。实际生产里构造碰撞更常用的是下一节的线性代数方法。3.2 可控尾块让任意文件 CRC 等于目标值比“找到两个碰撞”更实用的是可控尾部注入给定任意前缀 P找到 4 字节 tail使得CRC32(P || tail) target其中 target 可以随便指定。这等价于 CRC 函数关于 tail 的可逆性问题而依然是仿射性给的底气。处理完前缀后CRC 寄存器停在状态 state。接下来处理 4 字节 tail 时可以用一个函数 f(tail32) 描述从 tail 到最终 CRC 的映射。因为整条链路是仿射的f(tail) M · tail xor b其中 M 是 32×32 的 GF(2) 矩阵。构造 M 不需要解析多项式取零输入求 b再对每个 bit i 置 1 求 f(1i)异或掉 b就得到 M 的第 i 列。之后解 M · tail target xor b 即可。import struct def _step(crc: int, byte: int) - int: crc ^ byte for _ in range(8): crc (crc 1) ^ (0xEDB88320 if crc 1 else 0) return crc def digest(prefix: bytes) - int: crc 0xFFFFFFFF for b in prefix: crc _step(crc, b) return crc def crc_after_tail(state: int, tail32: int) - int: for b in struct.pack(I, tail32): state _step(state, b) return state ^ 0xFFFFFFFF def build_system(prefix: bytes): state digest(prefix) base crc_after_tail(state, 0) # f(0) b cols [crc_after_tail(state, 1 i) ^ base for i in range(32)] mat [] for out_bit in range(32): mask 0 for in_bit, colvec in enumerate(cols): if (colvec out_bit) 1: mask | 1 in_bit mat.append(mask) return mat, base注意struct.pack(I, ...)用的小端序这对应 refintrue。digest里暂不执行 xoroutcrc_after_tail最后才做xor 0xFFFFFFFF这样f的仿射常数没有被提前抵消。cols缓存可以避免 1024 次重复计算 32 位循环是这段代码的关键优化。3.3 GF(2) 高斯消元32 元线性方程在无依赖环境下求解有了 M 和 b剩余问题是解 M·tail target xor b。可以用 numpy 的 mod-2 运算但这里给一个纯 Python 实现任何环境都能跑def solve_gf2(mat, rhs): aug [mat[i] | (((rhs i) 1) 32) for i in range(32)] pivot {} row 0 for col in range(32): sel -1 for r in range(row, 32): if (aug[r] col) 1: sel r break if sel -1: continue aug[row], aug[sel] aug[sel], aug[row] for r in range(32): if r ! row and ((aug[r] col) 1): aug[r] ^ aug[row] pivot[col] row row 1 for r in range(32): if (aug[r] 0xFFFFFFFF) 0 and ((aug[r] 32) 1): raise ValueError(no solution) x 0 for col, r in pivot.items(): if (aug[r] 32) 1: x | 1 col return xaug的低 32 位是系数行第 32 位是右侧常数。行消元和普通高斯消元完全一致只不过每步异或替换了线性组合。消成 RREF 后每个主元列对应的行末位就是该变量的取值自由变量统一取 0。如果某行系数全零但右侧为 1说明方程组无解。下面验证完整流程prefix bhello, crc32 collision target 0xDEADBEEF mat, base build_system(prefix) tail solve_gf2(mat, target ^ base) data prefix struct.pack(I, tail) import zlib print(ftail{tail:08x}) print(hex(zlib.crc32(data))) # 0xdeadbeeftarget ^ base就是方程右边。解出的 tail 附加到任意 prefix 后CRC32 正好等于 target。这个技巧在改固件、修压缩包、规避旧校验时都出现过它证明 CRC32 的碰撞能力可以精确到任意目标值而非只能“凑对”。3.4 这些参数在构造里各扮演什么角色上一节的代码默认了标准 CRC-32 的四个参数。改参数时要注意三处连锁反应一是 poly 换成目标实现的反射多项式二是struct.pack(I)在 refinfalse 时要改成I否则 bit 顺序对不上三是 init 和 xorout 虽然只影响 base 和最后一步但如果拿zlib.crc32对拍二者也必须一致。用crcmod的话检查Crc(0x104C11DB7, revFalse, initCrc...)之类的构造函数原理一样只是 API 命名不同。提示如果你用zlib.crc32对拍得到的永远是全 0xFFFFFFFF xorout 的参数组合crcmod 默认参数可能不同构造矩阵前先确认参数一致。4. CRC32 碰撞的现实影响哪些系统会真的出事4.1 内容寻址与对象去重静默数据错配以 CRC32 作为内容寻址键的系统把文件的 CRC32 当作文件名或存储地址。碰撞发生时两个不同文件会映射到同一个键后端“先查后写”的逻辑会认为数据已存在直接复用旧对象。坏处是静默的读端拿到的对象不是自己请求的对象但哈希一致调用方无法察觉。这种系统在早期 P2P 分块、分布式缓存、文件去重软件里都真实存在。32 位空间在千万级对象规模下已有碰撞不是骇人听闻而是可以预见的。正确做法是用 128 位以上哈希做寻址键在保留 CRC 的旧系统里至少要叠加对象长度、元数据再哈希并在写入前做二次确认。4.2 缓存 Key 与限流指纹错误命中和串台把多个字段拼起来做 CRC32 生成缓存 key是另一种高频用法。正常情况下随机碰撞概率不高但一旦出现高并发下两个用户会共享同一份缓存内容轻则数据错乱重则把计费、风控的判定结果串给无关用户。攻击者更可以直接构造碰撞尝试把目标 key 的缓存内容替换。这类场景的问题不在 CRC32 算得快不快而在语义上缓存 key 要求“不同输入得到不同输出”这恰恰是 CRC 没有的设计目标。替换成 64 位或 128 位非加密哈希如 XXH3、MurmurHash3 的 128 位变体性价比最高同样快但空间大得多。4.3 固件与安装包校验从防误码变成防篡改的误用很多嵌入式设备至今用 CRC32 校验固件升级包头部的 CRC 字段是明文存储。固件被修改后只要重新算一个合法 CRC 并写回字段开机校验就能通过。对于恶意升级、版权绕过这类场景CRC32 没有任何防御能力。这里的典型误用是用 CRC 代替 MAC 或签名。如果威胁模型里存在“有人能改文件但校验不更新”那么必须换成带密钥的 HMAC 或数字签名不是换一个更长多项式就能解决。若暂时无法换至少把 CRC 校验配合启动流程中的版本号、平台 ID 一起绑定增加攻击者构造成本。4.4 数据库页校验和日志行校验从概率到可利用数据库页校验里CRC32 主要用于检测介质静默损坏面向随机位翻转这一用途是合理的。但 MySQL binlog 的 checksum、消息队列单条消息的 CRC 字段同样只有 32 位在高吞吐系统中会出现偶发校验和碰撞。非恶意情况下概率极低可一旦有发送端 bug 或内存损坏碰撞会掩盖整条消息的错误排障时比直接报错难得多。在这些系统里建议至少记录 CRC 的透传路径出现“校验通过但内容解析失败”的告警时把碰撞当先行指标处理。能用 64 位校验就不要贪图 32 位的速度特别是消息量大到小时级亿万条时生日概率已经从“忽略不计”变成“迟早遇上”。场景碰撞后果缓解方向内容寻址存储读写错配、数据静默损坏换 128 位哈希缓存 key错误命中、用户数据串台换 64 位以上哈希固件升级包恶意修改通过校验改用 HMAC/签名日志行校验故障被掩盖记录告警并升级校验5. 不换哈希的增强路径与替换梯度CRC32 在检测随机误码的领域仍不可替代网络帧尾部、磁盘块校验、压缩包结构校验这些场景目标都是“抓住翻转的 bit”攻击者不在威胁模型内32 位冗余足够。换掉它反而可能因为实现复杂引入新问题。5.1 依然可以放心用的三个场景传输链路误码检测、内存 bit flip 防护、非对抗环境下的数据块快检这三个场景把 CRC32 用在它该在的位置。判断标准很简单输入内容是否可能被人为构造如果不会CRC32 的碰撞概率在可接受范围。5.2 最低成本增强CRC32C、长度与随机盐如果暂时不能换最低成本的增强是把三个独立信号叠起来内容本身、内容长度、随机盐。组合效果注意CRC32CCastagnoli 多项式硬件指令crc32加速吞吐更高仍是 32 位不改善碰撞CRC32(data) len(data)区分不同长度输入同长度碰撞依然存在CRC32(salt || data)打乱攻击者预期不能防住能同时看到 salt 的构造随机盐只在“碰撞由外部预置”的场景有效每条记录生成随机 4 字节前缀攻击者无法提前准备固定碰撞。但注意它仍然是 32 位空间里的游戏防御的是随机碰撞不是带算力的恶意构造。5.3 需要抗碰撞时的替换梯度需要真正抗碰撞时替换梯度按速度和语义选哈希位宽目标建议场景XXH3-6464非密码学高速去重、分片、缓存 keyBLAKE3-256256密码学安全文件寻址、内容寻址存储SHA-256256密码学安全跨平台兼容性优先的协议决定前先回答一个问题如果碰撞被构造出来系统会不会把错误数据当正确数据用不会就留在 CRC会就把抵御恶意构造的责任交给密码学哈希。上线前把碰撞响应日志打出来比把希望寄托在“应该碰不上”上更实际。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价