← 返回博客首页

哈希碰撞详解:生日悖论、真实实验与为什么 MD5 必须淘汰

两个不同的文件、两段不同的密码,算出同一个哈希——这就是哈希碰撞。它听起来像「不可能事件」,但密码学对它的研究精确得多:碰撞一定存在(鸽笼原理),出现的时间算得出来(生日悖论),对某些算法甚至可以按需构造(MD5)。

理解这三层,才能回答真正重要的问题:文件校验该用什么算法?密码存储为什么必须加盐?SHA-256 还能用多久?


碰撞为什么一定存在:鸽笼原理

哈希函数把任意长度的输入压缩成固定长度的输出。SHA-256 永远输出 256 位,可能的输出只有 2²⁵⁶ 种;而可能的输入是无穷的。

无限个输入映射到有限个输出,必然有多个输入共享同一个输出——这就是鸽笼原理,不需要任何高深数学。好消息是「存在」不等于「找得到」:2²⁵⁶ 的输出空间大到随机碰撞几乎不会发生。坏消息藏在下一个问题里。


生日悖论:碰撞比你直觉的早得多

「多少个随机输入之后,出现碰撞的概率过半?」直觉会说「要试一半的输出空间」,但真实答案小得多——这就是生日悖论:23 个人里有两人生日相同的概率已超过 50%(一年 365 天)。

对 b 位哈希,碰撞概率过半所需的输入数量约为 2^(b/2),不是 2^b。用真实计算验证:

import math
def collision_prob(bits, n):
    N = 2 ** bits
    return 1 - math.exp(-n * (n - 1) / (2 * N))

# 32 位截断(比如只取 SHA-256 摘要的前 8 个十六进制字符)
for n in (10_000, 50_000, 77_000, 100_000):
    print(n, f'{collision_prob(32, n)*100:.1f}%')
# 10000 → 1.2%   50000 → 25.3%   77000 → 49.9%   100000 → 68.8%

77000 个随机输入就能让 32 位哈希的碰撞概率过半——生日界正好落在 2^(32/2) = 65536 附近,理论与计算吻合。64 位截断要到约 5×10⁹ 才过半,而 MD5 的 128 位空间按生日界只需要约 2⁶⁴ 次运算——这个数字在分布式算力面前不再遥不可及,这正是 SHA-1 被真实碰撞攻破的路径。


真实实验:亲手制造一次碰撞

概率表是抽象的,做一个能跑的实验就不抽象了。把 SHA-256 截断到前 2 个十六进制字符(8 bit,只有 256 种输出),暴力碰撞几秒内就能复现生日悖论:

import hashlib
seen = {}
for i in range(1_000_000):
    s = f'oltool-{i}'
    h = hashlib.sha256(s.encode()).hexdigest()[:2]
    if h in seen:
        print(f'碰撞: {seen[h]!r} 与 {s!r} 前 2 位都是 {h!r}(第 {i} 次尝试)')
        break
    seen[h] = s
# 碰撞: 'oltool-21' 与 'oltool-24' 前 2 位都是 '7b'(第 24 次尝试)

验证两个"碰撞"的完整哈希:7b3f71f6… 与 7bf43201…——前 2 位相同,整体完全不同。这个实验精确演示了截断哈希的失效方式:比较的位数越少,碰撞来得越快。也解释了为什么工程中「取哈希前 N 位做短指纹」时,N 的选择必须基于碰撞概率计算,而不是拍脑袋。


MD5 与 SHA-1:碰撞已经可以「下单」

MD5 的现状不再是理论:2004 年起选择前缀碰撞攻击逐步工程化,攻击者可以构造两个 MD5 相同但内容不同的文件——真实世界里的伪造证书(2008 年 rogue CA)与 Flash 文件伪装都源于此。SHA-1 在 2017 年被 SHAttered 攻击实现真实碰撞(约 2⁶³ 次运算,成本约 11 万美元 GPU 时),PDF 伪造 PoC 随之公开。

这改变了威胁模型:你不需要担心「恰好撞上」,需要担心的是「有人故意造一对」。攻击者构造两个预算表——一个给你看、一个给你签——只要签字系统用 MD5/SHA-1,两份文件的哈希就一样,事后无法证明你签的是哪一份。

密码学处理这个问题的方法是加长度与结构:SHA-256 输出 256 位,生日界约 2¹²⁸,远超可行算力;且构造碰撞需要打破内部压缩结构,目前没有已知方法。


对两类场景的分别影响

