宝塔拨测博客拨测技术博客
宝塔拨测

bcrypt 为什么适合存密码?$2b$10$ 的结构、cost 轮数与 72 字节上限

bcrypt 能用来存密码,靠的不是「不可逆」——MD5 也不可逆。它靠的是 MD5 没有的两样东西:自带随机盐,让预先算好的彩虹表失效;cost 参数,让每一次计算都要花一笔真实的时间,而且这笔开销你可以调。

打开 bcrypt 加密工具,输入 hello,轮数留默认的 10,结果是 60 个字符:

$2b$10$RFt4zyjwVVAOPb7I9Q./uuGp6.KjY2De4GYDxjxK4QWEzp7gcFOo2

这串字符不是随机噪声,是四段拼起来的:算法标识、成本、盐、摘要。看懂这四段,cost 怎么设、为什么同一个密码两次结果不同、为什么超长密码后半段无效,都能自己推出来。

输入 hello、轮数 10 时,工具给出的哈希以 $2b$10$ 开头,共 60 个字符

60 个字符分成四段:成本和盐都写在结果里

段位 内容 实测值 长度
第 1 段 版本标识,bcrypt 的格式版本 $2b$ 4
第 2 段 cost 轮数,实际迭代 2 的 cost 次方 10 2
第 3 段 随机盐,16 字节用 bcrypt 自己的 Base64 变体编码 RFt4zyjwVVAOPb7I9Q./uu 22
第 4 段 摘要 Gp6.KjY2De4GYDxjxK4QWEzp7gcFOo2 31

四段合计 59 个字符(4 + 2 + 22 + 31),再加上 10 和盐之间的那一个 $,整串正好 60 个字符、一共含 3 个 $。第一段的 $2b$ 是当前官方的 bcrypt 版本标识;第二段的 10 表示 1024 轮密钥扩展。

这段结构来自 1999 年 Provos 和 Mazières 的论文《A Future-Adaptable Password Scheme》:bcrypt 用 128 位盐,把成本、盐和加密结果一起输出成字符串。论文里给密码函数定的三条标准是——抗第二原像、盐空间大到无法预计算、成本可调。这三点正是它和 MD5 的分水岭。

实际意义很直接:盐不需要你另外存。用 MD5 加盐时,你得在数据库里加一列放盐;bcrypt 把盐塞进了这 60 个字符,取出来就能用。成本也一样,校验时不用问「当初设了多少轮」,字符串自己带着。

同一密码两次哈希不同,但两次都能验过

实测连续两次对 hello 做 10 轮 bcrypt,结果完全不同:

运行 盐(22 字符) 摘要(31 字符)
第 1 次 RFt4zyjwVVAOPb7I9Q./uu Gp6.KjY2De4GYDxjxK4QWEzp7gcFOo2
第 2 次 Mt87tPCp8.CEJ7UDSEFqCu mIbibU81vtQ9UFJFKI.tcvKVmkbOiBm

两串哈希拿去比对,都返回「匹配」;把待比对字符串换成大写的 Hello,立刻变成「不匹配」。

所以校验逻辑不是「比较两个哈希字符串是否相等」,而是四步:

  1. 从库里取出那 60 个字符;
  2. 取前 29 个字符($2b$10$ 加 22 字符盐),这里已经包含了成本和盐;
  3. 用这段前缀,把用户这次输入的明文重新算一遍;
  4. 比较新结果的后 31 个字符与库里的是否一致。

工具内的比对函数就是这么做的:它先检查传入的哈希长度必须是 60,不满足直接返回 false,然后把前 29 位当作盐和成本重新计算,再比后 31 位。实测三种「格式不对」的输入,全都只得到「不匹配」,不报错:

传入的哈希 比对结果
hello 的哈希截掉最后 1 位(59 字符) 不匹配
32 字符的 MD5 值 不匹配
60 个字符的随机串 xxxxxxxxxxxx… 不匹配

这就是排查登录失败时最容易走偏的地方:59 位截断、把 MD5 值塞进 bcrypt 校验、前缀写错,表现全都一样——不匹配。先数位数,再查前缀。

hello 与它自己的哈希比对,结果为匹配;换成大写 Hello 则是不匹配

cost 每加 1,耗时翻倍:1.7ms 到 1322ms

cost 不是「轮数」的字面意思,而是指数。passlib 文档写得很明确:实际迭代次数是 2 的 cost 次方,cost 加 1 耗时翻倍。本机 Chromium 里同一台机器实测(每档 3 次):

cost 三次耗时(ms) 中位数(ms) 大约是上一档
4 1.7 / 1.7 / 3.0 1.7 —
6 7.9 / 5.5 / 6.7 6.7 3.9×
8 21.9 / 30.7 / 21.8 21.9 3.3×
9 41.7 / 43.8 / 42.2 42.2 1.9×
10 82.3 / 82.3 / 84.0 82.3 2.0×
11 164.9 / 168.1 / 162.2 164.9 2.0×
12 324.0 / 331.3 / 323.7 324.0 2.0×
13 667.9 / 644.0 / 660.6 660.6 2.0×
14 1347.9 / 1295.1 / 1322.1 1322.1 2.0×

