Python 里有三样东西都长得像「随机」:random、secrets、hashlib。它们分别对应三种完全不同的需求。这篇不讲概念,直接做实验:能复现的、猜不到的、算出来永远一样的,到底各是什么表现。
| 需求 | 该用 | 特征 |
|---|---|---|
| 可复现的模拟、测试数据、洗牌 | random | 给同一个种子,得到同一串数 |
| 令牌、密码、盐、任何需要「猜不到」的东西 | secrets | 来自操作系统的熵,没有种子 |
| 指纹、缓存 key、文件校验 | hashlib | 同一输入永远同一输出,没有随机性 |
下面每一行结论都来自一次真实运行。
第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。
攻击的核心 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 / 5random 生成的密码重置令牌、会话 ID、验证码,只要攻击者能看到足够多的同一个生成器的输出,后面的就能被算出来——上面第 3 行的 12 位令牌就是被原样预测出来的。而对 SystemRandom 做完全相同的事,命中数是 0/5。注意攻击者事先不知道受害者的种子(受害者用 os.urandom 播种),只看输出。「随机」和「不可预测」是两回事。
新进程里 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。
random、secrets、hashlib;攻击代码是我自己写的 untemper,不依赖第三方库randint(0, 99))需要多少样本、哈希算法之间的抗碰撞差异