返回文章列表

随机性陷阱: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(伪随机数生成器)。它有两个核心特性:

  1. 确定性:给定相同的内部 state,输出序列完全可预测
  2. 可反推:观察到足够多的连续输出就能反推 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 v4crypto.randomUUID()uuid 包 v4内部已正确处理
抽奖 / 关键业务crypto.getRandomValues() + 拒绝采样同上
游戏动画 / debugMath.random()OK
任何对外暴露随机数的接口crypto.getRandomValues()假设攻击者会爬你的 endpoint 反推 PRNG state

Math.random() 不是"差一点的 crypto.getRandomValues()"——它是 完全不同的设计目标。前者优化"均匀分布 + 速度",后者优化"不可预测 + 不可回推"。两者一旦混用,安全等级就由更弱的那个决定。

延伸阅读