加盐 (Salt) 与 PBKDF2:为什么单纯用 SHA-256 存储用户密码是不安全的
仅使用单次 SHA-256 存储用户密码极易遭受彩虹表查表与 GPU 高速暴力破解。防范密码泄露的关键在于使用随机“盐”(Salt) 消除预计算查表,并利用 PBKDF2 等慢哈希算法增加单次计算耗时。本文深入分析加盐 PBKDF2 原理并提供 Web Crypto 实现。
一、问题概述:单次 SHA-256 存储密码的安全缺陷
许多开发者误以为“只要用了 SHA-256 这种不可逆哈希,密码就是安全的”。然而 SHA-256 是为高速吞吐量设计的消息摘要算法,现代 GPU 每秒可计算上百亿次 SHA-256 哈希。如果攻击者拿到数据库中未加盐的 SHA-256 散列值,可以通过彩虹表或暴力破解迅速还原出常见明文密码。
二、最小复现:未经加盐的明文密码被彩虹表匹配
下面的对比展示了单次 SHA-256 与加盐 PBKDF2 在面对预计算攻击时的安全差异:
/* 不安全做法:单次 SHA-256 (无 Salt) */
// SHA256("123456") = "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
// 攻击者直接在公开彩虹表中查询该 Hex 即可秒级还原出明文 "123456"
/* 安全做法:加盐 (Salt) + PBKDF2 迭代 600,000 次 */
// 每位用户随机生成 16 字节 Salt,将单次破解耗时从纳秒级提高到毫秒级,使 GPU 批量破解成本飙升百万倍!三、根因分析:彩虹表查表攻击与 GPU 硬件算力压制
1. 彩虹表 (Rainbow Tables):利用“空间换时间”预先计算亿万常见密码的单次 SHA-256 哈希值。未加盐的密码在不同用户间相同,导致一份彩虹表能批量破译整个数据库。2. GPU 算力碾压:SHA-256 算法逻辑极其简单,定制 ASIC 或 GPU 密集阵列能并行极速计算。3. 慢哈希 (Key Derivation Function):PBKDF2 / Argon2 / bcrypt 通过成千上万次循环迭代(如 OWASP 推荐 PBKDF2-HMAC-SHA256 至少 600,000 次),人为拉长计算耗时。
四、推荐方案:OWASP 密码安全标准与动态工作因子
1. 每用户独立 Salt:为每位用户生成至少 16 字节的密码学随机值。2. 算法选择:优先 Argon2id;不可用时考虑 scrypt;需要 FIPS-140 兼容实现时可使用 PBKDF2-HMAC-SHA256。bcrypt 主要用于遗留系统。3. 动态工作因子:当前 OWASP 对 PBKDF2-HMAC-SHA256 的基线是至少 600,000 次,但仍应在目标服务器上校准并随硬件提升升级。4. 数据库存储版本化参数:至少保存算法、Salt、工作因子与 Hash,便于日后迁移。
五、完整代码:基于 Web Crypto API 的 PBKDF2 密码派生与验证
下面的 TypeScript 代码同时实现创建记录和验证记录。示例 API 也可用于支持 Web Crypto 的服务端运行时;真正的密码存储与校验应放在服务端,并按服务器性能校准迭代次数。
interface PasswordHashResult {
saltHex: string;
hashHex: string;
iterations: number;
}
const DEFAULT_PBKDF2_ITERATIONS = 600_000;
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function hexToBytes(hex: string): Uint8Array {
if (!/^(?:[0-9a-f]{2})+$/i.test(hex)) throw new Error("无效的十六进制数据");
return Uint8Array.from(hex.match(/.{2}/g)!, (pair) => Number.parseInt(pair, 16));
}
async function derivePassword(
password: string,
salt: Uint8Array,
iterations: number,
): Promise<Uint8Array> {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations,
hash: "SHA-256",
},
keyMaterial,
256
);
return new Uint8Array(bits);
}
async function hashPasswordPBKDF2(
password: string,
iterations = DEFAULT_PBKDF2_ITERATIONS,
): Promise<PasswordHashResult> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await derivePassword(password, salt, iterations);
return {
saltHex: bytesToHex(salt),
hashHex: bytesToHex(hash),
iterations,
};
}
async function verifyPasswordPBKDF2(
password: string,
stored: PasswordHashResult,
): Promise<boolean> {
const expected = hexToBytes(stored.hashHex);
const actual = await derivePassword(password, hexToBytes(stored.saltHex), stored.iterations);
if (actual.length !== expected.length) return false;
// 不提前返回,避免把第一个不同字节的位置暴露为明显的时序差异。
let difference = 0;
for (let i = 0; i < actual.length; i++) difference |= actual[i] ^ expected[i];
return difference === 0;
}
const stored = await hashPasswordPBKDF2("MySecurePass123!");
console.log(await verifyPasswordPBKDF2("MySecurePass123!", stored)); // true六、常见错误方案
在前端将密码加盐哈希后发送给后端(依然等同于硬编码明文 Password-as-a-Hash 凭据);全局所有用户使用同一个静态硬编码 Salt;将 PBKDF2 迭代次数设置为 1。
七、边界条件:前端加密 vs 服务端验证边界
密码加盐和 PBKDF2 计算应当在服务端完成。如果在前端完成 PBKDF2 并将 Hash 发送给服务端校验,攻击者只需窃取该 Hash 即可作为凭据直接登录(Pass-the-Hash 攻击)。前端只需使用 HTTPS 传输明文密码。
八、如何验证密码哈希的防暴力破解能力
在目标服务器硬件上测量注册与登录的派生耗时,并在安全预算内校准迭代次数;单元测试应覆盖正确密码、错误密码、损坏记录,并断言相同密码配合不同 Salt 会产生不同 Hash。浏览器示例中的比较只能尽量避免提前返回;服务端应优先使用成熟密码库提供的常量时间比较。
九、FAQ
问:PBKDF2、bcrypt 和 Argon2id 如何选择?答:一般优先 Argon2id;需要 FIPS-140 兼容实现时选择 PBKDF2-HMAC-SHA256;bcrypt 主要用于已有遗留系统。
问:只用 SHA-256 多重哈希(如循环 1000 次)可以吗?答:不应自创密码哈希方案;使用成熟、可配置并经过广泛审查的密码 KDF。
问:数据库泄露后加盐还有用吗?答:有用。每条记录的独立 Salt 让相同密码产生不同 Hash,阻止跨用户复用预计算结果,但弱密码仍可能被逐条离线猜测。
十、总结
保护用户密码的物理核心是“阻慢攻击者”。使用高熵随机 Salt 抵御彩虹表,配合高迭代次数的 PBKDF2 / Argon2 抑制 GPU 暴破,才是现代化身份认证系统的标准防线。
来源与延伸阅读
技术审校所依据的规范与权威参考资料。
相关文章
为什么前后端算出来的 MD5 不一致?字符集编码 (UTF-8 vs GBK) 隐形坑
剖析哈希函数作用于底层字节流而非抽象字符的本质,分析 UTF-8 与 GBK 编码下相同字符串物理字节差异导致的 MD5 不一致问题与校验对齐方案。
实现原理Web Crypto 计算文件 SHA-256:非流式 digest 的内存边界与大文件方案
澄清 Web Crypto API 不支持流式 digest:中小文件可设置上限后一次性计算,真正的大文件应在 Worker 中使用可信的增量 SHA-256 实现。
最佳实践Git 使用全教程:从工作区原理、现代分支命令到救命灾难恢复指南
全面讲解 Git 三大区域划分、现代 git switch/restore 命令、分支管理策略、Merge 与 Rebase 异同、冲突解决以及基于 git reflog 的恢复救命手册。
继续阅读
可打开关联的浏览器工具,使用自己的样本验证文中的处理流程。
打开关联工具