DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Working: Math.random() Is Not Your Friend: PRNG Prediction and Timing Attacks in API Tokens

An attacker does not need your database. They need 4 consecutive password reset emails and a stopwatch.

Most token security failures come from 2 one-line mistakes. First: using Math.random() instead of a CSPRNG. Second: using == instead of a constant-time comparison. Both are invisible in code review. Both are measurable under attack.

Math.random() Is a Deterministic Function Wearing a Security Costume

V8's xorshift128+ PRNG is algebraically invertible. The information-theoretic minimum is 3 outputs. Each Math.random() call exposes 52 bits of a 128-bit state. A Z3 SAT solver can exploit this minimum. The linear algebra tool js-rng-state-recovery requires 64 to 128 consecutive outputs but operates without an optimization solver.

V8 has used xorshift128+ since version 4.9.41.0, which shipped with Chrome 49. The internal state is 128 bits and the period is 2^128-1. The V8 engineering blog states explicitly: do not use Math.random() for security-sensitive operations. The previous algorithm, MWC1616, was even weaker.

The js-rng-state-recovery tool (Lincoln-LM, GitHub) reconstructs V8 state from 64 to 128 consecutive outputs. The method uses linear algebra over GF(2). No brute force required. An alternative approach using a Z3 SAT solver, documented by Mikk Küttim, works with partial outputs. Once state is recovered, every future Math.random() output is predictable. Past outputs can also be reconstructed backward.

The attack is executable in a browser context. An attacker who controls JavaScript on a page can observe Math.random() outputs from the same V8 process. In applications that generate tokens client-side, the token is fully exposed to the attacker.

cal.com, a GitHub repository with 57,000 stars, was generating API keys with Math.random().toString(36).substring(2). The pattern is endemic in AI-generated code. The Cloud Security Alliance found Math.random() in over 40% of cases where a CSPRNG was required.

The fix is 1 line: crypto.randomBytes(32).toString('hex') in Node.js, not Math.random().

CVE-2024-40762 and CVE-2025-22150: Weak PRNG in Production at Scale

Weak PRNG in security-critical token generation is not a theoretical concern. 2 CVEs published within 24 months confirm real-world authentication bypass and request tampering in production environments.

CVE-2024-40762 (CVSS 7.1): SonicOS SSLVPN used a weak PRNG in its authentication token generator. The attack vector is network-based, requiring no privileges. The outcome is authentication bypass. Fixed in SonicOS 7.0.1-5165 and 6.5.5.1-6n.

CVE-2025-22150 (CVSS 6.8): undici used Math.random() to generate the boundary in multipart/form-data requests. Undici is the Node.js HTTP client with millions of downstream applications. An attacker with access to one endpoint could predict boundaries used in backend API calls.

CVE-2026-40975 (Spring Framework): the ${random.value} expression in Spring configuration files drew from a non-cryptographic source. Any value observed in an exposed configuration allowed prediction of subsequent random draws from the same JVM. Applications using ${random.value} for security properties were exposed.

The password reset attack has 6 steps. Register N accounts on the platform and collect N reset tokens. Feed the tokens into js-rng-state-recovery and predict the target account's next token. Request a reset for the target account and use the predicted token to set a new password. The attack requires only N public tokens. No privileged access. No cryptographic attack.

The Timing Oracle: == Returns Early and Attackers Notice

String equality in every major language terminates on the first mismatched byte. This gives an attacker a character-by-character oracle, reconstructible through response time statistics.

CVE-2026-59276: Spring Security used standard string comparison in DigestAuthenticationFilter, KeyBasedPersistenceTokenService, and InMemoryOAuth2AuthorizationService, among other components. The timing oracle allows reconstruction of valid digest and persistent tokens. Spring Security is the most widely deployed Java security framework in production. BalloonHashingPassword4jPasswordEncoder and Pbkdf2Password4jPasswordEncoder were also affected.