从 10 轮往上,每加一档都稳定翻倍;表里 6 到 8 跨了两档,所以是 3.3 倍而不是 2 倍。这条曲线决定了怎么选:OWASP 给的下限是 10;更实用的标定方法是拿你线上最慢的那台机器测一次,让单次校验落在 100 毫秒上下。本机 10 轮 82 毫秒、12 轮 324 毫秒,对登录接口来说 12 轮已经开始影响响应时间了。手机 CPU 比服务器慢好几倍,标定时要按最慢的设备算。

反过来说,这个「慢」正是防护本身:攻击者拿到库以后是离线爆破,每猜一个密码都要付同样的 82 毫秒,成本被直接放大几万倍。

轮数填 0 会得到 10 轮,填 1 只会得到 4 轮

输入框写着「范围 0-100」,但真正生效的范围不是这个。实测:

输入框里的值 结果里的成本段 实际轮数
0 $2b$10$ 10
1 $2b$04$ 4
3 $2b$04$ 4
10 $2b$10$ 10

原因在页面加载的 bcryptjs 实现里:生成盐时先执行 rounds = rounds || 10,所以 0 被当成「没填」,回落到默认 10;接着 rounds < 4 ? 4 : ... 把小值抬到 4。同一段代码里还有上限 rounds > 31 → 31,意思是填 100 也会按 31 轮算——那是 2^31 轮,在浏览器里会长时间无响应(这一条是按源码判断,没有实测,因为真的执行下去页面会卡死,不建议试)。

结论:输入框的 0–100 只是控件范围,bcrypt 有效成本是 4–31。别以为填 100 更安全,它只会让你的登录接口卡住。

轮数填 0,结果里的成本段仍然是 $2b$10$,即 10 轮

72 字节上限:第 73 个字节开始,密码等于没设

这是 bcrypt 最容易被忽略的一条硬限制:它只把密码的前 72 个字节送进算法。passlib 文档写明默认会静默截断超过 72 字节的密码;OWASP 的密码存储速查表也直接建议把用户密码长度上限设为 72 字节,因为「大多数实现」的上限就是 72 字节。

先看 ASCII 的实测。以 pass: 加 67 个 a(共 72 字节)为哈希对象:

拿去比对的字符串 字节数 结果
pass: + 61 个 a 66 不匹配
另一组 72 字节,末位换成 Y 72 不匹配
原字符串本身 72 匹配
原字符串末尾再加一个 X 73 匹配
72 个 a + 28 个 b 100 匹配(另一次运行)

73 字节和 100 字节都能通过 72 字节哈希的校验,说明第 73 个字节起根本没有参与运算。

中文会更早触到上限:UTF-8 下一个汉字占 3 个字节,24 个汉字正好 72 字节。实测以「宝塔面板」加 20 个「中」(24 个汉字,72 字节)为哈希对象:

  • 23 个汉字(少一个中)→ 不匹配;
  • 另一组 24 个汉字、末位换成「文」→ 不匹配;
  • 25 个汉字,末尾多一个「尾」→ 匹配,第 25 个汉字整字被丢弃。

25 个汉字的密码(末尾多一个「尾」)与 24 个汉字的哈希比对,仍然显示匹配

实际影响有两层。一层是「用户以为设了长密码,其实只有前 72 字节有效」;另一层是「等价密码变多」——只知道前 72 字节的人也能登录。处理办法有两个方向:

  • 在应用层直接把密码长度上限卡在 72 字节(OWASP 推荐的做法);
  • 先 pre-hash 再交给 bcrypt。OWASP 提醒这条路有坑:哈希输出里可能出现 NUL 字节,而 bcrypt 原本按 NUL 结尾处理字符串,遇到 0 字节就截断;同时要防 password shucking。它给出的做法是 bcrypt(base64(hmac-sha384(密码, pepper))),即先做 Base64 编码、再用带密钥的 HMAC。别随手写成 bcrypt(sha256(密码))。

为什么不用 MD5:把「一次计算」变贵

本机实测(同一台机器,不同运行时,只看量级):

算法 输入 速度
MD5(Node 单线程) 16 字节 50 万次 966ms,约 51.7 万次/秒
bcrypt cost 10(Chromium) hello 82.3ms 一次,约 12 次/秒

两者差了大约 4.3 万倍。这个差距在正常登录时完全感觉不到,但在拖库之后的离线爆破里,就是「几小时」和「几百年」的区别。而且 bcrypt 还留了一个旋钮:硬件变快了,把 cost 加 1,成本立刻翻倍;MD5 没有这个旋钮,只能整个换掉。

bcrypt 也不是终点。OWASP 现在的排序是:首选 Argon2id(最低 19 MiB 内存、2 次迭代、1 并行度),其次 scrypt,bcrypt 归为「只在老系统、拿不到前两者时用」。但即便是 legacy 定位,bcrypt 也远好过 MD5 或裸 SHA-256。

