在讨论如何安全存储密码时,经常会遇到三个概念:盐(salt)、慢哈希(slow hash) 与 恒定时间比较(constant-time comparison)。本文以“数据库泄露后攻击者能做什么”为线索,依次看看这三者各自解决什么问题:先用盐消除批量预计算的优势,再用慢哈希提高单次破解的成本,最后用恒定时间比较堵住在线验证环节的旁路。这些概念同样适用于令牌验证、密钥派生等场景,这里以密码存储为主线。
1. 背景:哈希函数与密码存储面临的攻击
1.1 密码学哈希在做什么
密码学哈希函数(如 SHA-256、BLAKE3)把任意长度的输入映射成固定长度的摘要,并满足三个基本性质:
- 单向性:由摘要反推原始输入在计算上不可行。
- 抗碰撞:很难找到两个不同输入产生相同摘要。
- 雪崩效应:输入哪怕只改一位,输出也面目全非。
这些特性让哈希非常适合校验文件完整性、构造 Merkle 树(一种把大量数据逐层两两哈希、最后归并成一个根摘要的树形结构)、生成数据指纹等场景。并非所有带“哈希”之名的算法都仍满足这些性质:MD5 与 SHA-1 的抗碰撞性已被攻破,不再适合安全场景。
1.2 密码存储:为什么选择哈希
密码保存常见有三种方式,它们在泄露后的后果各不相同:
- 明文存储:数据库里直接存
password。一旦泄露,攻击者立刻获得真实密码;由于用户常在多个网站复用密码,泄露的密码还会波及该用户在其他网站的账户。 - 加密存储:数据库里存
encrypt(password)。只要解密密钥泄露,所有密码都可逆;而且拥有密钥的服务方内部人员也能看到明文。 - 哈希存储:数据库里存
hash(password)。哈希是单向的,泄露后攻击者只能尝试暴力破解,而无法直接还原。
三者对比之下,密码更适合用哈希存储,而不是加密或明文保存。接下来的问题是:哈希本身还不够,盐、慢哈希与恒定时间比较各自能补上什么缺口。
1.3 在线攻击、离线攻击与时序旁路
三类攻击场景对应三层不同的防御:
- 在线攻击:攻击者通过正常登录接口不断尝试密码。防御重心在业务层,如速率限制、账户锁定、CAPTCHA、强制使用强密码、多因素认证(MFA)等。
- 离线攻击:攻击者已经拿到数据库,在本地用硬件暴力破解。这是盐 + 慢哈希要解决的问题。
- 时序旁路攻击:攻击者不直接暴力猜密码,而是通过测量系统响应时间来推断正确值,对应的是恒定时间比较这层防御。
盐与慢哈希主要解决离线攻击;恒定时间比较主要加强在线验证环节的安全性。
1.4 为什么单轮快哈希仍然不够安全
假设网站把 SHA256(password) 存进数据库。一旦泄露,攻击者拿到一列哈希值,但这并不意味着“无法利用”:
- 因为 SHA-256 算得极快,现代 GPU 或专用芯片每秒可以尝试数十亿个候选密码。
- 因为相同密码必然得到相同哈希,攻击者可以一次性预计算海量常见密码的哈希,然后批量反查。
所以,安全存储密码的目标不是“让哈希不可逆”——这本来就由哈希函数保证——而是让攻击者即使拿到数据库,破解任意一个账户的成本也足够高。盐、慢哈希与恒定时间比较正是围绕这个目标的三层防御。
2. 盐:让相同的密码不再相同
2.1 什么是盐
盐是一段随机生成的、与密码一起输入哈希函数的额外数据。它不需要保密,通常与哈希结果一起存在数据库里。验证登录时,系统取出该用户的盐,用同样的算法重新计算,再与库存值比对。
用伪代码表示:
| |
其中重要的是:每个用户的盐应当不同。即使两个用户都用了 123456 作为密码,因为盐不同,数据库里出现的哈希值也完全不同。
2.2 为什么需要盐:从预计算到彩虹表
如果没有盐,相同密码会产生相同哈希。攻击者拿到泄露的数据库后,可以发动两类攻击:
- 预计算攻击:提前把常见密码(如 Top 100 万弱口令)全部算一遍哈希,做成“密码 → 哈希”对照表。泄露发生后直接查表,就能快速反查出大量账户。
- 彩虹表攻击:如果直接把全部常见密码的哈希存成一张表,空间开销会非常大。彩虹表是一种用时间换空间的折中方案。
彩虹表是怎么工作的
直接保存“密码 → 哈希”对照表的问题在于空间:想覆盖 1000 万个密码,就要存 1000 万条记录。彩虹表的思路是把哈希计算组织成“链”,只保存链的两头。
建表:从一个起点密码出发,交替执行两种操作:
- 哈希:把密码
P算成哈希值H(P); - 归约:用归约函数(reduction function) 把哈希值映射回密码空间,得到另一个候选密码。归约函数不是哈希的逆运算,它只是按确定的规则把哈希值换算成密码空间中的一员,例如把哈希值当作大整数,对空间大小取模后再换算成字符组合。实际建表时还可以把密码空间划分成若干子段,让每张表的归约结果只落在自己负责的子段内。
交替执行下去就形成一条链:
| |
这条链是完全确定的:哈希与归约都是确定性函数,起点 P0、归约函数与链长一经确定,链上每个中间值就随之固定,仅凭起点即可把整条链重新推演出来。因此表中只需保存链的起点 P0 与终点 H(Pn):终点充当查找时的“锚点”,起点留着供命中后重算。一条走过 1000 个密码的链,在表里只占 2 条记录;用大量这样的链覆盖密码空间,就得到一张彩虹表。
查找:关键是确定目标哈希值 h 在链上的位置,但位置事先未知,只能逐个假设来试:假设 h 在链的倒数第 1 个位置,就对 h 做一次归约,看结果是否等于某条链的终点;不等,就假设它在倒数第 2 个位置,从 h 出发多走一段(归约、哈希、再归约),再比对终点;依此类推。一旦命中某条链的终点,说明 h 可能在这条链上,于是从该链起点重新计算整条链,找到 h 出现的位置,它前面的那个密码就是答案。终点命中也可能是链间重合造成的空命中:重算后没找到 h,就继续尝试其余假设。
实际彩虹表还会为链上每个位置使用不同的归约函数 R1、R2、…、Rn,“彩虹”即由此得名,这样可以减少链与链之间的重合。这不影响上述查找:每条链的终点都固定在第 n 个位置,因此“假设 h 在倒数第 k 个位置”就唯一确定了从哪个归约函数开始、依次使用哪几个;从起点重算时位置从头数起,函数选择同样确定。
彩虹表的本质可以从两个角度看:一是空间换时间——建表时投入大量离线计算存成表,换取破解时的快速反查;二是时间换空间——链上只存起点与终点,查找时靠逐步推演补回中间值,换取存储的压缩。链越长,省下的空间越多,推演所需的步数也越多。彩虹表对无盐哈希非常有效:一张表可以覆盖整个数据库。
盐的作用,对比两条链就能看清:
| |
无盐链的轨迹完全由密码空间决定,而密码空间(P0 … Pn)是攻击者可以预先圈定的,因此链可以提前离线算好、存成彩虹表反复使用,一张表覆盖所有用户。
加盐之后,轨迹还取决于盐。每个用户的盐都是注册时随机生成的,泄露之前攻击者一个都无法预知,自然无法提前离线建表。
即使泄露后拿到了全部盐,也只是确定了盐空间:哪个密码配哪个盐正是攻击者要破解的问题,事先无从知晓,覆盖范围无法收窄到实际出现的组合,只能按盐分别建表——为每个用户各建一张。此时链反解的是用户 i 的哈希 H(P + S_i),一个确定的函数,因此整条链每一步都用同一个 S_i 计算。建一张表的成本与直接暴力破解该用户相当,对其他用户又毫无用处,预计算优势由此被消除。
从需要覆盖的空间来看,盐把攻击者的预计算对象从密码空间扩大到了“密码空间 × 盐空间”:盐取 128 bit 时,空间膨胀为原来的 2¹²⁸ 倍,离线预计算在可行性上被直接排除。
2.3 密码哈希的存储格式:MCF 与 PHC String Format
现代密码哈希库通常把算法标识、参数、盐和哈希结果编码成一条字符串,方便直接存入数据库。这类格式主要有两种:MCF(Modular Crypt Format)与 PHC String Format。两种格式的字段分隔符都是 $;字符串里出现的 /、+、. 等只是 Base64 编码的字符,不是分隔符(下文的例子特意选了不含 / 和 . 的结果;实际生成的字符串中常常含有这些字符)。
bcrypt 的 MCF 格式
MCF 源自 Unix crypt(3) 的密码字段格式,以 $ 分隔算法标识、参数、盐与哈希。除 bcrypt 的 $2a$/$2b$ 外,常见的标识还有 $5$(SHA-256-crypt)、$6$(SHA-512-crypt)、$y$(yescrypt)等。bcrypt 自身也有版本演进:$2a$ 是早期版本,$2b$ 修复了旧实现中的边界处理问题,现代库一般生成 $2b$。
| |
$2b$:算法标识与版本。12:cost 因子(work factor),表示迭代次数为2^12 = 4096轮。- 接下来的 22 个字符:Base64 编码的 16 字节盐(bcrypt 使用自定义的 Base64 变体字符集)。
- 最后的 31 个字符:Base64 编码的 24 字节哈希结果。
PHC String Format(Argon2、scrypt 等)
PHC(Password Hashing Competition)格式更通用,结构为:
| |
以 Argon2id 为例:
| |
$argon2id$:算法标识。v=19:版本号。m=65536,t=3,p=4:内存、时间、并行度参数。MbG5aRrwDWQy888L+KXuWg:Base64 编码的盐。- 最后一段:Base64 编码的哈希输出。
这种“自描述”格式的好处是:验证时只需要这一条字符串,就能完整还原出当初使用的算法、参数和盐,无需在数据库的其他字段中另外记录这些信息。
2.4 盐该怎么用
- 每个用户、每次改密码都使用新的随机盐。盐的长度至少 128 bit(16 字节),应使用密码学安全伪随机数生成器(CSPRNG,例如操作系统提供的
/dev/urandom或语言库里的crypto/rand、secrets.token_bytes等)生成。 - 盐不需要保密,但要保证唯一性与不可预测性。把它和哈希值一起存即可。
- 不建议自行设计哈希拼接格式,例如
sha256(username + password + salt)。专门设计的密码哈希算法已经内置了盐与慢哈希机制,并且能避免编码、长度、截断等细节错误。 - 胡椒(pepper) 是与盐类似但全局统一、不存数据库(或存于 HSM/独立密钥管理服务)的密钥。它可以作为额外防御层,但一旦泄露所有用户都会受影响,且实现复杂度更高,通常不是必须。
3. 慢哈希:降低单次破解速度
盐解决了“批量预计算”问题,但没有解决“暴力破解单个账户”的问题。如果哈希计算很快,攻击者仍然可以逐个尝试海量密码。
3.1 为什么普通哈希太快了
SHA-256 的设计目标之一是快。这对校验文件完整性是优点,对密码存储却是缺点:现代 GPU、FPGA(现场可编程门阵列)和 ASIC(专用集成电路)每秒可以计算数十亿次 SHA-256。如果密码哈希只是 SHA256(password + salt),攻击者仍然可以在离线环境下快速遍历整个字典。
3.2 慢哈希与密钥派生函数
慢哈希(slow hash)属于密钥派生函数(KDF, Key Derivation Function)的一种,专门设计成计算成本高,以抵抗离线暴力破解。它的核心思想是:让合法用户登录时等待几十到几百毫秒,却让攻击者在离线尝试时付出远高于合法用户的计算代价。
KDF 并非都是慢的。另一类快速 KDF(如 HKDF)用于从高熵密钥材料(例如密钥交换的结果、主密钥)派生子密钥,追求计算效率。选择依据是输入的熵:输入本身随机性足够时用快速 KDF;输入是用户密码这类低熵数据时用慢哈希。
常见慢哈希算法包括:
| 算法 | 特点 | 推荐场景 |
|---|---|---|
| bcrypt | 基于 Blowfish,固定 72 字节输入,自带盐与 cost | 密码存储经典选择 |
| scrypt | 内存困难,除时间成本外还消耗大量内存 | 抵抗 ASIC/FPGA 的能力较强 |
| Argon2 | 2015 年密码哈希竞赛冠军,参数可调(时间、内存、并行度) | 当前首选,推荐 Argon2id |
| PBKDF2 | 迭代次数可调,NIST SP 800-132 推荐 | 兼容要求高或合规场景 |
这些算法的共同点是有一个可调的 cost 因子(work factor),例如 bcrypt 的 cost、PBKDF2 的迭代次数、Argon2 的 time_cost 与 memory_cost。随着硬件性能提升,应定期提高这些参数。
PBKDF2 与 bcrypt 的“慢”主要来自密钥拉伸(key stretching):把哈希运算迭代成千上万次,使单次计算成本线性放大。scrypt 与 Argon2 在迭代之外又增加了内存维度,见下节。
Argon2 有三个变体:Argon2i 使用数据无关的内存访问模式,侧重抵抗时序旁路;Argon2d 使用数据相关的内存访问模式,侧重抵抗 GPU 破解;Argon2id 是两者的混合。一般场景推荐 Argon2id,可同时获得两类防护。
3.3 内存困难有什么用
GPU/ASIC 在并行计算 SHA-256 时效率极高,很大程度上是因为 SHA-256 只需要很少的内存。内存困难函数(如 scrypt、Argon2)要求计算过程中占用大量 RAM。大规模并行时,内存成本会急剧上升——攻击者需要为每个并行线程准备一份内存,而芯片上的高速缓存/内存非常昂贵。这就削弱了专用硬件相对普通 CPU 的优势。
3.4 慢哈希的实际配置思路
以 Argon2id 为例(Go 语言 golang.org/x/crypto/argon2 或 Python argon2-cffi):
time(迭代次数):根据可接受的验证延迟设置,通常 1–3 次迭代。memory:尽可能大,常见为 64 MiB,安全敏感场景可达 512 MiB 或更高。threads(并行度):与服务器 CPU 核心数匹配。
目标是让正常用户的登录延迟仍在可接受范围内(例如几百毫秒),却让攻击者的离线破解成本大幅提高。
3.5 成本因子需要随时间升级
硬件性能在持续提升,三年前“足够慢”的参数今天可能已经不再安全。因此,cost 因子应当定期提高。
常见的平滑升级做法是:数据库里记录当前使用的 cost;用户登录时先用旧参数验证;一旦验证通过,立即用新的更高参数重新计算哈希并更新数据库。这样,活跃用户会逐步迁移到新参数,而无需强制所有用户立刻修改密码。
4. 恒定时间比较:堵住在线验证的旁路
盐与慢哈希主要应对“数据库泄露后的离线破解”。攻击者即使无法直接拿到数据库,仍可能通过测量系统响应时间来推断密码——恒定时间比较针对的正是这类问题。
4.1 普通比较有什么问题
很多语言中 == 或 strncmp 在发现第一个不同字节时就会提前返回。这看起来是优化,却泄露了信息:
- 比较时间越长,说明前缀匹配越多。
- 攻击者可以通过测量大量请求的响应时间,逐字节推断出正确值。
这就是时序旁路攻击(timing side-channel attack)。它不需要攻击者能读取内存或数据库,只需要能精确测量响应时间。
时序只是旁路的一种。广义的旁路攻击还包括缓存、功耗、电磁泄露等信息泄露渠道,各自的防护方式不同;就 Web 密码验证而言,时序是最主要的风险来源。
4.2 恒定时间比较怎么做
恒定时间比较函数会:
- 先检查两个字符串长度是否相等,不等则直接返回。这一步确实会泄露“长度是否一致”,但恒定时间比较要保护的是内容而非长度;在比较哈希、MAC、签名这类定长值时,长度由算法决定,本来就是公开信息。
- 按位遍历所有字节,无论是否相同,都做异或累加。
- 最后根据累加结果是否为零返回布尔值。
这样,无论两个字符串在第几位不同,执行路径与耗时都基本一致。伪代码如下:
| |
各语言库对长度差异的处理与这里一致或更细致:Go 的 crypto/subtle.ConstantTimeCompare 在长度不等时立即返回(文档明确写明);Python 的 hmac.compare_digest 尽量抹平长度差异带来的耗时差别,但文档同样注明长度信息理论上可能泄露、值不会泄露。如果长度本身需要保密,常见做法是先把两个值各自做一次哈希(或 HMAC)再比较,让被比较的值总是定长的。
实际工程中应直接使用语言/框架提供的安全比较函数,例如:
- Python:
hmac.compare_digest - Go:
crypto/subtle.ConstantTimeCompare - Rust:
subtle::ConstantTimeEq - Node.js:
crypto.timingSafeEqual
4.3 什么时候用、什么时候没那么重要
- 适合使用的场景:比较 API Key、Session Token、HMAC(基于哈希的消息认证码)、JWT 签名、TOTP(基于时间的一次性密码)等。
- 也建议使用的场景:比较密码哈希结果,作为纵深防御的一环。
- 作用有限的情况:如果攻击者已经拿到了数据库里的哈希值,时序旁路攻击的防护就失去了意义。真正的防线仍然是盐 + 慢哈希。
5. 三者如何协同
一个现代密码验证流程大致是:
- 用户注册/改密码时,生成随机盐。
- 用 Argon2id/bcrypt/scrypt 把密码与盐转换成慢哈希值。
- 数据库中存储包含算法、参数、盐与哈希的完整字符串(如 PHC 格式)。
- 登录时,取出该用户的盐与参数,用同样算法重新计算。
- 用
constant_time_compare比较计算结果与库存值,返回登录是否成功。
这三者各司其职:
- 盐:消除相同密码的哈希一致性,挫败彩虹表与批量预计算。
- 慢哈希:提高单次哈希计算成本,让暴力破解成本过高。
- 恒定时间比较:堵住在线验证环节的时序旁路,避免攻击者通过响应时间逐字节猜测。
6. 几个容易混淆的问题
- 盐需要保密吗? 不需要。它的作用是打乱输入,不是加密密钥。
- sha256(password + salt) 够用吗? 不够。缺少慢哈希与恒定时间比较,且容易在格式、编码、截断上出错。
- 用了 HTTPS 还需要恒定时间比较吗? 需要。HTTPS 防的是传输层窃听,时序攻击仍然可以通过请求响应时间进行。
- 慢哈希会让网站变慢吗? 验证密码本就不是高频操作,几百毫秒的用户可接受延迟换取数据库泄露时的安全收益,是值得的。
- 加盐后密码就一定安全吗? 盐只解决特定问题。弱密码仍可能被字典攻击(按常见口令列表逐个尝试)或针对性破解,只是成本被大幅提高。
- 彩虹表和字典攻击是一回事吗? 不是,区别在存的内容:字典存的是密码本身,攻击时逐个现场算哈希比对;彩虹表存的是提前算好的“哈希 → 密码”对应关系(链条只是它的压缩形式),拿到哈希直接反查。预计算的对应关系会被盐打乱,所以盐能击败彩虹表;现场计算的字典攻击不受盐影响,那是慢哈希要应对的。
- 哈希过的密码可以找回或解密吗? 不能。哈希是单向的,忘记密码时只能重置,无法把原密码发回给用户。
- 网站数据库没泄露,是不是就不用担心? 即使本网站数据库安全,用户也可能在别的网站泄露过相同密码,攻击者会用这些密码来尝试登录你的站点,这叫凭据填充攻击(credential stuffing)。强密码策略、检测异常登录、MFA 是这方面的主要防御。
7. 小结
- 盐:随机、公开、唯一的数据,让相同密码产生不同哈希,抵御彩虹表与批量预计算。
- 恒定时间比较:消除字符串比较的时间差异,防止时序旁路攻击。
- 慢哈希:通过高计算成本与内存困难性,显著提高暴力破解的成本。
在实际工程中,稳妥的做法是不自己实现密码哈希方案:选择经过审计的库与算法(Argon2id、bcrypt、scrypt),正确配置参数,并在比较敏感值时使用语言提供的恒定时间比较函数。
参考链接
- OWASP Password Storage Cheat Sheet 密码存储的工程实践清单
- Password Hashing Competition PHC 官网,Argon2 为 2015 年胜出算法
- RFC 9106: Argon2 Memory-Hard Function Argon2 的规范文档,含参数选择建议
- A Future-Adaptable Password Scheme bcrypt 原始论文(USENIX 1999)
- Stronger Key Derivation via Sequential Memory-Hard Functions scrypt 原始论文
- NIST SP 800-132 PBKDF2 的正式标准
- PHC string format 规范
$id$param$salt$hash编码格式的定义 - Making a Faster Cryptanalytic Time-Memory Trade-Off 彩虹表原始论文(Oechslin, CRYPTO 2003)
- Python hmac.compare_digest 官方文档
- Go crypto/subtle 官方文档