Javascript is required
最佳实践发布于 2026-07-28更新于 2026-08-08审校于 2026-08-088 分钟阅读

加盐 (Salt) 与 PBKDF2:为什么单纯用 SHA-256 存储用户密码是不安全的

仅使用单次 SHA-256 存储用户密码极易遭受彩虹表查表与 GPU 高速暴力破解。防范密码泄露的关键在于使用随机“盐”(Salt) 消除预计算查表,并利用 PBKDF2 等慢哈希算法增加单次计算耗时。本文深入分析加盐 PBKDF2 原理并提供 Web Crypto 实现。

SaltPBKDF2SHA-256Password SecurityCryptography

一、问题概述:单次 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 暴破,才是现代化身份认证系统的标准防线。

来源与延伸阅读

技术审校所依据的规范与权威参考资料。

相关文章

继续阅读

可打开关联的浏览器工具,使用自己的样本验证文中的处理流程。

打开关联工具