数据库泄露后,用户密码是怎么被破解的?"MD5 加密"真的安全吗?为什么安全审计要求用 bcrypt 而不是 SHA256?本文从明文存储到加盐哈希再到专用密码哈希函数,把密码存储的演进讲清楚。
第一阶段:明文存储(绝对禁止)
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password VARCHAR(50) — 明文存密码
);
一旦数据库泄露,所有用户密码直接暴露。攻击者可以拿着邮箱+明文密码去撞其他网站(密码复用攻击)。
真实案例:2019 年 Facebook 明文存储了数亿用户密码,内部员工可直接查看。2020 年 Twitter 内部工具泄露,同样涉及明文密码。
第二阶段:直接哈希(不安全)
// 注册时
const passwordHash = md5(password)
db.saveUser(username, passwordHash)
// 登录时
const inputHash = md5(inputPassword)
const user = db.findUser(username)
if (inputHash === user.passwordHash) {
// 登录成功
}
看起来安全——数据库里存的是哈希值,泄露了也"解不开"。但问题在于:相同的密码有相同的哈希值。
问题一:彩虹表破解
彩虹表(Rainbow Table)是预先计算好的"密码 → 哈希值"映射表。常见密码(123456、password、qwerty 等)的 MD5/SHA256 值早就被计算好存在表里了。
彩虹表片段(MD5):
123456 → e10adc3949ba59abbe56e057f20f883e
password → 5f4dcc3b5aa765d61d8327deb882cf99
qwerty → d8578edf8458ce06fbc5bb76a58c5ca4
abc123 → e99a18c428cb38d5f260853678922e03
…(上亿条)
数据库泄露后,攻击者拿哈希值去彩虹表里查,几秒钟就能还原出密码。
在线 MD5 解密网站就是这个原理——它们不是"解密"了 MD5,而是在庞大的彩虹表里查到了对应的明文。
问题二:相同密码哈希相同
如果两个用户用了相同的密码,他们的哈希值也相同。攻击者只要破解了一个,就知道了所有相同密码用户的密码。
管理员账户如果用了常见密码,整个系统就危险了。
第三阶段:加盐哈希(大幅提升安全性)
加盐(Salt)是在密码哈希前,拼接一段随机字符串:
hash = MD5(password + salt)
每个用户有不同的 salt,相同密码产生不同哈希:
const crypto = require('crypto')
function hashPassword(password) {
// 生成随机 salt(16 字节 = 32 十六进制字符)
const salt = crypto.randomBytes(16).toString('hex')
// 加盐后计算哈希
const hash = crypto
.createHash('sha256')
.update(password + salt)
.digest('hex')
return { salt, hash }
}
// 用户 A 注册
const userA = hashPassword('password123')
// { salt: 'a1b2c3d4…', hash: 'a7b3c9d2…' }
// 用户 B 注册,密码相同
const userB = hashPassword('password123')
// { salt: 'x8y2z5w1…', hash: 'f2d8a1c7…' }
// 不同的 salt → 完全不同的 hash
Salt 不是密钥,不需要保密,和哈希值一起存在数据库里就行。它的作用是让每个用户的哈希计算不同,攻击者无法用一张彩虹表破解所有用户。
加盐后验证密码
function verifyPassword(inputPassword, salt, storedHash) {
const inputHash = crypto
.createHash('sha256')
.update(inputPassword + salt)
.digest('hex')
return inputHash === storedHash
}
const user = db.findUser(username)
if (verifyPassword(inputPassword, user.salt, user.passwordHash)) {
// 登录成功
}
加盐后的攻击成本
| 无盐 MD5 | 几秒(查彩虹表) | 几秒(批量查) |
| 加盐 MD5 | 几小时(暴力破解) | 10000 × 几小时 |
| 加盐 SHA256 | 几小时(暴力破解) | 10000 × 几小时 |
加盐让批量破解成本乘以用户数。但如果用户密码很弱(123456),暴力枚举仍然很快。
第四阶段:专用密码哈希函数(正确方案)
SHA256 是通用哈希函数,设计目标是快。现代 GPU 每秒可以计算几十亿次 SHA256,暴力破解弱密码只需几秒。
专用密码哈希函数的设计目标是慢——故意让计算一次哈希需要几百毫秒,让暴力破解变得不现实。
bcrypt
const bcrypt = require('bcrypt')
// 注册:哈希密码
const saltRounds = 10 // 成本因子,2^10 = 1024 次迭代
const hash = bcrypt.hashSync('myPassword123', saltRounds)
// $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// 算法/成本/ salt(22字符) hash(31字符)
// 登录:验证密码
bcrypt.compareSync('myPassword123', hash) // true
bcrypt.compareSync('wrongPassword', hash) // false
bcrypt 输出格式解析:
- $2b$:算法标识(bcrypt)
- 10$:成本因子(2^10 = 1024 轮)
- N9qo8uLOickgx2ZMRZoMye:22 字符的 salt(自动生成)
- IjZAgcfl7p92ldGxad68LJZdL17lhWy:31 字符的哈希值
成本因子可以调整:
- 因子 10:约 100ms / 次(推荐默认)
- 因子 12:约 400ms / 次(更安全)
- 因子 14:约 1.5s / 次(高安全场景)
随着硬件性能提升,可以逐步增大成本因子,保持每次计算约 100ms 左右。
scrypt
scrypt 是内存难函数(Memory-hard Function)——不仅计算慢,还需要大量内存。GPU 虽然算力强,但内存带宽有限,scrypt 让 GPU 加速效果大打折扣。
const scrypt = require('scrypt')
// 配置参数
const params = {
N: 16384, // CPU/内存成本(必须是 2 的幂)
r: 8, // 块大小
p: 1 // 并行度
}
const hash = scrypt.hashSync('myPassword123', params, 64, salt)
Argon2
Argon2 是 2015 年密码哈希竞赛(Password Hashing Competition)的冠军,是目前最推荐的密码哈希方案。
const argon2 = require('argon2')
// 哈希
const hash = await argon2.hash('myPassword123', {
type: argon2.argon2id, // 推荐类型(抗侧信道 + 抗 GPU)
memoryCost: 65536, // 内存成本(64MB)
timeCost: 3, // 时间成本(迭代次数)
parallelism: 4 // 并行度
})
// 验证
const valid = await argon2.verify(hash, 'myPassword123')
Argon2 有三个变体:
- Argon2d:最大化抗 GPU 攻击,适合加密货币
- Argon2i:抗侧信道攻击,适合密码哈希
- Argon2id:两者结合(推荐),先 i 后 d
三种密码哈希函数对比
| 发布年份 | 1999 | 2009 | 2015 |
| 设计目标 | 计算慢 | 计算慢 + 内存难 | 全面最优 |
| 可调参数 | 成本因子 | N, r, p | memory, time, parallelism |
| 抗 GPU | 一般 | 好 | 最好 |
| 标准 | 事实上的标准 | RFC 7914 | PHC 冠军 |
| 生态支持 | 最广 | 较广 | 逐步普及 |
| 推荐场景 | 通用(最稳妥) | 高安全场景 | 新项目首选 |
各语言推荐方案
| Node.js | bcrypt / argon2 | bcrypt / argon2 |
| Python | bcrypt / argon2 | bcrypt / argon2-cffi |
| Java | bcrypt / Argon2 | Spring Security BCryptPasswordEncoder / argon2 |
| Go | bcrypt / argon2 | golang.org/x/crypto/bcrypt |
| PHP | password_hash | 内置 password_hash(PASSWORD_BCRYPT) |
| Ruby | bcrypt | bcrypt gem |
在线练习
理解加盐哈希最直观的方式是亲手算一算。可以使用在线的哈希计算工具,推荐一款在线的免费工具 盘子工具站哈希计算工具 上做这种练习——
第一步:输入 password123,记录 MD5 和 SHA256 结果。 
第二步:输入 password123 + 你的随机salt(比如 password123a1b2c3),再算一次哈希。对比加盐和不加盐的结果——加盐后哈希值完全不同。 
第三步:换一个 salt(比如 password123x8y2z),再算一次。相同密码、不同 salt,产生完全不同的哈希值。这就是加盐的核心意义。 
你还可以做一个彩虹表原理的小实验:用工具计算 123456 的 MD5 值 e10adc3949ba59abbe56e057f20f883e,然后去搜"MD5 解密"网站粘贴这个值——它会立刻返回 123456。这就是彩虹表的威力,也是为什么密码存储必须加盐。
所有计算在浏览器本地完成,不上传服务器,适合做安全实验。

