2011 年,CSDN 的 600 万用户密码被泄露。密码是明文存储的。这件事在当时的互联网圈引起了很大的震动——不是因为 CSDN 被黑了,而是因为一家这么大的技术社区,居然用明文存密码。
更让人意外的是,2011 年之后的很多年里,依然有大量的网站被爆出明文存储密码。2021 年 Facebook 被曝出以明文形式存储了数亿用户的密码(虽然是内部可读,不是对外泄露)。2023 年 Twitter 也被曝出类似问题。
我不是在嘲笑这些公司——我的第一个 Web 项目也是明文存密码的。当时觉得"谁会来黑我的小破站"。这种想法很天真。因为你不知道你的数据库哪天会以什么方式流出去,而一旦流出去,所有用户的密码都暴露了。
哈希不是加密
很多非技术人员(甚至一些开发者也)会混淆"加密"和"哈希"。
加密是可逆的。你用密钥加密密码,持有密钥的人可以解密得到原文。如果服务器被攻破,攻击者拿到了密钥,所有密码就都暴露了。
哈希是不可逆的。你把密码通过哈希函数处理成一个固定长度的摘要,这个摘要不能还原成原始密码。服务器验证时,把用户输入的密码做同样的哈希计算,比对结果是否一致。
所以正确的密码存储流程是:
- 用户注册时提交密码 "mypassword"
- 服务器计算 hash("mypassword") = "a1b2c3..."
- 数据库存储 "a1b2c3..."
- 用户登录时提交密码 "mypassword"
- 服务器计算 hash("mypassword") = "a1b2c3..."
- 比对哈希值,一致则登录成功
攻击者即使拿到了数据库,也只知道哈希值,不知道原始密码。
但事情没有这么简单。
彩虹表攻击,同样的密码产生同样的哈希
如果网站直接对密码做 MD5 或 SHA-256 哈希,两个用户的密码相同的话,哈希值也相同。攻击者可以先预计算所有常见密码的哈希值,建立一个映射表。这个表就是"彩虹表"。
彩虹表攻击的原理:假设 "123456" 的 MD5 是 "e10adc3949ba59abbe56e057f20f883e"。攻击者在泄露的数据库里搜索这个哈希值,就能找到所有用 "123456" 的用户。
2012 年有一个公开的彩虹表包含 95 亿条记录,覆盖了所有 1-8 位的小写字母数字组合。对 8 位以内的纯小写密码,破解成功率接近 100%。
解决方案是加盐(salt)。盐是一个随机生成的字符串,每个用户不同。 存储时,把盐和密码拼接后再哈希:
hash(password + salt)
盐和哈希值一起存储在数据库里。即使两个用户的密码相同,因为盐不同,哈希值也不同。攻击者无法预计算彩虹表——彩虹表需要针对每个盐值单独生成,变得不可行。
但加盐解决的是"相同的密码产生相同的哈希"这个问题,没有解决"哈希函数太快"的问题。
哈希速度太快是问题
MD5 和 SHA-256 被设计用来做数据完整性校验的。"快"是它们的设计目标——能快速处理大文件。
但快对密码存储来说是坏事。攻击者可以用 GPU 每秒尝试数十亿个密码。单块 RTX 4090 每秒可以算大约 1000 亿次 MD5,50 亿次 SHA-256。
如果数据库里一个用户的盐和哈希值是 salt=abc123, hash=def456,攻击者可以用 GPU 每秒尝试 50 亿个密码组合,不需要针对每个用户重新计算——他可以先算 SHA-256("password1" + "abc123") 跟目标哈希比,不对就试下一个。如果用户的密码在常见密码列表里(大概几百万个),几毫秒内就能破解出来。
这就是为什么需要慢哈希算法。
bcrypt,故意变慢的哈希
bcrypt 是 1999 年由 Niels Provos 和 David Mazières 设计的密码哈希函数。它不是为了让计算更快,而是为了让计算更慢。
bcrypt 有一个 cost 参数(也叫 work factor),控制哈希的计算次数。cost 每增加 1,计算时间翻倍。
- cost = 10:大约需要 10ms(单核 CPU)
- cost = 12:大约需要 40ms
- cost = 14:大约需要 160ms
10ms 对用户登录来说几乎感觉不到延迟。但对攻击者来说,160ms 破解一个密码意味着每秒只能尝试 6-7 个——跟 MD5 的每秒 1000 亿个相比,差距是天文数字。
bcrypt 的另一个好处是它内部自动处理了盐的生成和管理。bcrypt 输出的哈希值包含算法标识、cost 参数、盐和实际哈希值在一个字符串里:
$2b$10$rL3fG5sH7jK9mN2pQ4rT6uV8wX0yZ1aB3cD5eF7gH8iJ9kL0mN1
↑ ↑ ↑ ↑
算法 cost 盐值 (22 字符) 实际哈希值 (31 字符)
你不需要单独管理盐,不需要操心盐的长度、盐的随机性。bcrypt 全都处理好了。
实际项目中的调参
一个旧系统用的是 MD5 哈希密码。安全审计要求改成 bcrypt。迁移策略是:用户登录时,检查密码是否还是 MD5 格式,如果是则验证 MD5 后再用 bcrypt 重新哈希并存储。
这样不需要用户重置密码,也不用发全员邮件。但代价是旧用户第一次登录时做了两次哈希运算。对服务端来说还好,毕竟登录不是高频操作。
cost 参数的选择:我当时用了一台低配云服务器(1 核 CPU)做压测。cost = 10 平均耗时 12ms,cost = 12 平均耗时 48ms。考虑到登录请求的并发量不大,选了 cost = 12。再高的话,用户登录高峰期 CPU 可能会被打满。
如果你用的是云服务商提供的身份认证服务,它们通常默认 cost = 10 左右。如果你自己实现认证系统,建议先在自己的服务器配置上压测一下,找到延迟可接受的最大 cost 值。
现代方案,argon2
2015 年,Password Hashing Competition 的获胜者是 argon2。它比 bcrypt 更安全。
argon2 有三个变体:
- argon2d:抗 GPU 攻击,但容易受到侧信道攻击
- argon2i:抗侧信道攻击,适合密码哈希
- argon2id:混合模式,推荐选择
argon2 比 bcrypt 强的地方在于:它不仅是计算密集型的,还是内存密集型的。bcrypt 只需要少量内存,而 argon2 允许你配置内存用量——这使得攻击者用 GPU 并行破解的难度大增,因为 GPU 的内存是有限的。
argon2 有四个可调参数:
- 时间成本(t):类似于 bcrypt 的 cost,控制计算次数
- 内存成本(m):控制内存用量(单位 KB)
- 并行度(p):控制线程数
- 输出长度(tagLength):哈希值的长度
推荐配置(来自 OWASP):m = 64MB, t = 3, p = 4。但具体要看你的服务器能承受多少。
不要自己造轮子
这里有一个很重要的建议:不要自己实现密码哈希逻辑。
我看到过一些"自创"的密码哈希方案:
- 把密码反转后做 MD5
- 把密码每个字符后面加一个固定字符再做 SHA-256
- 用 AES 加密密码,密钥写死在代码里
这些方案要么没有解决"快"的问题,要么是可逆的,要么两者都有。
用标准库:
- Node.js:
bcrypt或bcryptjs包 - Python:
bcrypt或hashlib+argon2-cffi - Java:
spring-security-crypto内置 bcrypt - Go:
golang.org/x/crypto/bcrypt - Ruby:
bcryptgem
如果你在生成密码相关的工具,随机密码生成器可以生成高强度随机密码,但密码的存储安全性取决于服务端的实现——再强的密码如果存在一个用 MD5 哈希的数据库里,也是不安全的。
写在最后
密码存储的安全演进经历了三个阶段:明文 → 加盐哈希 → 慢哈希。每前进一步都是在应对一个明确的攻击手段。bcrypt 和 argon2 出现二十多年了,但很多网站到现在还在用 MD5,甚至明文。
你没法控制网站怎么存你的密码,但你可以做到:
- 不同网站用不同密码
- 开启两步验证(2FA)
- 用密码管理器生成高强度随机密码