4、密码存储安全基础:哈希算法对比(MD5/SHA256/bcrypt)、加盐策略、加密与解密
你看,咱们做 DLP 场景的时候,最头疼的往往不是接口怎么调,而是密码存哪、怎么存。我接手过一个遗留系统,用户表里的密码居然还是明文存的……直接给我看愣了。后来重构时我特意把这一块做扎实了,今天就聊聊这个话题。
4.1 为什么说哈希是存储密码的正道?
先说个定义:哈希(Hash)是一种单向的数学变换。你给它任意长度的数据,它吐出一个固定长度的摘要。但反过来,你几乎不可能从摘要还原原始输入——这就是“不可逆”。
那加密呢?加密是双向的,你拿密钥加密,别人拿密钥或配对密钥就能解密。如果把加密用在密码存储上,一旦密钥泄露,所有密码秒变裸奔。我见过一个项目用 AES 加密存密码,结果密钥硬编码在代码里……那是真踩坑。
所以结论很简单:存密码,只哈希,不加密。
4.2 MD5、SHA256、bcrypt 到底差在哪?
我当年刚入行时,大部分项目还在用 MD5。后来查资料发现 MD5 的碰撞漏洞早在 2004 年就公开了——一个 128 位的输出,已经被证明可以构造出两个不同输入产生相同哈希。这对密码存储来说是致命伤。你再想想,攻击者只要能找到一个相同哈希的字符串,就等于绕过了密码验证。
那 SHA256 呢?这是 SHA-2 家族的一员,256 位输出。目前没有公开的碰撞攻击。很多人觉得“那就用 SHA256 吧,足够安全了”。嗯,理论上没错,但实际应用中 SHA256 是“快速哈希”——它的设计目标是计算快、碰撞难。可在密码存储场景里,快恰恰是缺点。攻击者可以用 GPU 或者 ASIC 每秒跑上亿次 SHA256,暴力破解弱密码只是时间问题。
这里就要请出 bcrypt 了。bcrypt 是一种自适应慢速哈希,它内部会反复迭代,还内置了加盐(Salt)机制。最关键的是,你可以通过调整 “cost factor”(工作因子)来增加计算难度。假设你设 cost=12,那么一次 bcrypt 大约需要 250 毫秒(具体看硬件);对合法用户来说多等 0.25 秒没感觉,但攻击者想遍历 10 万个常见密码,那就得花 7 个多小时——这还不算每个密码都要单独跑一次 bcrypt 的迭代。
我把这三个算法的主要差异列个表:
| 特性 | MD5 | SHA256 | bcrypt |
|---|---|---|---|
| 输出长度(位) | 128 | 256 | 可变(通常 184) |
| 是否可逆 | 不可逆 | 不可逆 | 不可逆 |
| 是否抗碰撞 | ❌ 已攻破 | ✔ 目前安全 | ✔ 安全 |
| 运算速度 | 极快(≈ 2 亿次/秒 GPU) | 快(≈ 6000 万次/秒 GPU) | 慢(cost=12 时 ≈ 4 次/秒 GPU) |
| 内置加盐 | ❌ 需要手动加 | ❌ 需要手动加 | ✔ 自动加盐 |
| 抗 GPU/ASIC 加速 | 非常差 | 较差 | 优秀(因内存和迭代开销) |
| 推荐场景 | 不应使用 | 校验完整性(如文件摘要) | 密码存储首选 |
看到这个表格,你大概心里有数了。我个人习惯:密码存储一律用 bcrypt,也不去折腾手动加盐的事,bcrypt 已经帮你干了。
4.3 加盐策略:别让两个相同密码的哈希一样
“加盐”是什么?就是在哈希之前,往密码后面拼一段随机字符串(叫 Salt)。每个用户拥有一个独立的 Salt,存的时候把 Salt 和哈希结果一起存。下一次验证时,用用户输入的密码 + 存储的 Salt 再算一遍哈希,对比即可。
为什么要加盐?你想想,假如没用盐,攻击者拿到你的用户表,看到两个哈希值一样,立马就知道这两个用户用的是同一个密码。更糟的是,他可以预先计算常见密码的哈希表(叫彩虹表),一比对就能还原大批密码。加了盐之后,每个密码都混入了不同的随机数,彩虹表直接失效。攻击者必须对每个盐值单独跑一遍全量计算——计算量直接翻几万倍。
我曾经在一个项目里用了全局固定盐……就是写在配置文件里的一个字符串。后来安全审计时被吐槽:固定盐跟没加盐区别不大,攻击者拿到配置文件后照样能建彩虹表。正确的做法是:每个用户生成一个随机盐,长度至少 16 字节,最好 32 字节,用密码学安全的随机数生成器(比如 Python 的
os.urandom)来产。
说到长度,我给出一个常用方案:
- 盐长度:16 ~ 32 字节(128 ~ 256 bit)
- 生成方式:
os.urandom(16).hex()或secrets._hex(32) - 存储格式:把 Salt 和 Hash 拼接起来存,例如:
$2b$12$SaltBase64HashBase64(bcrypt 默认格式)
盐不要用用户名、用户 ID、时间戳、递增数字这类可预测的玩意。理由很简单:攻击者能猜出规律,那跟没加盐没什么两样。
4.4 加密 ≠ 解密,这是两码事
很多新手会把“加密”和“解密”当成对称操作。其实在密码存储语境下,他们说的是两套完全不同的机制:
- 加密(Encryption):使用密钥将明文变成密文,以后可以用同一个密钥(对称)或配对密钥(非对称)还原。适用于数据传输和存储时的机密性保护,比如 HTTPS 的 TLS 握手、数据库字段加密等。
- 哈希(Hash):单向函数,不可还原。适用于密码验证、数据完整性校验等不需要还原原始值的场景。
所以“加密与解密”这个标题容易让人误解。正确的关系是:
存密码 → 用哈希;保护其他敏感数据(如信用卡、身份证)→ 用加密。
但在 DLP 场景里,我们有时候需要“解密”吗?比如某个接口要取出用户的临时凭证做校验——那是对称加密的场景,而不是密码存储。记住:密码永远不应该被解密。
4.5 框架图:密码存储安全的核心逻辑
我把这一块的知识结构画成一张图,方便你理解整体流程:
4.6 代码实战:Python 里到底怎么写?
纸上谈兵没用,咱们直接上代码。我用 Python 的 bcrypt 库演示注册和登录流程:
import bcrypt
def register_user(username, password):
# 1. 生成盐(bcrypt 内部自动生成,长度为 16 字节)
# 2. 哈希密码,指定 cost factor(推荐 ≥12)
# 注意:这里盐已经打包在结果里了
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
# 存储到数据库时只需要存 hashed(它包含了盐和哈希)
print(f"用户名: {username}, 哈希: {hashed.decode()}")
def verify_user(username, password, stored_hash):
# 从数据库取出 stored_hash,它已经包含盐
# 直接用用户输入的密码校验
if bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
print("验证成功")
else:
print("密码错误")
看到没?bcrypt 的 gensalt 自动给你生成了随机盐,还让你指定工作因子 rounds。我一般建议 cost=12,低于 10 就太快了,高于 14 对用户端体验影响较大(比如移动端登录等半秒到一秒)。具体情况可以压测后调整。
4.7 避坑清单(这部分我踩过)
- 千万别用 MD5 存密码——哪怕你加盐,MD5 的碰撞漏洞也让攻击者有机可乘。
- 固定盐 ≈ 不加盐——一定要每个用户单独随机盐。
- 不要自己实现哈希函数——你用 Python 的
hashlib.md5或者hashlib.sha256然后再手动拼接盐,容易出错(比如漏了编码、换了分隔符导致校验失败)。直接用成熟库。 - 成本因子别设太低——我见过有人用 bcrypt 但 rounds=4,跟 SHA256 一样快,白费功夫。
- 永远不要解密密码——如果有人问你要“忘记密码时把明文发回”,直接告诉他这是安全红线。只能重置,不能还原。
4.8 小结一下
密码存储的核心三件事:
- 只用慢哈希算法(bcrypt 为首选)
- 每个用户独立加随机盐
- 绝不加密、绝不解密
你把这个基础打牢,后面聊 DLP 场景里的密码同步优化才不会跑偏。好,这一章就到这。
“存密码用 bcrypt,存其他敏感数据用加密。别把两者搞混,否则你的库哪天被拖了,哭都来不及。”