HackerOne #240958 (Yelp Firefly): verify_access_token() used Python's == operator to compare HMAC values. Byte-by-byte termination was measurable. The report was publicly disclosed with a proof of concept. The fix was replacing == with hmac.compare_digest().

At network latency of 1ms ± 0.1ms, 10,000 requests per character position achieve p < 0.001 discrimination. A 32-character token is recoverable in roughly 320,000 requests. 11 public HackerOne reports cite early-exit string comparison as root cause. The fix is always the same: one function swap.

The attack does not require microsecond precision. Statistical discrimination between "correct byte" and "incorrect byte" is achievable with ordinary network latencies. Constant-time comparison eliminates this channel entirely.

The Correct Primitive Per Language Is a One-Line Swap

The fix for both vulnerability classes is 1 line change in every major language. The wrong default is simply what each language makes easiest to reach.

CSPRNG generation:

// Node.js
const token = crypto.randomBytes(32).toString('hex');
// never: Math.random()
Enter fullscreen mode Exit fullscreen mode
# Python
import secrets
token = secrets.token_hex(32)
# never: random.random() or random.randbytes()
Enter fullscreen mode Exit fullscreen mode
// Java
SecureRandom sr = new SecureRandom();
byte[] token = new byte[32];
sr.nextBytes(token);
// never: new Random()
Enter fullscreen mode Exit fullscreen mode
// PHP
$token = bin2hex(random_bytes(32));
// never: rand() or mt_rand()
Enter fullscreen mode Exit fullscreen mode
// Go
import "crypto/rand"
token := make([]byte, 32)
rand.Read(token)
// never: math/rand
Enter fullscreen mode Exit fullscreen mode

Constant-time comparison:

// Node.js
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
Enter fullscreen mode Exit fullscreen mode
# Python
hmac.compare_digest(a, b)
Enter fullscreen mode Exit fullscreen mode
// Java
MessageDigest.isEqual(a.getBytes(), b.getBytes())
Enter fullscreen mode Exit fullscreen mode
// PHP
hash_equals($a, $b)
Enter fullscreen mode Exit fullscreen mode
// Go
subtle.ConstantTimeCompare([]byte(a), []byte(b))
Enter fullscreen mode Exit fullscreen mode

Minimum entropy: 128 bits for externally-facing tokens. Long-lived tokens (API keys, OAuth client secrets) require 256 bits. Apply constant-time comparison to every security-sensitive verification: password reset, CSRF validation, API key lookup.

One Cognitive Error, Two Attack Surfaces

Both vulnerabilities arise from the same mistake. Developers apply general-purpose tools to cryptographic contracts without reading the threat model. Standard code review cannot detect the difference.

Math.random() and crypto.randomBytes() are syntactically identical in a diff. Linters without crypto-aware rules miss the distinction. == and timingSafeEqual produce the same boolean output. Both are visually indistinguishable in a pull request.

Cryptographic Failures sit at #2 in the OWASP Top 10. Insufficient randomness is explicitly enumerated under CWE-330. Detection requires static analysis rules (eslint-plugin-security no-insecure-random, Semgrep rule crypto.timing-unsafe-compare) or manual audit of all token generation and comparison paths.

Audit your API for both classes in sequence:

# Token generation
grep -r 'Math\.random\|Math/rand\|import random' ./src

# Token comparison
grep -r '== token\|=== token\|\.equals(tok\|str\.equals' ./src
Enter fullscreen mode Exit fullscreen mode

Static analysis catches the pattern name. It does not measure the actual entropy of tokens already in production. Tokens from Math.random() pass basic format checks (length, character set) because the statistical bias is subtle. A uniformity test over 1,000+ observed tokens distinguishes PRNG output from CSPRNG output at p < 0.01. For APIs generating public tokens at scale, this test is practical to run against your own production data.

The mistakes are findable in under an hour. Fixing each is 1 line. The CVE record shows they persist in production for years when left unfound.

Top comments (0)