两个不同的文件、两段不同的密码,算出同一个哈希——这就是哈希碰撞。它听起来像「不可能事件」,但密码学对它的研究精确得多:碰撞一定存在(鸽笼原理),出现的时间算得出来(生日悖论),对某些算法甚至可以按需构造(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 必须按碰撞概率计算,不要拍脑袋。
本站 文本哈希工具 支持全部主流算法,哈希算法参考 有逐项对比与选型建议。