MD5 和 SHA-1 为何已被攻破 — 以及"被攻破"的真正含义
MD5 由 Ron Rivest 于 1991 年设计。SHA-1 由 NSA 于 1995 年设计。两者都是通用密码哈希函数,都生成固定长度的摘要,并且多年来都被认为已"被攻破"。但"被攻破"一词需要明确定义,因为它涵盖了两种截然不同的攻击。
碰撞攻击是指攻击者能够找到两个不同的输入 A 和 B,使它们产生相同的哈希值。第二原像攻击是指攻击者在给定固定输入 A 的情况下,能够找到哈希到相同值的另一个输入 B。后者的难度大得多。MD5 自 2004 年起碰撞即被攻破,如今在一台笔记本电脑上几分钟就能生成实际可用的碰撞。SHA-1 的第一个实际碰撞由 Google 的"SHAttered"攻击于 2017 年 2 月演示 — 他们生成了两个具有相同 SHA-1 哈希的不同 PDF 文件,成本约为 6,500 CPU 年和 11 万美元的 AWS GPU 计算,2020 年的"Shambles"论文将其降至约 4.5 万美元。MD5 和 SHA-1 目前都还没有实际可行的第二原像攻击,但差距逐年缩小,而"我们无法给攻击者一个固定目标"是一个不宜依赖的脆弱属性。
实际影响:停止将 MD5 或 SHA-1 用于任何安全目的。现实世界中的事件并非纸上谈兵。Flame 恶意软件(2012 年,据信为国家行为者所为)滥用了 Microsoft Terminal Services 证书签名链中的 MD5 碰撞,伪造了一张有效的 Windows 代码签名证书。Git 在 2.13 过渡计划之前一直使用 SHA-1 进行内容寻址,而 SHAttered 演示的构造正是为了表明两个不同的提交可以共享同一个哈希 — Git 于 2017 年引入了碰撞检测,并正在向 SHA-256 迁移。CA/Browser Forum 于 2017 年强制 TLS 证书弃用 SHA-1。PGP、S/MIME 以及大多数代码签名也随之跟进。
那么 MD5 和 SHA-1 在什么场景下仍可接受?非对抗性的完整性校验。如果你计算哈希是为了检测随机损坏(CDN 文件与源站的对比、下载的产物与公布的校验和的对比),且攻击者没有伪造碰撞的动机,MD5 和 SHA-1 仍然可用。在抗碰撞性不属于安全模型的数据结构内部,它们作为指纹也依然合适 — Git 的 SHA-1 在 2026 年之所以基本还能存活,是因为 Git 的威胁模型假定生产者是可信的,而内容寻址只是一种便利手段。
现代推荐选择:任何新系统使用 SHA-256 或 SHA-3-256,需要高吞吐量时以 BLAKE3 作为快速的现代替代方案。SHA-256 自 2001 年以来一直稳固,没有出现有意义的削弱;密码学界有信心再对它托付 20 年以上的信任。SHA-3(Keccak,2015 年标准化)在结构上与 SHA-2 不同,因此即使 SHA-2 被发现缺陷,SHA-3 也不太可能受到同样的影响。BLAKE3 在软件实现中比 SHA-256 快约 5 倍,安全级别相同,非常适合超大文件。
// MD5 collision in seconds (e.g., HashClash, fastcoll)
fastcoll input.bin a.bin b.bin
md5sum a.bin b.bin
# 008ee33a9d58b51cfeb425b0959121c9 a.bin
# 008ee33a9d58b51cfeb425b0959121c9 b.bin <-- different files, same MD5
// Modern hashing in Node 20+
import { createHash } from 'node:crypto';
createHash('sha256').update(buf).digest('hex'); // recommended
createHash('sha3-256').update(buf).digest('hex'); // SHA-3 (Keccak)
createHash('blake2b512').update(buf).digest('hex'); // BLAKE2
// BLAKE3 — install @noble/hashes for portability
import { blake3 } from '@noble/hashes/blake3';
blake3(buf, { dkLen: 32 }); // 32-byte digest
// Decision:
// - Adversary involved (signatures, content auth)? → SHA-256 / SHA-3 / BLAKE3
// - Just detecting random corruption (CRC-style)? → MD5/SHA-1 still OK
// - Speed matters on huge files (TB-scale)? → BLAKE3Argon2id vs scrypt vs bcrypt — 现代密码哈希
对于密码,直接使用 SHA-256 是错误的。SHA-256 被设计为快速 — 在 GPU 上每秒可计算数十亿次哈希。密码哈希的全部意义就在于足够慢,使被盗数据库无法通过暴力破解获利。自 2000 年代以来,三个算法家族占据主导地位,OWASP 2026 年密码存储备忘单按以下顺序推荐:
Argon2id(2015 年 Password Hashing Competition 冠军;2021 年在 RFC 9106 中标准化)。三个参数:内存成本(m,单位 KiB)、时间成本(t,迭代次数)、并行度(p,线程数)。"id"变体混合了数据相关和数据无关的处理路径,可同时抵抗侧信道攻击和时间-内存权衡攻击。OWASP 2026 基线:m=19456(19 MiB)、t=2、p=1,或 m=12288、t=3、p=1,或 m=7168、t=5、p=1 — 选择你的服务器在登录高峰负载下能够承受的那一组。对新系统而言,Argon2id 是毫无争议的首选。主要库:argon2(Node)、argon2-cffi(Python)、org.bouncycastle.crypto.generators.Argon2BytesGenerator(Java)。
scrypt(2009 年,Colin Percival)。两个参数:N(CPU/内存成本因子,必须是 2 的幂)、r(块大小)、p(并行化)。推荐值(2026 年):N = 2^17(约 128 MiB)、r=8、p=1。scrypt 具有内存困难性,但不具备 Argon2 的抵抗特性。如果因兼容性原因不得不继续使用,它仍然可以接受。
bcrypt(1999 年,Niels Provos)。一个参数:成本因子(work factor;每增加 1,成本翻倍)。推荐值(2026 年):根据你的硬件预算取 cost = 12 或 13 — 在 2026 年,低于 10 都太快了。bcrypt 的缺陷在于输入长度有 72 字节上限(更长的密码会被静默截断)、不使用可观的内存,并且对专用硬件没有抵抗能力。它用于遗留系统没有问题,但不是今天新选型时的答案。PBKDF2 更古老也更弱 — 只有在 FIPS 合规强制要求时才可接受。
三者共同的一个关键实现要点:永远不要自己编写 bcrypt 的比较逻辑。verify 函数使用常量时间比较来避免时序攻击。对十六进制编码的摘要使用简单的 == 会泄露前面有多少个字符匹配的信息。请使用 argon2.verify(hash, password) 或 bcrypt.compare(password, hash),绝不要对哈希本身使用相等运算符。
迁移到更强算法时的策略:不要一次性强制所有用户重新哈希。而是在下次登录时,先用旧算法验证通过后,再用新算法重新哈希并存储。这就是"惰性迁移"(lazy migration)模式,也正是 Stack Exchange 在十年间从 MD5 迁移到 bcrypt 再到 Argon2 时采用的做法。
// Argon2id with OWASP 2026 baseline
import argon2 from 'argon2';
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
// Stored format embeds params:
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
const ok = await argon2.verify(hash, password); // constant-time
// Lazy migration on login
async function login(email, password) {
const user = await db.users.findOne({ email });
if (!user) return null;
if (user.hash.startsWith('$argon2')) {
if (!await argon2.verify(user.hash, password)) return null;
} else if (user.hash.startsWith('$2')) { // bcrypt
if (!await bcrypt.compare(password, user.hash)) return null;
// upgrade!
const newHash = await argon2.hash(password, ARGON2_OPTS);
await db.users.update({ id: user.id }, { hash: newHash });
}
return user;
}密码哈希 vs 非密码哈希 — 选用正确的类别
哈希函数分为设计目标截然不同的两大家族,把一类用在属于另一类的场景中是一个常见的 bug。
密码哈希(SHA-256、SHA-3、BLAKE3、BLAKE2)优先保证面对对抗性输入时的抗碰撞性、抗原像性和抗第二原像性。它们刻意不追求极致速度。只要输入可能被恶意方控制,就应使用它们 — 内容寻址、签名、安全相关上下文中用作标识符的哈希、密码哈希(使用上文的专用函数)、HMAC、密钥派生。在带硬件 SHA 扩展的现代 x86 上,SHA-256 约为 1.5–2 GB/s;BLAKE3 为 5–10 GB/s;SHA-3 比 SHA-2 略慢。
非密码哈希(xxHash、MurmurHash、CityHash、FNV、FarmHash)优先追求原始速度和良好的统计分布。它们对对抗性输入不提供任何保证 — 基于 xxHash 的设计,攻击者可以构造出全部碰撞到同一个桶的输入。xxHash 在现代 CPU 上约为 30+ GB/s(SHA-256 的 10 倍),这正是它存在的全部理由。适用场景:哈希表、Bloom 过滤器、非对抗性数据的去重键、只需担心随机损坏的完整性校验(网络数据包、内存位翻转)、按客户端对日志分桶。
典型的 bug 模式:在对抗性上下文中使用非密码哈希。经典的 2003 年"Crosby & Wallach"论文指出,早期的 Perl、Java 和 PHP 哈希表把这类哈希用于字典键,能够提交表单参数的远程攻击者可以精心构造全部碰撞的输入,把 O(1) 查找变成 O(n),从而对服务器实施 DoS。各语言采用的修复方案(带每进程随机密钥的 SipHash 算法)本身就是一种"带密钥的密码哈希" — 像非密码哈希一样快,但在攻击者不知道密钥时是安全的。
相反的 bug — 用 SHA-256 做哈希表 — 只是慢而已。它能正确工作,但把 CPU 浪费在应用并不需要的密码学保证上。在内层循环和语言运行时中,100 倍的速度差异非常重要。
快速决策树:不可信输入且碰撞影响正确性或安全 → SHA-256 / SHA-3 / BLAKE3。可信或低风险的随机数据且速度重要 → xxHash / MurmurHash。任意用户输入的哈希表键 → SipHash(Python 3.4+、Ruby、Rust 通常由语言替你选择)。密码 → Argon2id。
// CRYPTOGRAPHIC — adversary-resistant
import { createHash } from 'node:crypto';
createHash('sha256').update(buf).digest('hex');
// ~1.5 GB/s on modern x86
// NON-CRYPTOGRAPHIC — fast, NOT for security
import xxhash from 'xxhash-wasm';
const { h64 } = await xxhash();
h64(buf); // ~30+ GB/s, 64-bit digest
// Use for: hashtables, dedup, sharding non-adversarial data
// HASH TABLE BUG — never use plain non-crypto hash on attacker input
class BadCache {
constructor() { this.buckets = new Array(1024); }
set(k, v) {
const i = murmur(k) % 1024; // attacker can force all to same bucket
(this.buckets[i] ||= []).push([k, v]);
}
}
// CORRECT — Map / Object use the language's keyed/hardened hasher
const cache = new Map();
cache.set(userInput, value); // V8 uses random-keyed hashing
// Non-adversarial integrity (e.g., copy across rack)
const a = h64(file);
const b = h64(receivedFile);
if (a !== b) console.log('corrupted in transit');