不知道之前发生有关明文存储的密码案例是否还记得。
2009 年,RockYou 被黑客拖库,3200 万用户密码明文存储——这是安全史上最著名的明文密码泄露事件。影响远不止一个网站,大量用户在其他平台也用相同密码,连锁反应非常严重。
2012 年 LinkedIn 被拖库,650 万条密码哈希泄露。他们用的是 SHA-1 而且没加盐。虽然不是明文,但 SHA-1 是快哈希,GPU 跑起来每秒几亿次,绝大多数密码很快被还原。LinkedIn 后来在 2016 年又泄露了 1.17 亿条数据。
2019 年 Facebook 被曝数亿用户密码明文存储——不是哈希,就是明文,存在内部日志里。Facebook 工程师说"这是我们的 bug",但谁也不知道这些日志被多少人访问过。
国内类似案例也不少。某大厂数据库泄露,几千万条用户数据包含明文密码。这类新闻每隔一两年就会上一次热搜。
每次看到这种新闻我就想起自己踩过的坑。密码明文存储不光是"懒"的问题,是没意识到后果有多严重。
哈希函数的基础
不存明文,那就存哈希。"哈希"是一个函数,吃进去任意数据,吐出来固定长度的值。它有几个关键特性:
- 单向性:从哈希值不能反推原始数据
- 抗碰撞:极难找到两个不同输入得到相同输出
- 雪崩效应:输入改一个比特,输出完全不同
常见的哈希函数有以下几种。
MD5
MD5 输出 128 位(16 字节),1992 年设计。最早广泛用于文件完整性校验。2004 年王小云团队找到高效的碰撞攻击方法,MD5 在安全领域基本被判死刑。但很多老旧系统还在用,你翻翻一些遗留项目可能还能看到。
MD5 最大的问题不是碰撞——对密码存储来说,碰撞不是主要威胁。真正的问题是它太快了。现代 CPU 每秒能算几亿次 MD5,GPU 更是能到百亿级别。密码穷举在它面前跟玩似的。
SHA-1
SHA-1 输出 160 位(20 字节),比 MD5 稍慢一点点,但也不安全。2017 年 Google 和 CWI 研究所公布了 SHAttered 攻击——他们找到了两个内容不同但 SHA-1 值完全一样的 PDF 文件。SHA-1 在密码学上已经"破"了,Git 也在逐步迁移到 SHA-256。
SHA-256
SHA-256 输出 256 位(32 字节),是 SHA-2 家族成员。目前没有已知的实际碰撞攻击。它广泛用于 TLS 证书、区块链、数字签名,本身是很安全的哈希函数。
但用 SHA-256 直接哈希密码——仍然是错的。原因在下面。
快哈希不适合密码
MD5、SHA-1、SHA-256 都是"快哈希"。它们的速度就是问题本身。
算一笔账:假设你的密码是 8 位,用了大小写字母加数字(62 个字符),总共有 62^8 种组合。大约 218 万亿。
用 SHA-256,一块 RTX 4090 每秒可以算大约 30 亿次哈希。218 万亿除以 30 亿,约 72700 秒——不到 20 个小时就能穷举完所有 8 位密码。如果多块 GPU 并行,时间更短。
实际情况比这更残酷。攻击者不会傻傻地从头算到尾。他们会先用"常见密码字典"试。RockYou 泄露的数据表明,"123456"、"password"、"qwerty" 等前 1000 个密码覆盖了大约 20% 的用户。用常见密码字典加快哈希,破解率高得吓人。
这就是为什么密码哈希函数必须"慢"。
我之前跟一个读者聊过,他用 SHA-256 直接哈希密码,我说这不安全,他反问:"SHA-256 不是很安全吗?"它安全没错,但用错了地方。SHA-256 设计给数字签名用的,不是给密码存储用的。好比用跑车拉货——跑车是好车,但不是干这个的。
可以用密码强度测试工具检查你的密码够不够强,也可以用SHA 哈希工具体验一下 SHA-256 算得到底有多快。
盐值与彩虹表防御
就算你用了哈希,不加"盐"(salt)仍然不安全。
原因在"彩虹表"(rainbow table)。彩虹表是一种针对哈希密码的预计算攻击方法。攻击者事先算好常见密码的哈希值,存成一张巨大的查找表。拿到数据库的哈希值后,直接查表就知道明文密码,根本不用算。
这里有个关键问题:如果两个用户密码都是"123456",不加盐时哈希值一模一样。攻击者破解一次就能认出来两个人。
盐值就是解决这个问题的。每个用户注册时系统生成一个随机字符串(盐值),把盐值和密码拼在一起再哈希:
hash = bcrypt(password + salt)
加了盐之后:
- 即使密码相同,哈希值也完全不同(盐值不同)
- 攻击者无法预计算彩虹表——他不知道盐值是什么
- 攻击者必须为每个用户单独算一张表,成本暴增
盐值不需要保密。它和哈希值一起存在数据库里。攻击者看到盐值也没用——他的问题是要算的哈希太多,盐值不会减少他的计算量。每个盐值代表一个全新的哈希世界,他必须重新计算。
下面的图展示了彩虹表怎么攻击无盐值存储,以及加盐后为什么失效。
关于盐值有几点要注意:
- 盐值必须足够长。至少 16 字节(128 位),推荐 22 字节以上。太短的话盐值空间太小,仍然可能被预计算
- 每个用户一个唯一盐值。不要全局共用一个盐值——那就是换了个名字的 pepper,不是 salt
- 盐值用密码学安全的随机数生成器。别用
Math.random()或rand()
胡椒与额外保护
有些系统会在加盐之外再加一层"胡椒"(pepper)。胡椒和盐值的区别:
- 盐值:每个用户不同,存在数据库里
- 胡椒:所有用户共享,不存在数据库里(存在配置文件、环境变量或硬件安全模块中)
如果攻击者只拿到了数据库,没拿到配置文件,那他有盐值也没用——还需要胡椒。
但胡椒有个实际的问题:如果胡椒轮换或丢失,所有密码都会失效。有些系统在胡椒轮换时会保留旧胡椒一段时间,逐步迁移。但在中小型项目里,加盐已经够安全。胡椒是"锦上添花",不是必需品。
慢哈希函数
前面说了快哈希不适合密码。那专门为密码哈希设计的慢哈希函数长什么样?
bcrypt
bcrypt 1999 年发布,是目前最广泛使用的密码哈希函数。关键设计:
- 内置盐值:自动生成并拼在哈希结果里
- 可调节工作因子(cost factor):决定计算时间
- 基于 Blowfish 密码算法:反复执行密钥扩展,确保时间开销
bcrypt 的输出格式长这样:
$2b$10$abcdefghijklmnopqrstuvabcdefghijklmnopqrstuvwx
\ \ \ \ \
算法 版本 工作因子 22 字符盐值 + 31 字符哈希值
工作因子每加 1,计算时间翻倍。cost=10 大约 100ms,cost=12 大约 400ms。具体时间取决于硬件,但趋势是这样的。
bcrypt 有个限制:输入密码最多 72 字节。超过的部分会被截断。大多数情况下密码不会这么长,但如果用户用了超长密码或 passphrase,需要注意。
scrypt
scrypt 2009 年发布,比 bcrypt 多了一个重要特性:内存密集(memory-hard)。它需要大量内存来算哈希,这让 GPU 和 ASIC 攻击成本大幅提高——GPU 虽然算力强,但显存有限。
scrypt 的参数包括 CPU 成本、内存成本和并行度。合理配置下,scrypt 对硬件攻击的抵抗力比 bcrypt 强。
不过 scrypt 的参数配置比 bcrypt 复杂,配不好可能反而更弱。这也是它没有完全取代 bcrypt 的原因之一。
Argon2
Argon2 是 2015 年密码哈希竞赛(Password Hashing Competition)的冠军,目前被认为是最先进的密码哈希函数。三种变体:
- Argon2d:抗 GPU 攻击最强,适合加密货币
- Argon2i:抗侧信道攻击,适合密码哈希
- Argon2id:两者结合,推荐用于密码存储(默认选项)
Argon2 的参数比 bcrypt 和 scrypt 更灵活,可以独立调节 CPU 时间、内存用量和并行度。
关于这三种慢哈希函数的选择,对比表会更直观。
总结一下选择思路:
- 如果系统已经在用 bcrypt,不需要换——它仍然足够安全
- 新项目优先考虑 Argon2id
- 如果库不支持 Argon2,scrypt 是好备选
- 不管选哪个,确保工作因子配置合理(下面会说)
整个密码哈希的注册和验证流程,可以参考下面的流程图。
额外提醒:定时攻击
即使哈希函数选对了,实现上还有坑。比较两个哈希值时,不要用普通的字符串比较:
# 危险:会泄漏信息
if user_input_hash == stored_hash:
return True
普通字符串比较是"短路的"——发现第一个不同字符就立刻返回。攻击者可以通过响应时间推断命中了几个字符,逐步缩小猜测范围。这叫"定时攻击"(timing attack)。
正确做法是恒定时间比较:
def constant_time_compare(a, b):
if len(a) != len(b):
return False
result = 0
for x, y in zip(a, b):
result |= x ^ y
return result == 0
好消息是 bcrypt 和 Argon2 的标准库通常已经内置了恒定时间比较。只要用官方 API,不太容易踩这个坑。
工作因子怎么调
工作因子决定了哈希函数的计算强度。设多少合适?
几条原则:
- 目标单次哈希时间:250-500ms。太短不安全,太长影响用户体验
- 考虑最弱设备:用户可能在手机上登录,浏览器端计算也很慢
- 随时间调整:硬件每年都在变快。摩尔定律下,每隔一两年就应该增加工作因子
- 不要设太高:多个用户同时登录时,工作因子太高会导致服务器 CPU 飙升
bcrypt 的 cost 从 10 开始试:10 大约 100ms,12 大约 400ms,14 大约 1.6 秒。经验推荐 10-12。
有一个实用的做法:在用户登录时检测哈希的工作因子,如果低于当前标准,就重新哈希并更新数据库。这样可以在不打扰用户的情况下逐步升级。
哈希存储格式
不同哈希函数的输出格式和长度不同。数据库字段设计时要考虑:
| 算法 | 哈希输出 | 存储格式 | 推荐字段长度 |
|---|---|---|---|
| bcrypt | 192 bit | $2b$10$... | 60 字符 |
| scrypt | 可变 | 自定义格式 | 128 字符 |
| Argon2 | 可变 | $argon2id$v=19$... | 128 字符 |
字段类型建议用 VARCHAR 而不是 CHAR——不同算法的哈希长度不同,未来升级时灵活一些。
还有一点:bcrypt 的哈希结果中包含了盐值和工作因子,不需要额外字段存储。Argon2 和 scrypt 也一样——哈希字符串自带所有参数。
所以数据库里密码字段只需要一列,不需要单独建盐值字段。当然,如果系统需要对盐值做特殊处理(比如用自定义 pepper),可以分开存。
遗留哈希迁移策略
接手老项目,数据库里全是 MD5 甚至明文密码,怎么办?
不能直接升级——你不知道用户密码是什么,也不能把旧哈希还原成明文。
正确做法是渐进式迁移,也叫"双哈希"策略:
- 用户登录时,检测到密码仍使用旧哈希
- 用旧算法验证密码
- 验证通过后,用新算法重新哈希
- 更新数据库中的哈希值
- 用户完全无感知
伪代码:
def verify_and_upgrade(password, stored_hash):
if is_legacy_format(stored_hash):
if legacy_verify(password, stored_hash):
new_hash = bcrypt.hashpw(password, bcrypt.gensalt())
update_user_hash(user_id, new_hash)
return True
return False
else:
return bcrypt.checkpw(password, stored_hash)
这个策略需要一定时间。活跃用户登录时自动升级,但长期不登录的"休眠用户"可能永远留在旧格式。对这些用户,可以在密码过期或重置时触发生成新哈希。
迁移期间需要注意:
- 不要在代码里长期保留两种验证逻辑。迁移完成后尽快移除旧代码
- 监控迁移进度,确认大部分活跃用户已完成升级
- 旧哈希和新哈希不要共用一个字段——建议新建字段,迁移完成后切换