揭秘彩虹表攻击原理与防御实战技巧
我为啥要折腾彩虹表这玩意儿?
上周上线前夜,安全组突然甩给我一份报告,说我们某个老项目的密码存储用了 MD5,没加盐,被列为了高风险项。我一看,好家伙,这项目还是十年前用 PHP 写的,用户表里存的全是明文 MD5。虽然现在登录流程已经上了 HTTPS,但架不住人家拿个彩虹表一跑,分分钟给你反推出原始密码。
于是我就开始重新看密码哈希这块的防护方案。本来想直接上 bcrypt 就完事了,但得先搞清楚:为什么不能只靠简单哈希?彩虹表到底多可怕?现有的对抗手段哪些真有用?所以我把常见的几种方案拉出来对比了一下——主要是加盐哈希、bcrypt、scrypt 和 Argon2。以下是我的实战总结。
谁更灵活?谁更省事?
先说结论:我现在新项目一律用 Argon2,老项目迁移优先上 加盐的 SHA-256,实在动不了的就加一层 HMAC 做二次混淆,至少别让标准彩虹表直接命中。
下面一个个来看。
方案一:裸哈希(MD5/SHA-1)——别用了,真的
这种就是当年我踩过的坑。用户注册时直接:
// 千万别这么干!
function hashPassword(password) {
return crypto.createHash('md5').update(password).digest('hex');
}
结果呢?网上随便找个 rainbow table 工具,比如用 RainbowCrack 配合预生成的链表,5位数字密码秒破。我试过一个 e10adc3949ba59abbe56e057f20f883e,不到两秒就出结果是 123456。
这类方案的问题不是性能差,而是完全没防御预计算攻击。同一个密码永远输出同一个哈希值,等于给人家送数据集。
方案二:加盐哈希 —— 至少能挡住通用表
这是我处理老项目时最常用的“低成本升级”方式。核心思想就一点:每个用户生成独立随机盐,拼接后哈希。
const crypto = require('crypto');
function generateSalt() {
return crypto.randomBytes(16).toString('hex'); // 16字节随机盐
}
function hashPasswordWithSalt(password, salt) {
return crypto.createHash('sha256').update(salt + password).digest('hex');
}
// 存储时:{ hashed: '...', salt: '...' }
验证的时候也简单:
function verifyPassword(password, storedHash, storedSalt) {
const hash = hashPasswordWithSalt(password, storedSalt);
return hash === storedHash;
}
优点很明显:改动小,兼容老系统,数据库加个 salt 字段就行。而且因为盐是随机的,攻击者没法为每个可能密码预先计算所有组合,彩虹表基本废了。
但我踩过一次坑:一开始我把盐写死在代码里,以为“隐蔽就行”。结果泄露后全库密码一夜之间被批量破解。记住:盐必须每个用户独立且随机生成,藏代码里等于没藏。
另一个问题是,SHA-256 算得太快了。现在 GPU 一秒能算几亿次,配合字典攻击依然危险。所以这只是“基础防护”,不能算终极方案。
方案三:bcrypt —— 我用得最久的主力方案
从 Node.js v8 开始自带 bcryptjs 或原生支持,我写了好几年都靠它撑着。它的设计就是专治暴力破解:内置 salting,还带 cost factor 控制计算延迟。
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 12; // 根据服务器性能调整
return await bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hashed) {
return await bcrypt.compare(password, hashed);
}
生成出来的哈希长这样:$2b$12$...,里面已经包含了算法版本、cost 和 salt,不需要额外存字段,特别省心。
我比较喜欢用 bcrypt 的原因是:平衡性好。你调高 cost 能明显拖慢破解速度,而对正常用户登录的影响几乎感知不到。我在阿里云一台 2C4G 的机器上测试,cost=12 时单次哈希耗时约 120ms,但 GPU 并行算力在这里吃不满了。
缺点也不是没有:它依赖 OpenSSL,在某些嵌入式环境或特殊运行时可能装不上;另外它内存占用固定,抗 FPGA/ASIC 攻击能力不如更新的算法。
方案四:scrypt 和 Argon2 —— 下一代选择
scrypt 我用得不多,因为它配置参数太复杂了。你要手动设 N、r、p 三个值,调不好反而拖垮服务。但我见过有人在加密钱包项目里硬上 scrypt,确实抗矿机能力强。
真正让我转向的是 Argon2。它是 Password Hashing Competition (PHC) 的冠军算法,设计目标就是防专用硬件攻击。Node.js 有 argon2 包,用起来也不算难:
const argon2 = require('argon2');
async function hashPassword(password) {
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // ~64MB
timeCost: 3,
parallelism: 1,
});
}
async function verifyPassword(password, hashed) {
try {
return await argon2.verify(hashed, password);
} catch (err) {
return false; // 自动处理 invalid hash
}
}
Argon2 的优势在于可配置性强,尤其是 memoryCost 可以拉高到几百 MB,直接让 GPU 显存爆掉。我测过一次,设置 memoryCost=128MB 后,NVIDIA 3090 上每秒只能跑不到 10 次尝试,比 bcrypt 还稳。
但它也有问题:Node.js binding 编译容易失败,CI 流水线经常卡住;生产环境还得确保机器有足够内存。所以我一般只在金融类、管理员账户这种高安全场景用。
性能对比:差距比我想象的大
我在本地 Mac M1 上做了个简单压测(单线程),对比三种算法的成本增长曲线:
- SHA-256(加盐):~0.1ms / 次
- bcrypt(cost=12):~120ms / 次
- Argon2(memory=64MB):~180ms / 次
看着好像差别不大,但换成攻击视角就吓人了:
假设攻击者有 RTX 4090(约 1000 亿次 SHA-256/s),他可以在一天内遍历整个 8 位字母数字空间。但换成 bcrypt,同一张卡最多跑 10 万次/秒,暴力破解变得完全不现实。Argon2 更狠,因为内存瓶颈,根本没法并行化。
所以结论很明确:慢才是安全的本质。你越愿意牺牲一点点用户体验,攻击者的成本就指数级上升。
我的选型逻辑
现在我做项目基本按这个顺序决策:
- 全新项目:直接上 Argon2,配置 memoryCost 至少 64MB,timeCost=3,type=argon2id(抗侧信道攻击更强)。
- 已有项目升级:先加盐 SHA-256 过渡,再逐步迁移到 bcrypt。可以用登录时重哈希策略,用户一登录就自动升级成新格式。
- 极端受限环境(如 IoT 设备):只能用 SHA-256 加盐,但必须结合速率限制、登录失败锁定、多因素认证来补足。
有一次我图省事,在一个内部管理系统用了 plain SHA-256,结果三个月后日志发现有人在扫常见密码。加上 bcrypt 后,这类请求立马没了——不是他们放弃了,是算不动了。
别忘了前端也能做点事
虽然密码哈希是后端的事,但前端也可以帮忙减负。比如大项目我会加一层 WebCrypto,在提交前先做一次 PBKDF2:
async function clientSideHash(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: encoder.encode(salt),
iterations: 10000,
hash: 'SHA-256',
},
keyMaterial,
256
);
return Array.from(new Uint8Array(bits))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
然后把这个 hash 当作“新密码”传给后端再走一遍 bcrypt。虽然不能替代服务端防护,但至少让中间人拿不到原始密码,也减轻了后端计算压力。
注意:千万别只在前端哈希就完事了!那等于把哈希当新密码,一样会被 replay 攻击。
以上是我的对比总结,有不同看法欢迎评论区交流
说实话,没有银弹。我见过团队为了追求 Argon2 把服务器内存打满,也见过坚持用 MD5 的老系统天天被撞库。关键是根据业务风险等级做取舍。
我的底线是:至少加盐 + 不可逆哈希 + 防暴破机制。能做到这三点,大多数场景就够了。至于彩虹表?只要你不用裸哈希,它们就只能干瞪眼。
这个技巧的拓展用法还有很多,后续会继续分享这类博客。如果你也在处理类似问题,不妨试试从 bcrypt 入手,别一上来就想一步到位。

暂无评论