● 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

工程与系统安全加固清单

参考来源

← 浏览完整的安全目录 全部漏洞指南 →