揭秘彩虹表攻击原理与防御实战技巧

FSD-浚博 安全 阅读 1,621
赞 29 收藏
二维码
手机扫码查看
反馈

我为啥要折腾彩虹表这玩意儿?

上周上线前夜,安全组突然甩给我一份报告,说我们某个老项目的密码存储用了 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 更狠,因为内存瓶颈,根本没法并行化。

所以结论很明确:慢才是安全的本质。你越愿意牺牲一点点用户体验,攻击者的成本就指数级上升。

我的选型逻辑

现在我做项目基本按这个顺序决策:

  1. 全新项目:直接上 Argon2,配置 memoryCost 至少 64MB,timeCost=3,type=argon2id(抗侧信道攻击更强)。
  2. 已有项目升级:先加盐 SHA-256 过渡,再逐步迁移到 bcrypt。可以用登录时重哈希策略,用户一登录就自动升级成新格式。
  3. 极端受限环境(如 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 入手,别一上来就想一步到位。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论