「用哈希保证文件没被改过」和「用哈希存密码」这两句话里的「哈希」是同一个函数,但它们需要的安全性质方向相反。混淆这一点会导致两类事故:把 MD5 当防篡改用,或者把 SHA-256 当密码存储用。
本文把这条边界讲清楚。工具支持见站内 开发者工具速查。
先分清:非对抗场景与对抗场景
判断某个哈希算法够不够用,先问一个问题:攻击者会不会主动构造输入?
非对抗场景(文件校验、缓存 key、ETag、数据分片)里,出问题的原因是随机因素——传输错误、磁盘故障、进程崩溃。攻击者没有动机为你构造碰撞,用 MD5 也不会有人去攻击你。
对抗场景(防篡改、签名、密码存储)里,攻击者有明确的动机和资源。此时算法必须假设攻击者会主动寻找弱点。
这个区分解释了为什么「MD5 能不能用」的答案取决于用途,也解释了为什么同一算法在两个场景里的评价完全相反。
CRC32 与 SHA-256:文件校验该用哪个
文件校验有两条路:
CRC32 —— 专门为检测传输错误设计的算法,速度极快(比 SHA-256 快一到两个数量级),但抗碰撞能力极弱(32 位,碰撞概率随文件数量平方增长)。适合校验「下载完整性」——确保传输没出错。
SHA-256 —— 密码学哈希,抗碰撞能力强(目前无实用攻击),速度慢一些。适合「防篡改」——确保文件不是被替换过的。
本站提供的是 SHA-256(哈希工具),因为它在两个场景下都够用,代价只是速度。如果你的场景是每秒校验几百个文件的构建流水线,CRC32 的速度优势才有意义。
MD5 与 SHA-1:碰撞已可构造
这两个算法的现状需要明确:
- MD5:碰撞构造已经可以在几秒内完成,任何人都能在普通笔记本上做
- SHA-1:SHAttered 攻击(2017)公开,当时代价约 4.5 万美元,且成本持续下降
碰撞构造的现实影响:
| 用途 | MD5/SHA-1 是否可用 |
|---|---|
| 文件下载校验(非对抗) | 可用(但没有理由不用 SHA-256) |
| 缓存 key | 可用(碰撞不构成威胁) |
| 数据库去重键 | 可用 |
| 文件签名 / 证书完整性 | ❌ 不可用 |
| 任何防篡改场景 | ❌ 不可用 |
一个需要注意的技术细节:碰撞攻击不等于能还原任意原文(那需要第二原像攻击,比碰撞难得多)。但对于「替换文件」这个场景,碰撞攻击已经足够了——攻击者准备两个文件 A(合法)和 B(恶意),让它们的哈希相同,然后替换。校验值没变,文件却变了。
结论:新项目一律用 SHA-256。 它没有已知的实用碰撞攻击,速度对绝大多数场景够用。
密码存储:必须用 KDF,不能用通用哈希
这是本文最重要的一节。
通用哈希(SHA-256、MD5)的设计目标是快——这正是密码存储最不需要的性质。攻击者拿到一份泄露的密码哈希列表后:
- GPU 每秒能算几十亿次 SHA-256
- 用 rainbow table(预计算的哈希对照表)逐个比对,几分钟就能还原大部分密码
密码存储需要故意慢的算法,而且要满足两个条件:
① 自动加盐。 相同密码在不同用户下产生不同哈希,这样一份 rainbow table 对所有用户都无效。盐不需要保密,但必须唯一。
② 可调成本参数。 让暴力破解的成本随硬件进步而提高——今天用 10 次迭代,硬件变强后可以提到 12 次而不需要改数据格式。
符合这两个条件的算法(KDF):
| 算法 | 特点 |
|---|---|
| Argon2id | 首选。抗 GPU 与 ASIC,可调内存与迭代 |
| bcrypt | 成熟稳定,内置盐,默认参数合理 |
| scrypt | 内存硬,抗侧信道 |
| PBKDF2 | 标准化最久,但参数需谨慎设置 |
密码绝不能用 SHA-256 直接存——它没有盐、没有成本参数、而且太快。本站的 bcrypt 工具 可直接生成。
长度扩展攻击:别自己拼 HMAC
这是个隐蔽但严重的错误。假设你在做「验证 webhook 签名」:
// ❌ 错误
const sig = hash(secret + body)
这个构造有长度扩展弱点。攻击者不知道 secret,但能给出:
hash(secret || body || padding || extra)
的合法签名,于是可以在原消息后追加任意内容,而验证会通过。
SHA-256 抗碰撞但不抗长度扩展(SHA-3、BLAKE2 抗)。正确做法是用 HMAC:
// ✅ 正确
const sig = crypto.createHmac('sha256', secret).update(body).digest('hex')
HMAC 从设计上处理了这个问题——它不是简单的拼接,内部有两轮混合。
顺带一条相关规则:任何 token / 签名的比较都要用 timingSafeEqual,不要用 ===。字符串比较在第一个不同字符处返回,耗时随公共前缀长度变化,攻击者能用这个差异逐字节猜出正确值。
迁移旧哈希:渐进方案
如果你的系统还在用 MD5 存密码,不必一次全改。标准做法是登录时升级:
- 用户登录时,用旧算法验证密码
- 验证通过后,立刻用新 KDF 重新哈希并保存
- 下次登录就走新路径
这个方案的巧妙之处:只有真实用户会触发升级,攻击者手上的哈希列表不会被更新(他们不知道密码)。随着正常用户陆续登录,旧哈希自然被替换完。
function login(password, stored) {
if (verifyLegacy(password, stored)) {
// ✅ 顺手升级为 KDF 格式
save(hashWithArgon2id(password))
return true
}
return false
}
本站的 密码强度工具 可以帮你评估用户密码的抗破解能力,为「拒绝弱密码」这类策略提供依据。
自查清单
- [ ] 先判断场景是「非对抗」还是「对抗」
- [ ] 文件校验用 SHA-256(除非有 CRC32 的速度刚需)
- [ ] 任何防篡改/签名场景不用 MD5 与 SHA-1
- [ ] 密码用 Argon2id / bcrypt / scrypt,绝不用 SHA-256 直接存
- [ ] 密钥场景用 HMAC,不自己拼
hash(secret + data) - [ ] token/签名比较用
timingSafeEqual - [ ] 迁移旧哈希用「登录时升级」的渐进方案
可复现的实测结果
用本站的 bcrypt 工具 对同一个密码连续哈希两次,会得到两个完全不同的结果——因为 bcrypt 自动加了随机盐。这直观演示了「为什么必须加盐」:彩虹表对每个用户都失效。用 哈希工具 对同一文件算两次则结果相同(确定性哈希的正常行为),这解释了为什么文件校验可以用它而密码不能。