两个不同文件算出一样的 MD5,这不是 Bug 是碰撞

2025-12-20编码转换

MD5 可能是程序员接触最多的哈希算法。做文件校验用 MD5,存密码用 MD5,爬虫去重用 MD5。但我自己真正开始研究它,是因为一次线上事故——用户上传的文件名重复导致覆盖,我用了 MD5 对文件内容去重,结果两个完全不同的文件居然算出相同的 MD5。那一天我才意识到,MD5 的"唯一性"是个危险的错觉。

哈希函数不是加密

很多刚入门的人会问:MD5 能解密吗?这个问题本身就有问题。MD5 是哈希函数,不是加密算法。加密是可逆的——你加密了什么就能解密回来。哈希是单向的,给你一个 MD5 值,理论上你没法倒推出原始数据。

但"理论上"三个字很微妙。MD5 只有 128 位输出,意味着最多有 2^128 种可能的哈希值。这个数字看起来巨大(约 3.4×10^38),但跟无限多的输入比起来,碰撞是必然的——鸽巢原理,你比我更清楚。

问题在于,碰撞的概率有多大?以及,你能不能刻意制造碰撞?

MD5 的 128 位输出在 90 年代是安全的。当时最强大的攻击也需要 2^64 次运算才能找到碰撞——这在当时不现实。但摩尔定律和密码学的发展让这个门槛一路降低。2004 年,中国密码学家王小云团队提出了 MD5 碰撞攻击的新方法,把复杂度降到了 2^39。2006 年进一步降到 2^25。到了 2012 年,一台普通的笔记本电脑几秒钟就能造出 MD5 碰撞。

所以回到开头的问题:MD5 能不能"解密"?严格来说不能。但找到碰撞的成本已经低到可以忽略不计了。

输入处理

MD5 的处理流程分三步。第一步是对输入消息做填充和长度附加。

先看填充。MD5 要求输入按 512 位(64 字节)分组处理。如果输入不是 512 的倍数,就得填充。规则很简单:在消息末尾补一个 1,然后补 0,直到长度模 512 等于 448。最后 64 位写入原始消息的长度(位为单位)。

举个例子。假设输入是 "abc",二进制长度 24 位:

原始:                          01100001 01100010 01100011
补 1:                         01100001 01100010 01100011 1
补 0 到 448 位:               ...(后面全部是 0 直到 448 位)
最后 64 位写长度 24:           ...00000000 00011000

填充后的总长度正好是 512 的倍数。解码时通过最后 64 位知道原始长度,就能还原出原始数据。

这里有个细节我之前一直没注意:小端序。MD5 把 32 位字按小端序处理——低位字节在前。这个约定跟网络字节序(大端)相反,初次接触容易搞混。我在实现 MD5 时踩过这个坑,算出来的值跟标准结果对不上,排查了半天才发现是字节序的问题。

初始化状态

MD5 的内部状态是 4 个 32 位寄存器,分别叫 A、B、C、D。初始值是固定的:

A = 0x67452301
B = 0xEFCDAB89
C = 0x98BADCFE
D = 0x10325476

这 4 个值不是随便写的。它们来自 sqrt(2)、sqrt(3) 等常数的小数部分,取前 32 位。设计者选它们是为了让初始状态看起来足够随机。某种程度上,这些值就是 MD5 的"种子"。

64 轮压缩函数

这才是 MD5 的核心。每次处理 512 位(16 个字,每个字 32 位),经过 64 轮运算,更新 A、B、C、D 的值。

64 轮分成 4 组,每组 16 轮,每组使用不同的轮函数:

  • 第 1-16 轮(F 轮): F(X, Y, Z) = (X & Y) | (~X & Z)
  • 第 17-32 轮(G 轮): G(X, Y, Z) = (X & Z) | (Y & ~Z)
  • 第 33-48 轮(H 轮): H(X, Y, Z) = X ^ Y ^ Z
  • 第 49-64 轮(I 轮): I(X, Y, Z) = Y ^ (X | ~Z)

每一轮的具体运算:

temp = B + ((A + F(B, C, D) + M[i] + K[i]) <<< s[i])

其中:

  • M[i] 是当前 512 位分组中的第 i 个消息字
  • K[i] 是正弦常数,由 floor(2^32 * |sin(i+1)|) 计算得到
  • s[i] 是循环左移的位数
  • <<< 表示循环左移

