随机性陷阱:Math.random() vs crypto.getRandomValues()
随机性陷阱:Math.random() vs crypto.getRandomValues()
写前端代码十年,几乎每个项目都见过这种用法:
const token = Math.random().toString(36).substring(2);
const code = Math.floor(Math.random() * 1000000);
const winner = items[Math.floor(Math.random() * items.length)];
对前两个,安全审计直接打回——这是密码学场景误用伪随机的经典反例。第三个看似无害,但当 "winner" 涉及金钱、奖品、用户排序时,同样能被反推。
本文讲三件事:Math.random() 到底有多"不随机"、crypto.getRandomValues() 该怎么用、以及一个比想象中常见的细节—— modulo bias。
Math.random() 是什么
V8(Chrome / Node.js)的 Math.random() 实现的是 xorshift128+——一种 PRNG(伪随机数生成器)。它有两个核心特性:
- 确定性:给定相同的内部 state,输出序列完全可预测
- 可反推:观察到足够多的连续输出就能反推 state
xorshift128+ 的 state 是 128 bit。论文 Practical seed-recovery for the X128+ PRNG 表明,只需观察 5 个连续的 Math.random() 输出,就能用 SAT solver 在几秒内还原 state,并预测之后的全部输出。
实际意义:如果你的网站用 Math.random() 生成"6 位邮箱验证码",攻击者只要触发 5 次任何能拿到随机数的接口(比如生成 nonce、随机推荐顺序、AB 实验分桶),就能预测下一次发给受害者的验证码。
更糟的是 V8 的 batched 优化:Math.random() 一次性算 64 个输出存进 buffer,依次返回。这意味着你看到的不是"刚生成的随机数",而是"早就生成好的、可能已被旁观者预测过的数"。
crypto.getRandomValues() 是什么
window.crypto.getRandomValues()(浏览器)和 crypto.randomBytes()(Node.js)走的是操作系统的 CSPRNG(密码学安全伪随机数生成器):
- Linux:
/dev/urandom,内部熵池来自硬件中断、键盘 / 鼠标输入、磁盘 I/O 时序等不可预测源 - macOS:基于 Yarrow 算法,从内核熵源不断重新播种
- Windows:
BCryptGenRandom,CNG 子系统提供
CSPRNG 满足三个伪随机不具备的性质:
| 属性 | Math.random() | crypto.getRandomValues() |
|---|---|---|
| 输出分布均匀 | ✅ | ✅ |
| 知道历史无法预测未来 | ❌ | ✅ |
| 知道当前 state 无法回推历史 | ❌ | ✅ |
| 性能 | ~5ns/call | ~50–200ns/call |
10 倍性能差距是真的,但 99% 的场景下你一秒钟根本生成不到 10 万个随机数,完全不是瓶颈。任何涉及"安全"或"公平"的随机性,都应该用后者。
我们的 随机 Token 生成工具 用的就是 crypto.getRandomValues()——大段密码、API key、CSRF token 都安全。
什么场景必须用 CSPRNG
经验法则:如果这个数被攻击者预测后会带来损失,就用 CSPRNG。
- ✅ 必须用:密码 / API key / token / session ID / CSRF nonce / OAuth state / 邮箱验证码 / 短信验证码 / 密码重置链接 / encryption IV / salt
- ✅ 推荐用:抽奖 / 灰度分桶(如果用户能通过预测分桶规避实验)/ 排队顺序(如果"插队"有价值)
- 🟡 看情况:游戏物品掉率(玩家会作弊)、推荐排序(影响营收)
- ❌ 不必用:动画 jitter、UI 颜色随机化、debug 时填充测试数据
对 UUID 也是同样的逻辑:UUID v4 由 122 个随机 bit 组成。规范要求这些 bit 来自 CSPRNG——只有这样 v4 的"碰撞概率近乎不可能"才成立。uuid 包内部走的就是 crypto.getRandomValues(),我们的 UUID 生成工具 同理。
如果你用 Math.random() * 16 | 0 拼出 "UUID"(网上很多代码片段就这么干),它在密码学意义上 不是 一个合法的 UUID v4——熵不足,并且可被反推。
致命细节:modulo bias
即使你正确用了 CSPRNG,下面这种写法仍然有 bug:
// ❌ 看起来是均匀的,实际上不均匀
function randomDigit(): number {
const arr = new Uint32Array(1);
crypto.getRandomValues(arr);
return arr[0] % 10; // 0–9
}
为什么?Uint32 的范围是 [0, 2^32),共 4_294_967_296 个值。但 4_294_967_296 % 10 = 6——意味着 0、1、2、3、4、5 各对应 429_496_730 个原始值,而 6、7、8、9 各对应 429_496_729 个。差距小但是 可观察的统计偏差。
如果你的字符集长度不是 2 的幂(比如 base62 的 62、helpdesk 验证码用的 22 个无歧义字符),就会有这个问题。攻击者用大样本分析能识别出"哪些字符比理论概率多 0.001%",结合 ML 缩窄密码空间。
正确做法:拒绝采样。
function randomInRange(maxExclusive: number): number {
const arr = new Uint32Array(1);
// 拒绝采样阈值:所有 >= maxValid 的随机数都丢弃
const maxValid = Math.floor(0x100000000 / maxExclusive) * maxExclusive;
while (true) {
crypto.getRandomValues(arr);
if (arr[0] < maxValid) return arr[0] % maxExclusive;
}
}
代价:约 (2^32 / maxValid - 1) * 100% 的额外采样次数——对常见字符集(< 100)平均 < 1% 的损耗,可以忽略。
我们的 token-generator 实现里就用了同款拒绝采样:
// lib 实现节选
const maxValid = Math.floor(0x100000000 / charsetLen) * charsetLen;
while (result.length < length) {
if (cursor >= buffer.length) {
buffer = new Uint32Array(/* 一次申请多个 */);
crypto.getRandomValues(buffer);
cursor = 0;
}
const value = buffer[cursor++];
if (value < maxValid) result.push(charset[value % charsetLen]);
// 否则丢弃,下一轮
}
一次性申请大 buffer 而不是每次取 4 字节是另一个细节:getRandomValues 本身有固定调用开销,批量取能把 50 字符的 token 生成从 ~10μs 压到 < 1μs。
边界:Node.js 早期版本与 Web Crypto API 差异
Node.js 18 以下、Cloudflare Workers 等运行时对 Web Crypto API 的支持有差异:
- Node 19+:
globalThis.crypto.getRandomValues()与浏览器一致 - Node 14–18:必须
import { webcrypto } from "node:crypto"再用 - Cloudflare Workers:原生支持
crypto.getRandomValues() - Deno:原生支持
- 老浏览器(IE11、iOS Safari < 11):需要 polyfill 或 fallback——但实测这些环境的 user agent 占比已不到 0.1%,可以放心忽略
如果你必须兼容老环境,不要 fallback 到 Math.random()——这会让所有用户的安全性下降到最差环境的水平。要么直接拒绝服务(返回 503),要么用纯 JS 实现的 ChaCha20 等 stream cipher 自己接熵源。
总结
| 用途 | 用什么 | 关键点 |
|---|---|---|
| 密码、token、密钥 | crypto.getRandomValues() | 必须 + 配拒绝采样消除 modulo bias |
| UUID v4 | crypto.randomUUID() 或 uuid 包 v4 | 内部已正确处理 |
| 抽奖 / 关键业务 | crypto.getRandomValues() + 拒绝采样 | 同上 |
| 游戏动画 / debug | Math.random() | OK |
| 任何对外暴露随机数的接口 | crypto.getRandomValues() | 假设攻击者会爬你的 endpoint 反推 PRNG state |
Math.random() 不是"差一点的 crypto.getRandomValues()"——它是 完全不同的设计目标。前者优化"均匀分布 + 速度",后者优化"不可预测 + 不可回推"。两者一旦混用,安全等级就由更弱的那个决定。
延伸阅读
- Token 生成工具:基于 CSPRNG + 拒绝采样的实战实现
- UUID 生成工具:v1 / v4 / v7 三种语义的取舍
- V8 PRNG 反推 PoC(GitHub):5 个连续输出反推内部 state 的可运行 demo