失效的假设 “把密码哈希一下存起来就安全了”
所属组 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 + 撞库检测(因为用户会复用密码)。

思考题

  1. §二 说"攻击者的爆破速度 = 你的哈希速度"。据此解释:为什么"再套一层 sha256"(sha256(sha256(pw)))几乎没用,而 pbkdf2 的 20 万次迭代有用?两者都是"多算几次",差别在哪?
  2. 盐不需要保密,和哈希存一起。那它到底防住了什么、没防住什么?(对照彩虹表和定向爆破。)
  3. §四 的杠杆是"你付一次、攻击者付 N 次"。如果你的登录接口没有限流,攻击者能不能把慢哈希的成本转嫁回你的服务器(在线爆破打满 CPU)?这说明慢哈希要和什么配套?
  4. 为什么 hmac.compare_digest 对"存储的哈希比对"是必要的,但对"比较两个公开的随机 token"可能就没那么关键?时序侧信道在哪种情况下才有价值?
  5. 撞库攻击里,你这边存得再好也会中招。MFA 为什么能兜住它?它兜的是"密码泄露"还是"密码被猜中"——这两者对 MFA 有区别吗?
  6. 参数要"随硬件调高",可老用户的哈希是用旧参数存的。设计一个方案:在不知道用户明文密码的前提下,把存量哈希平滑升级到新参数。
  7. 有人说"我们用了 bcrypt,所以密码存储没问题了"。用 §七 的三条边界,各给一个这句话仍然可能出事的场景。

相关Web 安全机制篇 · 事件分析