BCrypt 密码哈希:从 Blowfish 到 cache-hard 的技术演化
一、元认知:哈希为什么不能”快” #
存密码这件事,反直觉的地方在于:哈希越快越危险。
MD5 每秒能算十几亿次,SHA-256 每秒几亿次。这些”快”在验证文件完整性时是优点——但在存密码时是致命伤。攻击者拿到泄露的哈希库后,用 GPU 每秒尝试几十亿个密码组合,快哈希让暴力破解变得可行。
密码哈希要的不是”快”,是故意慢——慢到合法用户登录时多等 200 毫秒无所谓,但攻击者暴力破解 10 亿个密码时要花几百年。
这就是 BCrypt 要解决的根本矛盾:如何让哈希速度可调,且只能往慢调,不能被攻击者往快调。
彩虹表的威胁 #
在 BCrypt 之前,密码存储的主要威胁是彩虹表——预先算好的哈希→密码对照表。一个 120GB 的彩虹表能在几秒内破解绝大多数常见密码的 MD5 哈希。
防御彩虹表的标准手段是加盐(salt):每个密码加一段随机字符串再哈希,让预计算表失效。但光加盐不够——如果哈希本身太快(如 MD5+salt),攻击者仍能用 GPU 高速尝试每个盐对应的密码。
BCrypt 的贡献不是”加了盐”(它内置 128-bit 盐),而是让哈希本身变慢,且慢的程度可调。
二、搭积木:BCrypt 怎么工作的 #
从 Blowfish 到 Eksblowfish #
BCrypt 由 Niels Provos 和 David Mazières 在 1999 年 USENIX 会议上提出,论文标题是 “A Future-Adaptable Password Scheme”(一个面向未来可适应的密码方案)。名字里的 “B” 来自它所基于的对称加密算法——Blowfish,由 Bruce Schneier 于 1993 年设计。
Blowfish 是一个分组密码,核心是密钥扩展(key schedule):用密钥初始化 18 个 32-bit 子密钥(P 数组)和 4 个 8×32 的 S 盒。这个过程有一次性的初始化成本。
Provos 和 Mazières 的关键改造是 Eksblowfish(Expensive Key Schedule Blowfish):
1 | |
区别在于:标准 Blowfish 的密钥扩展只跑一遍,Eksblowfish 跑 2^cost 遍。而且第一遍之后的状态被”冻结”——后续的加密过程都用这个昂贵的初始化状态。
这个设计的关键在于:盐也参与密钥扩展,且扩展轮数可调。攻击者无法预计算(因为每个盐不同),也无法加速(因为轮数由防御者设定)。
Cost Factor:自适应的慢 #
BCrypt 的 cost 参数控制 Eksblowfish 的迭代轮数:
1 | |
cost 每加 1,计算时间翻倍。范围是 4-31(但 31 会让单次哈希耗时达到数年,实际没人用)。
这就是 “Future-Adaptable” 的含义:1999 年用 cost=6(约 30ms),2025 年用 cost=12(约 300ms)——硬件变快了,就把 cost 调高,让哈希时间始终维持在”用户无感但攻击者痛苦”的水平。这是 BCrypt 最前瞻的设计。
输出格式 #
一个 bcrypt 哈希长这样:
1 | |
拆解:
| 段 | 含义 |
|---|---|
$2b$ | 算法版本 |
12 | cost factor |
nKj8Vp3T5r2YwE8mF1qK2O | 22 字符的 Base64 编码盐(128 bit) |
5xQ9zL8aB3cD4eF5gH6iJ7kL8mN9oP0 | 31 字符的 Base64 编码哈希(184 bit) |
盐和哈希编码在同一字符串里——不需要单独存盐。这是 bcrypt 的工程友好设计:一个字符串包含验证密码所需的全部信息。
72 字节限制 #
BCrypt 的密码输入上限是 72 字节。原因在 P 数组:18 个 32-bit 子密钥 = 72 字节,密码循环填充这 72 字节后,多余部分被忽略。
这个限制的后果:
- 多数实现静默截断——第 73 字节起的密码被丢弃,用户以为输了个长密码,实际只用了一部分
- 全 4 字节 UTF-8 字符(如 emoji)时,72 字节仅 18 个字符——密码长度感知严重失真
- 2024 年 Okta 因此出过漏洞:用户名+密码拼接后过 bcrypt,当用户名超过 52 字符时,密码部分被完全截断丢弃,任意密码都能登录
不同实现对超长密码的处理不一致:
| 实现 | 行为 |
|---|---|
| OpenBSD 原始实现 | 静默截断 |
Python bcrypt | 抛异常 |
Node.js bcrypt | 静默截断 |
Rails has_secure_password | 显式限制 72 字符 |
实践建议:如果用 bcrypt,要么在应用层强制限制密码长度 ≤72 字节,要么先用 SHA-256 预哈希再过 bcrypt(但预哈希引入新的”脚枪”问题——预哈希泄露后彩虹表攻击可行,需加 HMAC)。
三、案例即原理:两个 bug 和版本史 #
BCrypt 的版本号($2$ → $2a$ → $2b$)不是功能迭代,而是两个独立 bug 的修复记录。这两个 bug 恰好揭示了密码哈希实现中最容易出错的两个维度:字符编码和整数溢出。
Bug 1:$2a$ 的 8-bit 字符问题(2011) #
2011 年 6 月,crypt_blowfish(PHP 用的 bcrypt 实现)被发现有一个 bug:当密码中包含第 8 位为 1 的字符(即字节值 >127 的非 ASCII 字符)时,处理逻辑有误。
具体来说,$2a$ 规范要求密码以 UTF-8 编码并包含 null 终止符,但 crypt_blowfish 的实现对高位字节的处理与规范不一致。这导致同一个密码在不同实现下可能产生不同的哈希——在安全领域,这叫”哈希不一致”,意味着迁移系统时密码可能失效,或者更糟,某些密码的哈希碰巧变弱。
修复方式不是改 $2a$ 规范,而是新增两个版本标记:
$2x$:标记”已知有问题的旧哈希”,用于识别已存储的弱哈希$2y$:标记”修复后的算法”
重要:
$2x$和$2y$是 crypt_blowfish/PHP 专有的。OpenBSD(bcrypt 的原始实现)和 Canonical(Ubuntu/glibc)从未采纳这两个版本标记。”Nobody else, including Canonical and OpenBSD, adopted the idea of 2x/2y.”
Bug 2:$2b$ 的 wrap-around(2014) #
2014 年 2 月,OpenBSD 发现自己的 bcrypt 实现有一个更严重的 bug:密码长度用 unsigned 8-bit 值存储。当密码超过 255 字节时,长度值取 length mod 256——比如 260 字节的密码被当成 4 字节处理。
这意味着一个 260 字节的密码和前 4 字节相同的一个短密码,产生完全相同的哈希。虽然 bcrypt 本身截断到 72 字节,但这个 wrap-around bug 在截断之前就发生了,导致长度校验完全失效。
OpenBSD 修复后将版本改为 $2b$。这是当前推荐使用的版本。
两个 bug 的启示 #
| 维度 | Bug 1($2x/$2y$) | Bug 2($2b$) |
|---|---|---|
| 出错点 | 字符编码处理 | 整数溢出 |
| 影响范围 | crypt_blowfish 专有 | OpenBSD 原始实现 |
| 发现年份 | 2011 | 2014 |
| 修复方式 | 新增 $2y$ 标记 | 新增 $2b$ 版本 |
密码哈希实现是最不容错的代码——一个 off-by-one 或编码错误就可能让安全性归零。这也是为什么 OWASP 强调”不要自己实现密码哈希,用经过审计的库”。
四、衍生:从 CPU-hard 到 memory-hard #
BCrypt 的定位:cache-hard #
BCrypt 的 Eksblowfish 在计算时访问约 4KB 的数据(P 数组 + S 盒),这个大小恰好** fits in CPU L1 cache**。攻击者用 GPU 加速时,GPU 的每个核心没有独立的 L1 cache,4KB 的随机访问在 GPU 上变成全局内存访问,慢一个数量级。
这种特性叫 cache-hard——利用 CPU 缓存层次结构让 GPU/ASIC 不划算。BCrypt 是最早的 cache-hard 方案,虽然 1999 年提出时还没有这个术语。
scrypt(2009):memory-hard 的开端 #
Colin Percival 在 2009 年为 Tarsnap 在线备份服务设计了 scrypt(RFC 7914)。核心思想不同:不只用 CPU 时间,还用大量内存。
scrypt 要求在计算过程中维护一个大的内存缓冲区(默认几 MB 到几十 MB),且访问模式是随机的。这意味着:
- CPU 计算快但内存少的设备(如 ASIC)无法加速——因为没有足够的内存并行计算
- 即使有内存,随机访问模式让内存带宽成为瓶颈
1 | |
scrypt 的攻击者成本从”买更多 GPU”变成了”买更多 GPU + 买更多内存”,成本指数级上升。
Argon2(2015):PHC 冠军 #
2013-2015 年的 Password Hashing Competition(PHC)收到了 24 个提案,最终 Argon2 胜出(Alex Biryukov、Daniel Dinu、Dmitry Khovratovich,卢森堡大学),2015 年公布,2021 年成为 RFC 9106。
Argon2 有三个变体:
| 变体 | 内存访问模式 | 抗侧信道 | 适用场景 |
|---|---|---|---|
| Argon2d | 依赖密码(data-dependent) | 弱 | 加密货币、不怕侧信道的场景 |
| Argon2i | 独立于密码(data-independent) | 强 | 密码哈希、抗侧信道 |
| Argon2id | 混合(前半趟 i,后半趟 d) | 中 | RFC 9106 推荐默认 |
Argon2id 是 OWASP 当前推荐的首选密码哈希算法。相比 scrypt,它增加了并行度参数(p)和可调内存/时间的独立控制,且抗侧信道攻击。
PBKDF2(2000):最老但最兼容 #
PBKDF2(Password-Based Key Derivation Function 2,RFC 2898 / PKCS #5 v2.0)是 2000 年的方案,比 bcrypt 还早一年。它的原理最简单:把密码和盐用 HMAC 重复迭代 N 次。
PBKDF2 的问题在于纯 CPU-hard,没有内存硬度。它可以用 GPU 高效并行加速——一块 RTX 4090 每秒能算几百万次 PBKDF2-SHA256。
但 PBKDF2 有一个 bcrypt/Argon2 没有的优势:FIPS 140-2 合规。美国联邦信息系统要求 FIPS 认证的算法,Argon2 和 bcrypt 不在 FIPS 列表中,PBKDF2 是唯一合规的密码哈希方案。
四代哈希的演化轴 #
1 | |
演化的核心轴是提高攻击者的硬件成本:从纯 CPU 时间(MD5),到 CPU 缓存(bcrypt),到大内存(scrypt),到可调内存+并行+抗侧信道(Argon2)。
五、前沿:cache-hard vs memory-hard 之争 #
OWASP 的推荐与现实的落差 #
OWASP 2024/2025 Password Storage Cheat Sheet 的推荐顺序:
| 优先级 | 算法 | 参数 | 场景 |
|---|---|---|---|
| 1 | Argon2id | 19 MiB, t=2, p=1 | 新项目首选 |
| 2 | scrypt | N=2^17, r=8, p=1 | Argon2id 不可用时 |
| 3 | bcrypt | cost≥10 | 仅遗留系统 |
| 4 | PBKDF2 | SHA-256, ≥600k 迭代 | FIPS 合规场景 |
注意 bcrypt 已被降级为”legacy only”。但现实是:
| 系统 | 默认算法 | 实际推荐 |
|---|---|---|
PHP password_hash() | bcrypt (cost=12) | Argon2 需编译启用,非默认 |
| Ruby on Rails | bcrypt | 显式限制 72 字符 |
| Django | PBKDF2-SHA256 | Argon2 需第三方库 |
| WordPress | PHPass (MD5-based) | 2024 年仍在讨论迁移 bcrypt |
| Linux shadow | SHA-512 / yescrypt | Fedora/Arch 已转 yescrypt |
OWASP 推荐 Argon2id,但现实中大量系统仍用 bcrypt 甚至更弱的算法。迁移有成本——已有密码库要平滑过渡,不能要求所有用户改密码。
2024 前沿:cache-hard 被重新评估 #
2024 年 11 月,密码学家 Soatok 发表《Beyond Bcrypt》,引发了一场重要讨论:PHC 过度强调了 memory-hardness。
论点核心:
- 密码认证场景的运行时间通常 <1 秒。在这个时间预算内,memory-hard 函数(scrypt/Argon2)能用的内存有限(几十 MB),攻击者用高端 GPU 仍能并行多个实例
- cache-hard 函数(如 bcrypt)在 <1 秒时,利用 L1 cache 的速度优势,每秒能做更多次有效计算——但攻击者的 GPU 没有 L1 cache,每秒有效计算反而更少
- bcrypt wiki 甚至声称:运行时间 <1 秒时 bcrypt 比 Argon2 更强(标注 citation needed,但有逻辑支撑)
讽刺的是:bcrypt 是 cache-hard,而 scrypt 和大多数人用的 Argon2 配置不是 cache-hard(它们的内存访问模式是随机的,不利用 cache 层次)。
Pufferfish2 和 bscrypt:cache-hard 的新尝试 #
沿着 cache-hard 方向的新探索:
- Pufferfish2:基于 PHC 决赛入围方案 Pufferfish 改进,只在 CPU 核 L2 缓存内操作(非随机访问 RAM),比 scrypt/Argon2 更难用 GPU/ASIC 实现
- bscrypt:Sc00bz 设计的 cache-hard 方案,论证在短运行时下 cache-hard 优于 memory-hard
- Balloon Hashing:NIST SP 800-63B 点名推荐,可证明 space-hard,数据独立访问(抗侧信道),但不是 cache-hard
这些方案目前都未成为主流标准,但代表了一个重要的方向:密码认证场景可能不需要”大内存”,而是需要”高效利用 CPU 缓存层次”。
量子计算的影响 #
Grover 算法对无结构搜索给出平方加速——2^N 次暴力搜索变成 2^(N/2) 次。对密码哈希的影响:
- 不”破解”哈希——Grover 不攻击哈希函数本身,只加速暴力搜索
- 缓解方法简单:约翻倍 work factor(cost +1 即可,因为 bcrypt 的 cost 是 2 的幂)
- 真正的瓶颈是密码熵——如果用户用 8 位弱密码,不管哈希多慢,搜索空间只有 2^52(考虑字符集),量子计算下 2^26 次搜索就能破
量子计算对密码哈希的威胁远小于对公钥密码(RSA/ECC)的威胁。Shor 算法能分解大整数,直接破解 RSA——但 Shor 不适用于哈希函数。密码哈希的量子安全性主要取决于密码熵,而非哈希算法选择。
六、总结:密码哈希的本质 #
回到最初的问题:BCrypt 是什么?
BCrypt 是第一个”可调速度”的密码哈希方案。 在它之前,哈希速度由算法固定(MD5 永远那么快);在它之后,防御者可以随着硬件升级调高 cost,让哈希始终保持”用户无感但攻击者痛苦”的速度。
它的两个核心设计——Eksblowfish 的可调迭代和内置盐——至今仍是密码哈希的基本范式。scrypt 和 Argon2 改进的是”攻击者的硬件成本”维度(从 CPU 时间到内存),但没有改变这个基本范式。
选型决策 #
| 场景 | 推荐 | 理由 |
|---|---|---|
| 新项目,无合规限制 | Argon2id | OWASP 首选,memory-hard + 抗侧信道 |
| 遗留系统用 bcrypt | 保持 bcrypt,cost≥12 | 迁移成本 > 安全收益,调高 cost 即可 |
| FIPS 合规(美国联邦) | PBKDF2-SHA256, ≥600k 迭代 | 唯一合规选项 |
| 极端安全需求(<1s 预算) | bcrypt cost=14+ 或 Pufferfish2 | cache-hard 在短运行时下可能更优 |
一句话 #
密码哈希的本质不是”加密”,而是让暴力破解的成本高到不可行。bcrypt 用可调的慢做到了这一点,scrypt/Argon2 用内存硬度做到了更多,而 2024 年的 cache-hard 争论提醒我们:在密码认证这个特定场景下,”慢”不只有一种正确的方式。
参考资料 #
- Provos & Mazières, “A Future-Adaptable Password Scheme”, USENIX 1999 — bcrypt 原始论文
- bcrypt - Wikipedia — 版本历史、72 字节限制、算法细节
- OWASP Password Storage Cheat Sheet — Argon2id 首选,bcrypt legacy only
- NIST SP 800-63B §5.1.1.2 — memory-hard SHOULD be used
- Argon2 - Wikipedia — PHC 冠军,RFC 9106,三个变体
- Soatok, “Beyond Bcrypt”, 2024-11-27 — cache-hard vs memory-hard 争论
- RFC 9106 - Argon2 — Argon2 官方规范
- RFC 7914 - scrypt — scrypt 官方规范