然后整体左移:(A, B, C, D) = (D, temp, B, C)

64 轮跑完后,把这一轮的结果加到原来的 A、B、C、D 上。然后处理下一个 512 位分组。全部处理完,拿最终的 A、B、C、D 拼起来就是 128 位的 MD5 值。

图中展示的是单轮压缩函数的流程。实际工作中,每一轮的消息字索引 M[i]、常数 K[i] 和移位量 s[i] 都不同。这些参数是设计者通过实验确定的,目的是让每一位输入的变化都能充分扩散到输出中。

雪崩效应

好的哈希函数必须满足雪崩效应:输入改变 1 位,输出大约有一半的位发生变化。MD5 在这点上做得不错。

我做过一个测试。拿字符串 "hello world""hello worlD"(注意最后一位大写 D 和 d 的 ASCII 只差 32):

MD5("hello world")  = 5eb63bbbe01eeed093cb22bb8f5acdc3
MD5("hello worlD")  = f0d8b4a8d27a210b4a1ea9efd4f6b2a1

两个输出完全不同。二进制对比,差异位占比接近 50%。这个特性保证了 MD5 在用于数据完整性校验时,意外碰撞的概率极低。

碰撞攻击

但"意外碰撞"和"刻意碰撞"是两回事。

MD5 的设计缺陷在于它的压缩函数对输入差异不够敏感。2004 年王小云团队的方法本质上是差分攻击——通过精心构造两个不同的 512 位分组,让它们在每轮运算中的差异相互抵消,最终输出完全相同的哈希值。

具体来说,他们找到了一组差分路径:在某个特定位置翻转某些位,经过几十轮运算后,差异会以极高的概率被抵消。这种方法不需要穷举,计算量远小于暴力破解。

2006 年,Vlastimil Klima 进一步优化,把碰撞生成时间降到了分钟级别。他用的方法叫"隧道技术"(tunneling)——在 MD5 的某轮运算中发现了一个"隧道",允许在保持输出不变的前提下调整中间状态,从而加快碰撞搜索。

2012 年,黑客团伙"Team HashCat"演示了用 GPU 每秒生成 5.8 亿个 MD5 哈希,碰撞可以在数秒内找到。到了今天,MD5 碰撞已经是一个工程问题,而不是理论问题了。

常见的错误用法

MD5 被误用得最严重的地方是密码存储。很多网站曾经用 MD5(password) 直接存用户密码。攻击者只要拿到数据库,预计算好的彩虹表可以直接秒破。

更糟糕的是加盐不够。即便加了盐,MD5 的计算速度太快——GPU 每秒能算几十亿次,加盐只是增加了一点计算量,完全挡不住暴力破解。密码存储的正确方案是 bcrypt、scrypt 或 Argon2,它们的设计目标就是"算起来慢"。

文件校验是 MD5 相对安全的场景。前提是你控制着文件来源,并且只用来检测传输错误,而不是防篡改。如果你维护过包管理工具,用 MD5 校验下载的包是否完整。就会意识到如果中间人攻击者能替换文件,他也能替换对应的 MD5 值。真正防篡改需要 HMAC 或数字签名。

还有一个坑是哈希长度。MD5 的输出只有 128 位。按照生日悖论,找到一对碰撞只需要 2^64 次运算。SHA-256 输出 256 位,碰撞门槛是 2^128。2017 年 Google 演示了 SHA-1 碰撞(160 位,2^61 次运算),距离 MD5 只有一步之遥。

在用 MD5 哈希工具 时,可以做个小实验:随便输入一个字符串,记下 MD5 值,然后改一个字符再看。雪崩效应会让结果面目全非。这个工具支持中文输入和 UTF-8 编码,日常验证够用了。但如果你要做安全相关的验证——比如文件完整性防篡改、消息认证——建议换成 SHA-256 或 SHA-3。

写在最后

两个不同的 PDF 文件,内容分别是"用户协议 v1"和"用户协议 v2",MD5 完全相同。花半天排查日志,最终定位到是 MD5 碰撞。所以所有涉及安全边界的场景都应该用 SHA-256 了。

MD5 的意义在于它是哈希算法演进史上的一个里程碑。它比前代算法 MD4 更安全,比 SHA-1 更早出现,影响了后续所有哈希算法的设计。但密码学就是这样:今天的标准是明天的漏洞。理解 MD5 的结构不是为了继续用它,而是为了理解"为什么它不再安全"。