●
CWE-916 · OWASP A02:2021
不安全的密码哈希(CWE-916):为什么 SHA-256 不够,以及如何存储密码
把密码存成不加盐的 SHA-256、SHA-1 或 MD5 摘要,意味着泄露的数据表可以在 GPU 上以每秒数十亿次的速度被尝试,而且所有使用相同密码的用户共享同一个哈希。本文用 Python 的 argon2-cffi 示例说明盐、Argon2id 这类消耗内存的函数、登录时重新哈希以及虚拟验证如何保护被盗的数据库。
通俗易懂的原理解析 (ELI5)
衣帽间为每张号牌留了一份副本,用来核对哪件外套属于谁。如果副本只是普通复印件,偷走文件夹的小偷就能拿走所有外套。快速哈希就像稍微弄脏的复印件:带着高速扫描仪的小偷每秒能还原数百万张。慢速、耗内存的哈希则是把副本锁进一个小保险箱,打开它要整整一秒钟和一张大工作台,每张号牌一个保险箱,每个的锁都不一样。主人在门口打开一个保险箱,感觉不到延迟;小偷却得打开几百万个。
核心概念与专有名词
- 快速哈希
- SHA-256、SHA-1 和 MD5 的设计目标就是快。一块现代 GPU 每秒能计算数十亿次 SHA-256,所以简短或常见的密码在暴力破解和字典攻击下几小时内就会被破。
- 盐
- 与每个哈希一起存储的随机值,让相同的密码得到不同的哈希。没有盐,一张预先计算好的表就能一次破解所有账户,而相同的哈希还会暴露哪些用户共用了密码。
- 消耗内存的函数(Argon2id)
- Argon2id 让每次猜测都要付出时间和内存代价,从而抹掉 GPU 的大部分优势。OWASP 的最低要求是 19 MiB 内存、两次迭代和一条通道(
memory_cost=19456、time_cost=2、parallelism=1);scrypt 和 bcrypt 也是可接受的替代方案。 - PHC 字符串与重新哈希
PasswordHasher.hash()返回形如$argon2id$v=19$m=19456,t=2,p=1$...的字符串,其中包含算法、参数、盐和哈希。check_needs_rehash()会在存储的参数弱于当前参数时提示你,以便在下一次成功登录时升级哈希。- 响应时间与账户枚举
- 如果不存在的邮箱立即返回,而真实邮箱要花一次哈希验证的时间,响应时间就会暴露哪些账户存在。对一个虚拟哈希做验证,可以让两条路径耗时相同。
攻击执行流程分解
步骤 1
用户表泄露
一次 SQL 注入、一个暴露的备份或一台被盗的笔记本电脑,让攻击者拿到了 password_hash 列。
步骤 2
攻击者运行 GPU 破解工具
不加盐的 SHA-256 让他只需对字典计算一次哈希,就能和每一行比对。一个密码为 Summer2024! 的测试账户展示了常见模式被破解得有多快。
步骤 3
相同的密码一起被破解
所有选择相同密码的用户都有相同的摘要,所以破解一个哈希就暴露了所有这些用户。
步骤 4
密码被拿到别处重复使用
破解出的密码会被拿去尝试登录邮箱、银行和云账户。2012 年 LinkedIn 泄露了约 650 万个不加盐的 SHA-1 密码哈希,2016 年同一事件中约 1.17 亿条凭据被公开出售。
源代码对比:漏洞与安全实现
存在漏洞的实现
# users.py:每个密码一个快速、不加盐的摘要
import hashlib
from .db import db
# 相同密码得到相同哈希,GPU 每秒可尝试数十亿次
def create_user(email: str, password: str) -> None:
digest = hashlib.sha256(password.encode()).hexdigest()
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))
# 不存在的邮箱立即返回,响应时间暴露了哪些账户存在
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
加固后的安全修复
# users.py:Argon2id,每个密码独立加盐,登录时升级
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# OWASP 对 Argon2id 的最低要求:19 MiB、2 次迭代、1 条通道
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# 存储的字符串包含算法、参数、盐和哈希
def create_user(email: str, password: str) -> None:
if not 12 <= len(password) <= 128:
raise ValueError("password must be 12 to 128 characters")
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))
# 未知用户同样要付出一次验证的时间;弱哈希会被升级
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
stored = row[1] if row else DUMMY_HASH
try:
hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if row is None:
return False
if hasher.check_needs_rehash(stored):
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
return True
工程与系统安全加固清单
- 用 Argon2id(至少 19 MiB 内存、2 次迭代、1 条通道)、scrypt 或 bcrypt 哈希密码;添加一条 Semgrep 规则标记对密码变量调用
hashlib的代码,并运行 Bandit B324 检查 MD5 和 SHA-1。 - 存储库返回的完整 PHC 字符串,绝不要拆出或复用其中的盐。
- 每次登录成功后调用
check_needs_rehash(),替换用较弱参数生成的哈希。 - 账户不存在时对一个虚拟哈希做验证,让响应时间不会暴露已注册的邮箱。
- 要求至少 12 个字符,允许至少 64 个字符的长口令,并拒绝出现在泄露列表中的密码。