bigywater.tech
Python · 76

secrets、random 和 hashlib:三种随机需求不要混用

Python 里有三样东西都长得像「随机」:random、secrets、hashlib。它们分别对应三种完全不同的需求。这篇不讲概念,直接做实验:能复现的、猜不到的、算出来永远一样的,到底各是什么表现。

624 个输出
看到这么多 random 的输出,就能预测之后的全部
5 / 5 vs 0 / 5
同一攻击对 random 与 SystemRandom 的命中数
66 vs 898 ns
random.getrandbits(64) 与 secrets.randbits(64) 单次耗时

三种需求,三个模块

需求该用特征
可复现的模拟、测试数据、洗牌random给同一个种子,得到同一串数
令牌、密码、盐、任何需要「猜不到」的东西secrets来自操作系统的熵,没有种子
指纹、缓存 key、文件校验hashlib同一输入永远同一输出,没有随机性

下面每一行结论都来自一次真实运行。

验证一:random 有种子,secrets 没有

实测 · 本机执行输出
  第1次 seed(42): [81, 14, 3, 94, 35, 31]
  第2次 seed(42): [81, 14, 3, 94, 35, 31]
  不给种子(系统熵), 两次: [85, 59, 69, 26, 48, 61] [29, 91, 92, 4, 98, 86] | 相同? False
  secrets.token_hex(8) 三次: ['c3e5f0c66f5dce7e', 'ea0f6184631c4c14', 'cf678b81c10fda4f']
  secrets.SystemRandom().seed(42) 有效吗? [54, 79, 95, 97, 19, 38] [39, 48, 68, 44, 23, 24] → 相同? False

random.seed(42) 两次得到完全一样的 6 个数;不给种子(用系统熵初始化)每次不同。secrets.token_hex 每次不同,而且 SystemRandom.seed(42) 被静默忽略——调用不报错,但结果不可复现。所以「想要可复现」只能用 random,「想要不可预测」就不能用 random。

验证二:看够 624 个输出,random 的未来就是透明的

Mersenne Twister 是可逆的
攻击的核心 def untemper(y): # 撤销输出时的 4 步位运算 y ^= y >> 18 y ^= (y << 15) & 0xefc60000 t = y for _ in range(5): t = y ^ ((t << 7) & 0x9d2c5680) y = t t = y for _ in range(3): t = y ^ (t >> 11) return t state = [untemper(victim.getrandbits(32)) for _ in range(624)] clone = random.Random(); clone.setstate((3, tuple(state + [624]), None))
实测 · 本机执行输出
  观察了受害者 624 个 32 位输出, 还原内部状态后预测接下来 5 个:
    预测 1796510508   真实 1796510508   一致
    预测 3515932412   真实 3515932412   一致
    预测 2827806056   真实 2827806056   一致
    预测 1541232516   真实 1541232516   一致
    预测  905059829   真实  905059829   一致
  同一个克隆预测 random() 的下 3 个浮点数: True
  受害者用 random.choices 生成的 12 位「重置令牌」: 10r0f44at4s2   攻击者预测: 10r0f44at4s2   一致? True
  同样对 SystemRandom 做: 预测 [3500589940, 2105006898, 2823789283, 1536969587, 4250992135] 真实 [2900459284, 3559922608, 776402124, 3313603305, 4000136316] → 命中个数 0 / 5
用 random 生成的密码重置令牌、会话 ID、验证码,只要攻击者能看到足够多的同一个生成器的输出,后面的就能被算出来——上面第 3 行的 12 位令牌就是被原样预测出来的。而对 SystemRandom 做完全相同的事,命中数是 0/5。

注意攻击者事先不知道受害者的种子(受害者用 os.urandom 播种),只看输出。「随机」和「不可预测」是两回事。

验证三:hashlib 不随机,内置 hash() 也不是它

实测 · 本机执行输出
  新进程里 sha256('hello')[:16], hash('hello') = 2cf24dba5fb0a30e 3336813239569451581
  新进程里 sha256('hello')[:16], hash('hello') = 2cf24dba5fb0a30e -6288594827304141090
  新进程里 sha256('hello')[:16], hash('hello') = 2cf24dba5fb0a30e -319493961938406531
  sha256 三个进程一致? True | 内置 hash() 三个进程一致? False
  sha256(b'hello') 完整: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
  同一输入改一位 sha256(b'hellp'): fdd7585e08c4e2afd71dcabdb4636c89d557a3f42db9e2040c8bbd1708aa4ce7

三个新进程里,sha256(b'hello') 一模一样;而 hash('hello') 每个进程都不同——字符串的内置哈希被加了进程级随机盐。所以不能拿 hash() 做跨进程/持久化的指纹(存进数据库、当分片 key),这种场景要用 hashlib。反过来,hashlib 的输出也不能当随机数或令牌用:输入相同,输出永远相同,一位之差则完全不同(最后一行)。

验证四:安全是有价格的

实测 · 本机执行输出
  random.getrandbits(64)                 66 ns / 次
  secrets.randbits(64)                  898 ns / 次
  secrets.token_bytes(8)                777 ns / 次
  hashlib.sha256(b'x').digest()         291 ns / 次

本机上 secrets.randbits(64) 约 898 ns,random.getrandbits(64) 只要 66 ns,相差 14 倍左右。绝大多数场景里令牌一次请求只生成一个,这个差距完全可以忽略——只有在大量生成随机数做模拟时,才值得为速度用 random。

分步演示:从 624 个输出到完全预测

数字都取自上面实验的真实记录
还原 Mersenne Twister 状态
这一步的关键数据

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 random、secrets、hashlib;攻击代码是我自己写的 untemper,不依赖第三方库
    • 没有验证:更少输出量下的部分还原、非 32 位边界的输出(比如只观察 randint(0, 99))需要多少样本、哈希算法之间的抗碰撞差异
    京ICP备2026067502号-1京公网安备11010502063487号