| 失效的假设 | “把密码哈希一下存起来就安全了” |
| 所属组 | W2 · 认证与会话 |
| 本篇的前提 | 假设数据库【已经泄露】。密码存储的所有设计,都是在为这一刻做准备 |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、先把前提摆正:假设库已经没了
先接受一个不太舒服的设定:你的用户表已经躺在攻击者的硬盘上了。
不用纠结他是怎么拿到的——注入、内鬼、备份没加密、云桶配错,路径太多了。现在只剩一个问题:那一刻起,用户的密码还能撑多久?
打个比方
保险箱的广告从来不说"小偷打不开",它说的是"耐火 60 分钟、防撬 30 分钟"。
这个说法承认了一件事:只要东西在小偷手上,时间足够长,他就能打开。 保险箱卖的不是"打不开",是时间——足够你发现、报警、赶到。
密码哈希是同一门生意。库泄露之后,攻击者可以在自己的机器上不受限制地猜,没人拦得住他。你能做的只有一件事:让每一次猜测都变贵,贵到他猜完之前你已经强制所有人改完密码了。
⭐ 所以这一篇里所有的技术选择,衡量标准都不是"能不能破",而是"破一个要多久"。
W1 讲的是"别让攻击者进来"。W2 换一个更清醒的前提:假设他已经进来了,你的用户表已经躺在他硬盘上了。 密码存储这件事,唯一的意义就是回答一个问题——
库泄露的那一刻,用户的密码还能撑多久?
如果你存的是明文,答案是"零秒"。如果你存的是哈希,答案取决于你选了哪种哈希、怎么用的。这一篇就是把这个"撑多久"从零秒一路拉长。
注意这和 W1 的视角差别:W1 是防注入、防越权,努力不泄露;W2 承认"总有一天会泄露",转而设计泄露之后的韧性。两个视角都需要。
二、快哈希:弱密码几乎瞬破
第一反应通常是 sha256——它是密码学哈希,不可逆,听起来正合适。看看它在爆破面前撑多久:
import hashlib
# 泄露的用户表里存的是 sha256(密码)。攻击者拿到后离线爆破。
leaked = hashlib.sha256("hunter2".encode()).hexdigest()
# 攻击者的字典(现实里几十亿条,这里意思一下)
dictionary = ["123456", "password", "qwerty", "hunter2", "letmein"]
for i, guess in enumerate(dictionary, 1):
if hashlib.sha256(guess.encode()).hexdigest() == leaked:
print(f"快哈希 sha256:字典里第 {i} 个就命中 -> {guess!r}")
break
print("单机每秒能算上亿次 sha256 => 常见弱密码在离线爆破下几乎瞬破")
快哈希 sha256:字典里第 4 个就命中 -> 'hunter2'
单机每秒能算上亿次 sha256 => 常见弱密码在离线爆破下几乎瞬破
“不可逆"是真的——你没法从哈希直接算回密码。但攻击者根本不需要算回来,他猜:拿一本几十亿条的密码字典,每条算一次哈希,和泄露的哈希比对。
⭐ 而 sha256 的快在这里是致命的缺点:它被设计成算得飞快(单机每秒上亿次),这对校验文件是优点,对存密码是灾难——攻击者的爆破速度,等于你的哈希速度。 你算得越快,他猜得越快。
更糟的是"快"还催生了彩虹表:攻击者可以提前把常见密码的哈希都算好存起来,泄露发生时一次查表就出结果,连现算都省了。
打个比方
快哈希像一把印刷机:你把密码放进去,它瞬间印出一个指纹。问题是攻击者手上也有同一台印刷机,而且他一秒能印一亿次——他不需要"解开"你的指纹,只要把字典里每个词都印一遍,对上号就行。
慢哈希是把这台印刷机换成手工雕版:你每次登录多等几十毫秒,无所谓;他要试几十亿次,每次都得重新雕一遍。
三、加盐:废掉彩虹表
彩虹表的前提是"相同密码 → 相同哈希”,所以能预先算好。打破这个前提,彩虹表就废了——给每个用户配一个随机的"盐",混进去再哈希:
import hashlib, secrets
def hash_pw(pw):
salt = secrets.token_hex(8) # 每个用户一个随机盐
h = hashlib.sha256((salt + pw).encode()).hexdigest()
return salt, h
# 两个用户用了【相同】的密码
s1, h1 = hash_pw("hunter2")
s2, h2 = hash_pw("hunter2")
print("同一个密码,两次存储的 hash 是否相同:", h1 == h2)
print("=> 攻击者预先算好的彩虹表对不上号,必须对每个盐单独爆破")
同一个密码,两次存储的 hash 是否相同: False
=> 攻击者预先算好的彩虹表对不上号,必须对每个盐单独爆破
两个用户用了完全相同的密码 hunter2,存下来的哈希却不一样,因为盐不同。
于是:
彩虹表失效 攻击者没法"预先算好",因为他不知道你的盐
不能批量打 每个用户的盐不同,攻击者必须【对每个盐单独爆破】,
没法"算一次比对全表"
盐不是密钥,不需要保密,和哈希存在一起就行。它的作用不是"藏起来",是"让每个用户的哈希各不相同",把攻击者的一次工作量放大成 N 次。
四、慢哈希:用拖慢自己来拖垮攻击者
加盐挡住了"批量",但没改变"单个密码算得飞快"。真正的杀招是把哈希本身变慢——用专门的密码哈希(pbkdf2、bcrypt、scrypt、argon2),它们的设计目标恰恰是"算得慢、且没法加速":
import hashlib, hmac, time
pw, salt = "hunter2".encode(), b"random-salt-16by"
# 慢哈希的思路:故意让每次验证很贵,把攻击者的离线爆破速度一起拖垮。
def timed(fn):
t0 = time.perf_counter(); fn(); return time.perf_counter() - t0
t_fast = timed(lambda: hashlib.sha256(salt + pw).hexdigest())
t_slow = timed(lambda: hashlib.pbkdf2_hmac("sha256", pw, salt, 200_000))
print("pbkdf2 迭代次数:", 200_000)
print("pbkdf2 是否比 sha256 慢至少 100 倍:", t_slow > t_fast * 100)
print("=> 你验证一次多花几十毫秒无感,攻击者每个猜测都要多付这份成本")
# 比较 hash 必须用时序恒定比较;普通 == 会因'第几位开始不同'而提前返回,泄露信息
stored = hashlib.pbkdf2_hmac("sha256", pw, salt, 200_000)
print("时序安全比较结果:", hmac.compare_digest(stored, stored))
pbkdf2 迭代次数: 200000
pbkdf2 是否比 sha256 慢至少 100 倍: True
=> 你验证一次多花几十毫秒无感,攻击者每个猜测都要多付这份成本
时序安全比较结果: True
思路有点反直觉:故意让验证变慢。
你这边 每次登录多花几十毫秒 —— 用户完全无感
攻击者那边 他要试几十亿次,每一次都被迫多付这份成本 —— 爆破从"几小时"变成"几百年"
⭐ 这就是慢哈希的全部精髓:你只付一次的成本,攻击者要付几十亿次。 迭代次数(或 bcrypt 的 cost、argon2 的内存参数)就是这把杠杆的长度——它该随硬件变强而调高。
收尾:比较也要小心
最后那行 hmac.compare_digest 别忽略。普通的 == 比较字符串时,发现第一个不同的字节就返回,于是"从第几位开始不同"会体现在返回快慢上,理论上能被用来逐位试探。时序恒定比较不管哪位不同都花一样的时间,堵掉这条侧信道。
五、它出现过的地方
密码存储做错的代价,几乎总是在"库泄露之后"才结账,展开在事件分析里:
2012 LinkedIn 约 650 万条无盐 SHA-1,泄露后大量弱密码被快速还原
2013 Adobe 用可逆加密而非哈希,且附带明文密码提示
⚠️ 撞库(credential stuffing)说明:就算你这边存得完美,用户在别处复用了密码,你也会被别人的泄露波及。这引出 W2 后续的 MFA——它是"密码终究会泄露"的兜底。
六、防御
① 用专门的密码哈希函数 argon2(首选)/ scrypt / bcrypt / pbkdf2
绝不用 md5 / sha1 / sha256 裸哈希存密码
② 每用户随机盐 现代库(bcrypt/argon2)自动加盐并写进输出串
③ 参数随硬件调高 迭代次数/cost/内存参数定期上调,跟上算力
④ 时序恒定比较 验证哈希用 compare_digest 一类
⑤ 兜底不在存储层 MFA + 泄露密码检测 + 限流,应对"密码终会外泄"
判断标准一句话:你存密码用的函数,是不是"故意慢"的? 如果它以"快"为卖点(md5/sha 系列),就用错了工具。
⭐ 回到保险箱那个比方:这五条没有一条声称"打不开"。它们全部在做同一件事——把"打开一个"的时间成本抬高,抬到攻击者算完之前,你已经把所有人的密码都改完了。③ 那条"参数随硬件调高"尤其是这个意思:对手的算力每年在涨,所以这个"耐撬时长"必须跟着往上调,否则它在悄悄变短。
七、这些防御的边界
① 慢哈希拖慢的是离线爆破,救不了弱密码到"绝对安全"。 123456 就算用 argon2,也排在攻击者字典最前面。慢哈希是把"弱密码撑几秒"延长到"撑久一点",配合强制密码强度 + MFA 才完整。
② 盐防彩虹表,不防"针对单个高价值账号的定向爆破"。 盐让攻击者不能批量,但他仍能盯着一个账号猛试——这靠慢哈希 + 限流 + MFA。
③ 参数不是一次定死。 今天"够慢"的迭代次数,几年后在新硬件上就不够。要有机制定期上调,并在用户下次登录时透明地重哈希。
关于时效:机制(慢 + 盐 + 时序安全比较)稳定。会变的是推荐算法和参数——argon2 的具体内存/并行参数、pbkdf2 的迭代次数下限,会随算力和指南更新。落地时以当年的权威指南为准,本篇不钉死具体数值。
八、本篇小结
W2 的前提:假设库已经泄露。密码存储 = 设计"泄露之后能撑多久"。
⭐ 快哈希(sha256/md5)是错的:攻击者的爆破速度 = 你的哈希速度
彩虹表更让常见密码"查表即破"
⭐ 加盐:相同密码 -> 不同哈希,废掉彩虹表、逼攻击者对每个盐单独爆破(盐不必保密)
⭐ 慢哈希:故意变慢。你只付一次,攻击者付几十亿次 —— 迭代/cost/内存就是杠杆
收尾:比较哈希用时序恒定比较(compare_digest)
正确工具:argon2 / scrypt / bcrypt / pbkdf2;参数随硬件调高。
兜底在存储层之外:MFA + 撞库检测(因为用户会复用密码)。
思考题
- §二 说"攻击者的爆破速度 = 你的哈希速度"。据此解释:为什么"再套一层 sha256"(sha256(sha256(pw)))几乎没用,而 pbkdf2 的 20 万次迭代有用?两者都是"多算几次",差别在哪?
- 盐不需要保密,和哈希存一起。那它到底防住了什么、没防住什么?(对照彩虹表和定向爆破。)
- §四 的杠杆是"你付一次、攻击者付 N 次"。如果你的登录接口没有限流,攻击者能不能把慢哈希的成本转嫁回你的服务器(在线爆破打满 CPU)?这说明慢哈希要和什么配套?
- 为什么
hmac.compare_digest对"存储的哈希比对"是必要的,但对"比较两个公开的随机 token"可能就没那么关键?时序侧信道在哪种情况下才有价值? - 撞库攻击里,你这边存得再好也会中招。MFA 为什么能兜住它?它兜的是"密码泄露"还是"密码被猜中"——这两者对 MFA 有区别吗?
- 参数要"随硬件调高",可老用户的哈希是用旧参数存的。设计一个方案:在不知道用户明文密码的前提下,把存量哈希平滑升级到新参数。
- 有人说"我们用了 bcrypt,所以密码存储没问题了"。用 §七 的三条边界,各给一个这句话仍然可能出事的场景。