老系统迁移不用停机:加一个新字段存 bcrypt 或 Argon2id 的结果,登录时先用旧算法校验,通过的那一刻立刻用明文重新算一份写进新字段,存量数据会随用户登录逐步迁完。新用户注册时的密码,可以用 密码生成器 生成足够随机的初始密码。

用这个工具时的三个注意点

第一,生产密码不要贴进任何在线页面。 页面写明「基于 bcryptjs 在浏览器本地生成 bcrypt 哈希,可自定义 salt 轮数(0-100)……所有计算在浏览器本地完成,不会上传到服务器」,但这个结论只能从代码侧验证。真实用户密码应该在服务端用库生成:PHP 用 password_hash(),Node 用 bcrypt,Python 用 bcrypt。

第二,注意前缀和字段长度。 工具输出 $2b$,PHP 的 password_hash() 输出 $2y$(PHP 手册写明),算法相同、标识不同,对接前确认对方校验函数认哪个。数据库字段别只留 60 个字符,PHP 手册建议留 255,因为默认算法会随版本升级变化。

第三,比对区只给结论,不给原因。 长度不是 60、前缀不对、密码确实输错,全都显示「不匹配」。用它排查时,先自己数一遍哈希位数、确认没有从 Excel 或日志里带进空格。

一句话结论

bcrypt 适合存密码,是因为它把「每次验证都要付一笔钱」写进了算法参数,并且把盐和成本都塞进那 60 个字符里;代价是 72 字节上限,以及要随硬件发展不断上调的 cost。存密码用它或者 Argon2id,别再用 MD5。

本文数字来自 2026-11-11 11:41 与 11:43 的浏览器实测,截图与对应数字同源。

常见问题

bcrypt 能解密回原密码吗?

不能,bcrypt 是单向哈希。校验时不是「解密后对比」,而是从库里那 60 个字符里取出成本(第 5–6 位那两个数字)和 22 字符盐,把用户这次输入的明文重新算一遍,再比较后 31 个字符是否一致。所以库泄露了也拿不到明文,只能一个个猜。

同一个密码生成两次 bcrypt 哈希,为什么结果不一样?

因为每次都会生成一段新的随机盐,盐不同结果就不同。实测 hello 在 10 轮下连续两次得到 $2b$10$RFt4zyjwVVAOPb7I9Q./uuGp6.KjY2De4GYDxjxK4QWEzp7gcFOo2 和 $2b$10$Mt87tPCp8.CEJ7UDSEFqCumIbibU81vtQ9UFJFKI.tcvKVmkbOiBm,盐和摘要都不一样,但两个都能和 hello 比对成功。这也是不能用「哈希字符串相等」来判断密码对不对的原因。

bcrypt 的 cost 设多少合适?

OWASP 给的下限是 10。实际标定方法是:在你最慢的那台服务器上测一次校验耗时,让它落在 100 毫秒上下。本机浏览器实测 cost 10 是 82 毫秒、12 是 324 毫秒、14 是 1322 毫秒,cost 每加 1 耗时翻倍,所以不要为了「更安全」随手往上调,14 轮会让登录接口明显变慢。

为什么长度超过 72 字节的密码,后面的字符等于没设?

bcrypt 只把密码的前 72 个字节送进算法,passlib 文档写明默认会静默截断,OWASP 也建议把密码上限设为 72 字节。实测:先哈希 72 字节的 pass:+67 个 a,再用 72 字节加一个 X(共 73 字节)去比对,结果是「匹配」——第 73 个字节根本没参与。中文按 UTF-8 一个字 3 字节,24 个汉字正好 72 字节,第 25 个汉字整字被丢弃。

$2a$、$2b$、$2y$ 三种前缀有什么区别?

都是 bcrypt,只是版本标识不同:$2b$ 是当前官方版本,工具输出的就是它;$2a$ 是较早的版本,个别老实现只认这个前缀;$2y$ 是 crypt_blowfish 实现的写法,除名字外与 2b 一致(passlib 文档的说明)。PHP 的 password_hash() 生成的是 $2y$,对接时要确认对方的校验函数认哪个前缀。

新项目该选 bcrypt 还是 Argon2id?

OWASP 现在的首选是 Argon2id(最低 19 MiB 内存、2 次迭代、1 并行度),其次 scrypt,bcrypt 被归为「只在老系统、装不了 Argon2 和 scrypt 时使用」。但 bcrypt 仍然远好过 MD5 和裸 SHA-256:它有盐、有可调成本,是成熟可用的选择。真正要避免的是拿 MD5 或裸 SHA 存密码。

数据库里存 bcrypt 哈希,字段要留多长?

bcrypt 结果固定 60 个字符,但别只留 60。PHP 手册建议 password_hash() 的结果列留到 255 字节,因为默认算法会随版本升级更换、输出长度可能变长。留 60 会导致将来换到 Argon2id 时截断,登录全部失败。

bcrypt密码存储cost 轮数加盐哈希72 字节上限宝塔拨测