bigywater.tech
Python · 78

Python 密码存储:为什么不能直接 sha256(password)

把口令存成 sha256(password) 看起来已经「加密」了,其实攻击者一晚上能试的猜测数多到你想象不到。这篇让脚本真的去枚举一遍,再换成故意做得慢的 pbkdf2_hmac 和 scrypt,看每次猜测到底贵了多少。

250 ms
单核纯 Python 循环枚举出 sha256 存储的 6 位数字口令
64 ms / 次
pbkdf2_hmac 60 万次迭代,每次猜测的成本
相同口令,不同哈希
加盐之后,两个人用同一口令也认不出来

做的事:先当攻击者

假设数据库泄露,拿到 sha256(口令) 的十六进制串
实验脚本(节选) stored = hashlib.sha256(b"482915").hexdigest() # 泄露出来的值 for i in range(1_000_000): # 6 位数字口令的全部空间 g = f"{i:06d}" if hashlib.sha256(g.encode()).hexdigest() == stored: break
实测 · 本机执行输出
  库里存的: 48290cf691c41cbc99b2396d2e5313ccfba91987b384e6d8f08b951fa5045e83
  纯 Python 循环暴力枚举 6 位数字: 试了 482916 次, 找到 '482915', 用时 250 ms, 速度 1,931,646 次/秒 (单核, 没有任何优化)
  字典 (10 个常见口令) 命中 'letmein', 用时 9 µs

这是一个没有任何优化的 Python 循环,速度已经约 1,931,646 次/秒;用 C、多核或显卡,速度只会高几个数量级(这条我没有实测,只是指出瓶颈在猜测速度而不是算法)。口令如果落在常见口令字典里就更快:10 个常见口令的字典,命中 letmein 只用了 9 微秒。

验证一:不加盐,相同口令得到相同哈希

实测 · 本机执行输出
  ann: sha256 = f52fbd32b2b3b86ff88ef6c4…
  bob: sha256 = f52fbd32b2b3b86ff88ef6c4…
  cat: sha256 = d9298a10d1b0735837dc4bd8…
  加盐 (每人一个随机 16 字节):
  ann: salt=61e434a9… sha256(salt+pw) = 6f26d3bbe49f00fd6649b1de…
  bob: salt=f382e363… sha256(salt+pw) = 046069d603326682b1574a26…

ann 和 bob 用了同一个口令,库里的 sha256 一模一样——攻击者不用算,一眼就知道他俩口令相同,还能用一张预先算好的「哈希 → 口令」表(彩虹表)一次查完全库。每人一个随机 16 字节盐之后,同一个口令得到完全不同的值,攻击者只能逐个用户单独猜。盐不需要保密,和哈希一起存就行。

验证二:让每次猜测变贵

pbkdf2_hmac 的迭代次数、scrypt 的内存参数
实测 · 本机执行输出
  sha256(一次)                           0.29 µs / 次   →    3,439,588 次/秒
  pbkdf2_hmac 迭代 1000                  0.18 ms / 次   →      5,529.6 次/秒
  pbkdf2_hmac 迭代 100000               10.66 ms / 次   →         93.8 次/秒
  pbkdf2_hmac 迭代 600000               64.44 ms / 次   →         15.5 次/秒
  scrypt n=2^14, r=8, p=1             32.10 ms / 次   →         31.2 次/秒   (需要内存 ≈ 128*n*r = 16 MiB)
  scrypt n=2^15, r=8, p=1             64.80 ms / 次   →         15.4 次/秒   (需要内存 ≈ 128*n*r = 32 MiB)
  按 6 位数字空间 (100 万种) 算, 枚举全部要:
    sha256                    0.3 秒
    pbkdf2 1000             180.8 秒
    pbkdf2 100000        10,657.6 秒
    pbkdf2 600000        64,440.2 秒
    scrypt 16384         32,099.9 秒

单次 sha256 只要 0.29 µs;pbkdf2_hmac 迭代 60 万次,一次要 64.4 ms,慢了约 221,648 倍。对正常用户,一次登录多花 64 ms 完全无感;对要枚举 100 万种可能的攻击者,单核就是 17.9 小时。scrypt 除了耗时还要占内存(表里按公式 128·n·r 估算,并非实测),让攻击者不好靠大量并行硬件压价。迭代次数应当随机器变快而调高,而不是写死。

验证三:实际存储和校验

盐、迭代次数和哈希存在一个字符串里
实测 · 本机执行输出
  存进库的字符串: pbkdf2_sha256$600000$17479bc64d8471a85291a6f4e8e95467$97e315… 长度 118
  verify('hunter2') = True | verify('hunter3') = False
  同一口令两次 hash_pw 得到相同记录? False

把算法、迭代次数、盐、哈希用 $ 拼起来存一个字段,以后调高迭代次数时旧记录仍能按它自己的参数验证。同一口令两次生成的记录不同(盐不同),所以不能拿字符串比较去查「谁用了这个口令」;校验用 hmac.compare_digest(见上一篇)。

分步演示:同一个 6 位数字口令,枚举全部空间要多久

每个数字都是本机单核实测每次猜测的耗时 × 100 万(条长按对数刻度)
每次猜测越贵,枚举越慢
新加入的算法

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 hashlib(sha256、pbkdf2_hmac、scrypt)、hmac.compare_digest
    • 没有验证:argon2、bcrypt(标准库不带)、GPU 攻击的实际速度、迭代次数应该取多少(取决于你的硬件和登录延迟预算)
    京ICP备2026067502号-1京公网安备11010502063487号