场景 碰撞的影响 要求
文件完整性校验 攻击者可构造「恶意文件 + 你要的哈希」成对出现 用未被破解的算法(SHA-256 起);哈希必须来自独立可信渠道
密码存储 攻击者不需要碰撞——直接暴力枚举更便宜 与碰撞无关:需要盐 + 慢哈希(bcrypt/argon2),防的是枚举不是碰撞

第二行常被忽略:密码存储的威胁模型里,「构造两个同哈希的密码」没有意义——攻击者直接试常见密码更快。这就是为什么「MD5 碰撞已破」不是「MD5 存密码不安全」的主要原因(真正原因是快);两件事经常被混为一谈。

哈希算法的完整对比与选型见 哈希算法参考与文本哈希工具怎么选。


可复现的实测结果

三组输出,全部真实运行(脚本见文末说明),终端可直接复现:

生日界计算(32 位截断,碰撞概率过半的 n):

import math
def collision_prob(bits, n):
    N = 2 ** bits
    return 1 - math.exp(-n * (n - 1) / (2 * N))
print(collision_prob(32, 77_000))   # → 0.499…(≈ 50%)
print(collision_prob(64, 5 * 10**9)) # → 0.492  (64 位生日界 ≈ 5e9)

截断碰撞实验(SHA-256 前 2 位 = 8 bit):

import hashlib
seen = {}
for i in range(1_000_000):
    s = f'oltool-{i}'.encode()
    h = hashlib.sha256(s).hexdigest()[:2]
    if h in seen:
        print(f'{seen[h]!r} vs {s!r}: 前 2 位同为 {h!r},第 {i} 次命中')
        break
    seen[h] = s
# 'oltool-21' vs 'oltool-24': 前 2 位同为 '7b',第 24 次命中
# 两者完整 SHA-256: 7b3f71f6… ≠ 7bf43201…(截断相同 ≠ 整体相同)

同输入的确定性(哈希用于校验的根基):

import hashlib
hashlib.sha256(b'hello world').hexdigest()
# → b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9(任何机器、任何时间,同一结果)

最后一条也是文件校验可行的原因:算法确定性 + 输出空间够大 ⇒ 「哈希一致」可以作为「内容一致」的工程等价物。


小结

  • 碰撞必然存在(鸽笼),但出现时机可以计算(生日悖论:2^(b/2));
  • MD5 / SHA-1 的碰撞已可按需构造,任何安全场景都应淘汰;
  • SHA-256 起步做完整性校验,哈希取自官方渠道;
  • 密码存储的威胁是暴力枚举而非碰撞——盐 + 慢哈希才是正解;
  • 截断哈希(取前 N 位做短指纹)的 N 必须按碰撞概率计算,不要拍脑袋。

本站 文本哈希工具 支持全部主流算法,哈希算法参考 有逐项对比与选型建议。


广告

常见问题

哈希碰撞会让我的下载文件校验失效吗?

现实中不会。SHA-256 有 2²⁵⁶ 种输出,随机两个文件碰撞的概率约 1/2²⁵⁶——比连续中彩票还低几个数量级。需要担心的不是「偶然撞上」,而是**攻击者刻意构造**:MD5 的碰撞已经可以按需生成,所以校验下载文件必须用 SHA-256 起步,且哈希必须来自**官方渠道**(官网/HTTPS 页面),而不是下载链接旁边的同一来源。

SHA-256 会像 MD5 一样被破解吗?

目前没有。MD5 的碰撞攻击在 2004 年被证明可实际构造,SHA-1 在 2017 年被 Google 与 CWI 实现了真实碰撞(SHAttered,约 2⁶³ 次运算),两者都属「构造性破解」。SHA-256 至今没有已知的实际碰撞,输出空间 2²⁵⁶ 也让暴力路径不可行。密码学的历史规律是「算法终会退役」,所以工程上的正确姿势是**跟随标准演进**(如今更推荐 SHA-3/BLAKE3 用于新系统),而不是赌某个算法永远安全。

两个文件的哈希相同,它们就一定相同吗?

在哈希未被破解的前提下,**实践上可以认定相同**——但逻辑上这是「高度可能」而非「必然」:哈希把无限输入映射到有限输出,碰撞在数学上一定存在。工程上的含义是:用未被破解的哈希(SHA-256)做校验时,可以放心把「哈希相同」当作「文件相同」;用 MD5 时则不能——攻击者可以构造两个 MD5 相同但内容不同的文件。

为什么两段完全不同的文字,哈希只差几个字符?

因为哈希输出是**均匀分布**的固定长度摘要:改变输入的一个字母,输出的是「重新随机洗牌」而非「局部修改」。所以两个相近输入的哈希在视觉上毫无关联——这不是缺陷,恰恰是雪崩效应,它让攻击者无法通过观察输出猜测输入的变化方向。

← 返回博客首页