MD5 是哈希函数,不是"不能解密的加密"。它把输入映射成 128 位摘要,没有一把解密钥匙可以把摘要还原成原文。本篇把算法流程、输入字节、在线反查、碰撞和使用边界连起来,并用 Python 复现标准测试向量。
同为密码学工具,职责不同
RSA、SM2 具有公钥和私钥,并分别定义加密、签名等方案;MD5 本身没有密钥,只计算摘要。Base64、十六进制又属于编码,能还原原始字节。不能看到一串"像乱码的字符"就认定它是加密结果。
MD5 输出 128 位,也就是 16 字节。每个字节写成两位十六进制,所以常见字符串长度是 32。所谓"16 位 MD5"常指应用截取了 32 字符输出的一部分,不是标准另外定义了一种 16 字节之外的新算法。截断不会增强安全性。
算法如何从任意字节走到 16 字节
按 RFC 1321 的算法描述,消息先补一个 1 位和若干 0,使长度达到模 512 为 448;随后追加原始比特长度的低 64 位,按小端表示。于是最终长度是 512 位,也就是 64 字节的整数倍。即使原消息长度已经合适,也仍然要填充。
内部维护四个 32 位状态字 A、B、C、D;每个 64 字节分组拆成十六个小端 32 位字。每个分组经历 64 步运算,分为四轮,混合布尔函数、常数、模 2^32 加法和循环左移;再把结果累计回状态。全部分组处理完后,以规定字节序输出四个状态字。
例如三字节 abc 的原始长度是 24 比特,填充后占一个分组。长文件则逐块推进状态,所以流式 update 可以得到与一次性输入完全相同的摘要。注意:这是同一个哈希对象接收连续字节,绝不是把各块 MD5 文本拼起来再算一次。
这里讲流程是为了理解分组、字节序和流式行为,不建议为了业务需要手写压缩函数。正确实现算法,也不会修复 MD5 已知的密码学弱点。
"不可逆"与"能猜中"不矛盾
固定长度输出无法唯一容纳任意长度原文,必然存在不同输入得到相同摘要。因而不存在一个能从所有 MD5 值唯一恢复所有原文的通用解码函数。不过,这个多对一事实本身并不能证明某个哈希难以攻击。
工程上还要分清三类问题:碰撞是在可选择的消息中寻找不同的 x、y 使摘要相同;原像是给定一个摘要寻找匹配输入;第二原像是给定一个消息,寻找另一个同摘要消息。三者不是同一个任务。"MD5 碰撞已被攻破"不等于"任意目标文件现在都能从摘要恢复"。
所谓在线解密可能采用已有数据库、字典或候选枚举。比如候选只有 red、green、blue,分别算一次即可找出匹配 green 的项;这依靠范围很小,不是把哈希运算反向执行。匹配只能证明候选摘要相同,无法由摘要单独证明它一定是历史原文。
完整 Python 实验:标准向量、换行和流式输入
2026-09-28 在 macOS 27.2 arm64、Python 3.13.13 实际运行。只使用标准库 hashlib。保存为 md5_demo.py,执行 python3 md5_demo.py;候选反查只用三个虚构颜色词,不涉及任何账户数据。
python
import hashlib
vectors = {
b'': 'd41d8cd98f00b204e9800998ecf8427e',
b'a': '0cc175b9c0f1b6a831c399e269772661',
b'abc': '900150983cd24fb0d6963f7d28e17f72',
b'message digest': 'f96b697d7cb7938d525a2f31aaf161d0',
b'abcdefghijklmnopqrstuvwxyz': 'c3fcd3d76192e4007dfb496cca67e13b',
b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789': 'd174ab98d277d9f5a5611c2c9f419d9f',
b'1234567890'*8: '57edf4a22be3c955ac49da2e2107b67a',
}
for raw, expected in vectors.items():
assert hashlib.md5(raw).hexdigest() == expected
print('RFC1321_vectors=7/7')
for raw in (b'abc', b'abc\n', b'abc\r\n', '中文'.encode('utf-8')):
digest = hashlib.md5(raw)
print(raw.hex(), len(raw), digest.hexdigest())
assert len(digest.digest()) == 16 and len(digest.hexdigest()) == 32
parts = [b'ab', b'c']
h = hashlib.md5()
for part in parts:
h.update(part)
assert h.hexdigest() == hashlib.md5(b'abc').hexdigest()
assert h.hexdigest() != hashlib.md5(b'abc\n').hexdigest()
print('streaming=equal newline=different')
# A finite toy lookup, not inversion and not a password attack.
candidates = ['red', 'green', 'blue']
target = hashlib.md5(b'green').hexdigest()
found = [s for s in candidates if hashlib.md5(s.encode()).hexdigest() == target]
assert found == ['green']
print('toy_lookup=', found)
本次实际输出:
text
RFC1321_vectors=7/7
616263 3 900150983cd24fb0d6963f7d28e17f72
6162630a 4 0bee89b07a248e27c83fc3d5951213c1
6162630d0a 5 c13b6afecf97ea6b38d21a8f5167fa1e
e4b8ade69687 6 a7bac2239fcdcb3a067903d8077c4a07
streaming=equal newline=different
toy_lookup= ['green']
abc、abc 加 LF、abc 加 CRLF 分别是 3、4、5 字节;中文两字 UTF-8 编码为 6 字节。输出不同不是哈希不稳定,而是输入根本不同。7 个 RFC 向量通过,只说明当前函数结果符合这些已知样本,不代表 MD5 安全。
Python hashlib 文档 说明 update 接收字节对象,并提示 MD5 的碰撞弱点及部分受限构建的可用性差异。若运行环境禁用 MD5,应遵从系统策略或更换学习环境,不把关闭安全策略当作排障步骤。
接口签名不一致,先检查这五件事
第一,参与摘要的是字符串的 UTF-8、GBK,还是已经解码的二进制。第二,末尾是否有换行、空格或 BOM。第三,JSON 数字、字段顺序及分隔符是否遵循同一协议。第四,对端计算的是原始字节还是十六进制文本。第五,输出是否又做了大小写变化、截断或 Base64。
这里不应武断地给所有请求排序字段或删除空白。业务协议要求签原始正文时,任何"帮忙清洗"都改变输入。可在本地记录非敏感最小样本的长度和十六进制对照,不要把真实请求密钥、Cookie 或用户数据粘到在线工具。
即使参数对齐,直接 MD5(message) 也不能证明消息是谁发的:任何人都能重新计算。自己拼 secret+message 不是标准 HMAC,MD5 这类结构还涉及长度扩展等问题。新系统应采用协议约定的成熟 MAC 或签名方案。
还能在哪里使用,哪里必须换思路
RFC 6151 明确不再接受 MD5 用于需要抗碰撞的场景,包括数字签名。只检查非恶意传输差错与面对主动攻击者,是不同的安全目标。新设计不必为了"大家都用过"继续选择 MD5。
下载文件时,摘要只有来自可信渠道才有意义。即使用 SHA-256,若攻击者同时能替换文件和摘要,裸哈希也不能提供来源认证。去重时不要把一个有碰撞可能的摘要直接当永不冲突的全局主键;可按要求使用更合适的摘要并保留字节比较等判定机制。
密码存储也不是把 MD5 换成一次 SHA-256 就完成。快速哈希有利于离线批量猜测;随机盐避免相同密码有相同存储值,却不会把快速哈希变慢。OWASP 的密码存储指南 推荐使用专门的密码哈希方案,例如 Argon2id,并按环境选择参数和迁移策略。
学习 MD5 的价值,是读懂旧协议、定位输入错误,并知道什么时候该升级方案。判断一个接口时,先问它需要保密、来源认证、密码存储,还是普通内容指纹,再选择合适的工具。
本文使用 AI 辅助整理与写作,代码和列出的输出已在文中环境实际运行;未进行生产环境、跨厂商或安全认证测试。