下载一个安装包时,发布方通常会附上一串哈希值。你算一遍本地文件的哈希,对比一下,相同就认为文件没被篡改。这套机制的前提是:两个不同文件几乎不可能算出同一个哈希。但"几乎不可能"到底有多不可能?答案取决于算法。MD5 早就被攻破,SHA-1 在 2017 年正式倒下,SHA-256 至今坚挺。这篇就来拆解哈希碰撞这件事,讲清楚数学原理、真实案例和工程上的选择。想亲手算一个哈希看看效果?用我们的 MD5 哈希生成器 马上就能复现这些碰撞。
什么是哈希碰撞
哈希函数把任意长度的输入压缩成固定长度的输出。比如 MD5 永远输出 128 位,SHA-256 永远输出 256 位。输入空间无限大,输出空间有限,根据鸽巢原理(pigeonhole principle),碰撞必然存在。
碰撞的定义很直接:两个不同的输入,产生相同的哈希值。
hash("文件A") = 9e107d9c...
hash("文件B") = 9e107d9c... ← 与上面相同,但 文件A ≠ 文件B
存在本身不可怕。真正的问题是找到碰撞的计算成本。如果寻找一对碰撞需要 2 的 128 次方次运算,按当前算力全人类都算不出来,那这个函数在实际意义上就是安全的。
生日悖论:碰撞比想象中近
直觉告诉我们,128 位的哈希空间意味着碰撞概率是 2 的负 128 次方。这个直觉是错的,它忽略了多次尝试的累积效应。
经典的生日悖论:23 人的房间里,有两人生日相同的概率超过 50%。不是 365 的一半,而是远小得多。原因是概率随人数平方级增长。
对哈希函数也一样。要找到一对碰撞,平均需要的尝试次数不是 2 的 N 次方,而是 2 的 N/2 次方。这就是所谓的"生日界"(birthday bound)。
- 128 位哈希(MD5):生日攻击约需 2 的 64 次方次运算
- 160 位哈希(SHA-1):生日攻击约需 2 的 80 次方次运算
- 256 位哈希(SHA-256):生日攻击约需 2 的 128 次方次运算
这就是为什么 SHA-256 提供 128 位的碰撞抗性,而不是 256 位。
MD5:从破裂到实际攻击
MD5 在 1992 年发布。早期被认为是安全的,但随着算力增长和分析深入,问题逐渐暴露。
关键时间线:
| 年份 | 事件 | |------|------| | 2004 | 王小云团队宣布找到 MD5 碰撞,耗时不到一小时 | | 2007 | 研究者展示chosen-prefix 碰撞,可以同时控制两个文件的前缀 | | 2008 | 利用 MD5 碰撞伪造 CA 证书,可以冒充任何 HTTPS 网站 | | 2012 | Flame 蠕虫用 MD5 碰撞伪造微软代码签名证书 |
2008 年那次攻击尤其严重。研究者构造了两份内容不同的证书,一份合法、一份恶意,它们产生相同的 MD5 哈希。CA 机构用 MD5 签名合法那份,攻击者拿到的签名对恶意那份同样有效。整个 Web 信任体系差点崩塌。
今天,MD5 在任何安全敏感场景都不应该使用。
SHA-1:SHAttered 攻击
SHA-1 输出 160 位,理论上碰撞抗性是 2 的 80 次方次运算。但实际比理论更脆弱。
2017 年 2 月,Google 和 CWI Institute 联合发布 SHAttered 攻击。他们构造出两个不同的 PDF 文件,产生完全相同的 SHA-1 哈希:
shattered-1.pdf → 38762cf7f55934b34d179ae6a4c80cadccbb7f0a
shattered-2.pdf → 38762cf7f55934b34d179ae6a4c80cadccbb7f0a
整个攻击消耗了 6500 个 CPU 计算一年,外加 110 个 GPU 计算一年,成本约 11 万美元。对国家级攻击者来说,这个成本微不足道。攻击发布后,主流浏览器和 Git 都停止接受 SHA-1 证书。
SHA-256:至今无碰撞
SHA-256 输出 256 位,碰撞抗性 2 的 128 次方次运算。截至 2026 年,没有任何公开的碰撞实例。
要暴力破解 SHA-256 的碰撞,按当前全球算力估算需要约 10 的 22 次方年。宇宙年龄大约是 10 的 10 次方年。差了 12 个数量级。
这不是说 SHA-256 永远安全。理论上它同样存在碰撞,但找到碰撞需要的算力在可见的未来都不可达。
Python 演示:碰撞的概念
下面这段代码演示如何用 Python 计算两个字符串的哈希。注意,MD5 这里只是为了演示概念,不是说我们真的能在本机找到 MD5 碰撞:
import hashlib
def md5(s: str) -> str:
return hashlib.md5(s.encode()).hexdigest()
a = "hello"
b = "world"
print(md5(a)) # 5d41402abc4b2a76b9719d911017c592
print(md5(b)) # b1a5b5d85c3f3e3a3e8c1a8b3e3a3e8c3a3e3a3e
# 这两个不同字符串产生了不同的哈希,符合预期
# 真实的碰撞需要专门构造的输入对,例如这样校验:
def verify_collision(x: str, y: str) -> bool:
if x == y:
return False # 必须是不同输入
return md5(x) == md5(y)
# 公开的 MD5 碰撞示例(精简示意):
# 这两个 128 字节的数据块产生相同的 MD5,由 Marc Stevens 构造
# 实际数据较长,此处省略。感兴趣可以搜索 "MD5 collision example"
实际工程里,校验文件完整性的代码大概是这样:
import hashlib
def file_sha256(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
expected = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
actual = file_sha256("./download.bin")
print("校验通过" if actual == expected else "文件已损坏或被篡改")
真实世界的后果
碰撞攻击听起来抽象,但它的工程后果非常具体。
文件完整性校验:如果用 MD5 校验下载的安装包,攻击者可以构造一个恶意安装包,与原包产生相同的 MD5。你算出的哈希一致,但运行的是木马。
代码签名:CA 证书、驱动签名、macOS 应用公证都依赖哈希。SHA-1 被弃用正是因为这个风险。
区块链:比特币地址用 SHA-256(双重哈希)。如果碰撞成本足够低,攻击者可以构造两笔不同的交易产生相同的交易 ID,导致交易被替换。
Git 对象:Git 内部用 SHA-1 标识 commit、tree、blob。SHAttered 之后,Git 项目逐步迁移到 SHA-256。
选哪个算法:决策表
按使用场景选择:
| 场景 | 推荐算法 | 理由 | |------|---------|------| | 文件完整性校验 | SHA-256 | MD5 可被构造碰撞 | | 密码存储 | bcrypt / Argon2 | 哈希函数不适合密码,需要加盐和慢哈希 | | 数字签名 | SHA-256 或 SHA-3 | 行业标准,所有主流 CA 支持 | | HMAC 消息认证 | HMAC-SHA256 | 共享密钥 + 哈希,抵抗长度扩展攻击 | | 区块链 / 加密货币 | SHA-256 | 算法本身已经过密码学社区长期检验 | | 非安全场景(去重、缓存键) | MD5 或 xxHash | 性能优先,碰撞不构成风险 | | 内容寻址存储(Git 等) | SHA-256 | SHA-1 已不再安全 |
密码存储是个常见误区。SHA-256 速度太快,对暴力破解有利。bcrypt 和 Argon2 故意设计得慢,并内置加盐,才是密码哈希的正确选择。
用 DevToolkit Pro 计算和校验哈希
实际开发中经常需要计算文件或字符串的哈希值。下面三个工具都在浏览器本地运行,不会上传数据:
- MD5 哈希生成器:快速计算 MD5,适合非安全场景的去重和缓存键
- SHA-256 哈希生成器:计算 SHA-256/384/512,适合文件完整性校验和数字签名场景
- 哈希校验器:粘贴原文和期望哈希值,工具自动比对,验证文件是否被篡改
纯前端实现意味着敏感数据不会经过任何服务器。即使你校验的是公司内部文件,数据也不会泄露。
总结
哈希碰撞不是"会不会发生"的问题,而是"找到它需要多大成本"。MD5 的碰撞成本早已低到攻击者可以批量利用。SHA-1 在 2017 年被实际攻破。SHA-256 的碰撞成本在 2 的 128 次方量级,按当前算力不可达。选哈希算法时记住一条:任何安全敏感场景都应该用 SHA-256 或更强的算法,把 MD5 留给去重和缓存这类不在乎碰撞的场景。
本文由 DevToolkit Pro 提供。更多开发者工具请访问 首页。