Analyze, reproduce, reverse engineer and brute force the Math.random() PRNGs
of various browsers — in Rust. Coverage target: MSIE 6 (JScript) through
current Chrome / Firefox / Safari.
Math.random() is not standardized beyond "a double in [0, 1)", and every
engine — and every era of each engine — makes its own choices. For the current
engines:
| Engine | Browsers | Core PRNG | Double conversion | Serving order |
|---|---|---|---|---|
| V8 | Chrome, Edge, Opera, Brave, Node | xorshift128+ | bitcast((s0>>12)|exp) - 1 (52-bit) |
reversed cache of 64 |
| SpiderMonkey | Firefox | xorshift128+ | ((s0+s1) & (2⁵³-1)) * 2⁻⁵³ (low 53) |
in order |
| JavaScriptCore | Safari ≤8, iOS | GameRand (2×u32) | m_high / 2³² (32-bit) |
in order |
| Dart | Flutter apps (VM/AOT/wasm) | MWC A=0xffffda61 (u64) |
(bits26·2²⁷+bits27) / 2⁵³ (53-bit) |
in order |
| Hermes | React Native apps | std::minstd_rand LCG → std::mt19937_64 (2023) |
uniform_real_distribution (1/R² → 2⁻⁵³) |
in order |
The conversion is the part that distinguishes them: V8 reads s0 directly (so
its recovery is GF(2)-linear), while SpiderMonkey/JSC sum both lanes (s0+s1,
nonlinear over GF(2) → solved with z3). Older engines used entirely different
generators — see the full table below, which is the authoritative status.
Flutter doesn't fit the browser rows: a Flutter app draws from Dart's
dart:math Random, a Multiply-With-Carry generator (A=0xffffda61), not a
browser Math.random(). It's the same on every Flutter version and native
platform; only the web target differs (seedless Random() there delegates to
the host browser's Math.random()). Full analysis:
docs/dart-flutter-random.md.
Hermes (React Native) also stands apart: it never wrote its own PRNG, just
seeds a C++ stdlib engine and converts with std::uniform_real_distribution.
That engine changed exactly once — std::minstd_rand (a 31-bit MINSTD LCG,
2019–2023) → std::mt19937_64 (Nov 2023, PR #1175, fixing a 32-bit-seed
collision bug). Era 1 lands on a unique non-dyadic 1/R² grid and its whole
31-bit state falls out of a single output (floor(value·R)), making it the most
trivially crackable engine here; Era 2 is reproducible but its MT state needs a
bulk GF(2) solve. Full history: docs/hermes-math-random.md.
One call identifies the engine/era and returns a predictor that extends the
stream both ways (src/predict.rs):
let p = browser_rnd::predict::recover(&values).unwrap(); // a few consecutive Math.random() outputs
println!("{} — {}", p.id().engine, p.id().algorithm); // e.g. "V8 — xorshift128+ (s0>>12, reversed cache)"
let after = p.forward(10); // the next 10 values the page will produce
let before = p.backward(10); // the 10 values it produced before the capturepredict::identify(&values) returns just the Identification (engine, algorithm,
browsers, grid, whether time-seeded, and predictable). Presto/Opera is identified
but predictable == false (SNOW 2.0 CSPRNG — no state to recover). From the CLI:
$ browser_rnd predict samples/ie/ie6-winxp.txt -n 3
engine: JScript/Chakra
algorithm: drand48 48-bit LCG (27+27 → 2⁻⁵⁴)
browsers: Internet Explorer 6–11
...
3 values BEFORE / AFTER the capture: ...
Validated by held-out tests (tests/predict.rs): hand the predictor a middle slice
of a real capture and it reconstructs the surrounding values it never saw, forward
and backward, for every engine family.
- Capture. Open
collector/index.htmlin the target browser. It runs in ES3 so it works all the way back to MSIE 6. Copy the textarea. - Save. Drop the capture into
samples/(organised by family, e.g.samples/ie/,samples/v8/). These are committed as regression fixtures. - Analyze.
cargo run -- analyze samples/ie/ie6-winxp.txt— fingerprints the engine from UA + value structure (grid/resolution). - Recover. Confirmed algorithms live in
src/engines/with arecoverthat reproduces the full capture;cargo testexercises them oversamples/. - Reverse new ones.
src/bin/relab.rsis the scratch harness for probing an unknown capture (cargo run --bin relab -- <experiment> <sample>); confirmed findings get promoted into an engine module with a recovery test.
collector/index.html ES3 capture page (do not modernize — must run on IE6)
src/prng/ raw generators: xorshift128+ (invertible), MWC, LCG
src/engines/ per-browser models (generate + recover):
v8, v8_legacy (MWC eras), v8_libc (Chrome 1),
spidermonkey, spidermonkey_legacy (drand48),
jscript (IE6-11), jsc (Safari GameRand), presto,
dart (Flutter — Dart Random MWC),
hermes (React Native — minstd LCG / mt19937_64)
src/gf2.rs GF(2) linear solver (modern V8 recovery)
src/sample.rs parse captured textarea dumps
src/analyze.rs engine fingerprinting (grid / mantissa resolution + UA)
src/bin/relab.rs reverse-engineering scratch harness (incl. z3 experiments)
samples/ committed real captures used as test fixtures
tests/fixtures.rs well-formedness checks over every capture
tests/recover.rs end-to-end: recover state → reproduce the full sequence
z3 (SMT solver) is an optional external tool; only the SpiderMonkey recovery and
some relab experiments use it, and tests that need it skip cleanly if absent.
Every "cracked" entry is validated by reproducing a real capture's full 4096-value
sequence (tests/recover.rs), not recalled from memory. The grid column is the
double-conversion denominator — the first thing the fingerprint pins down.
| Engine / era | Browsers (samples) | Grid | Algorithm | Recovery | Status |
|---|---|---|---|---|---|
| old SpiderMonkey | Firefox 1, 3 | 2⁻⁵³ | drand48 48-bit LCG, (next26<<27)+next27 |
2²² brute | ✅ cracked |
| V8 MWC era 1 | Chrome 20/30, Opera 16 | 2⁻³² | MWC1616, (s0<<14)+(s1&0x3FFFF), 18273/36969 |
lane brute | ✅ cracked |
| V8 MWC era 2/3 | Chrome 10, Opera 22 | 2⁻³² | MWC1616, (s0<<16)|(s1&0xFFFF), 18273/18030/36969 |
direct lane carry | ✅ cracked |
| modern V8 | Chrome 77/100, Edge 100, Opera 70/75, Brave | 2⁻⁵² | xorshift128+, s0>>12, reversed cache of 64 |
GF(2) + offset search | ✅ cracked |
| modern SpiderMonkey | Firefox 100, Mypal 68 | 2⁻⁵³ | xorshift128+ (23,17,26), low 53 bits of s0+s1 |
z3 SMT | ✅ cracked |
| IE (JScript + Chakra) | IE 6/7/8/9/10/11 | 2⁻⁵⁴ | drand48 48-bit LCG, 27+27 → 2⁵⁴ | 2²¹ brute | ✅ cracked |
| JSC (Safari ≤8) | Safari 5.1.7 | 2⁻³² | GameRand (Ian Bullard), 2×u32 | closed-form | ✅ cracked |
| Presto | Opera 10 | 2⁻⁵³ | SNOW 2.0 CSPRNG + entropy reseeding | infeasible by design | 🔒 unpredictable |
| oldest V8 | Chrome 1 (2008, Win) | 2⁻³⁰ | MSVCRT rand() × 2, hi·2¹⁵+lo |
2¹⁷ brute | ✅ cracked |
| Dart / Flutter | Flutter VM/AOT/wasm (real captures) | 2⁻⁵³ | MWC A=0xffffda61, low 26+27 of 2 steps |
2¹¹ brute (closed-form) | ✅ cracked |
| Hermes era 1 (LCG) | React Native ≤ ~RN 0.73 (2019–2023) | 1/R² (non-dyadic) | std::minstd_rand LCG (48271, 2³¹−1), 2 draws/call |
O(1) floor(v·R) |
✅ cracked |
| Hermes era 2 (MT) | React Native ~RN 0.74+ (2023-11→) | 2⁻⁵³-ish | std::mt19937_64, top 53 bits of 1 output |
GF(2), ~625 outputs | 🔁 reproduced |
Notable findings:
- IE 6–11 all share one generator: drand48 (
0x5DEECE66D,+11), two steps/call,(hi27·2²⁷ + hi27)/2⁵⁴— same constants as old SpiderMonkey, just 54-bit. Genuine MSIE6/XP. (54-bit means the low bit of any value ≥ 0.5 is rounded by f64, so recovery anchors on a value < 0.5.) - Modern Firefox uses the LOW 53 bits of
s0+s1, not>>11. The addition is nonlinear over GF(2), so recovery uses the z3 SMT solver. - V8 has many generator eras — the full source-verified list (exact
commit/version/date) is in
docs/v8-math-random.md. In short: Chrome 1 (V8 ≤1.2.7) had no custom PRNG —result = (hi + lo/(RAND_MAX+1))/(RAND_MAX+1)from two hostrand()/random()calls (on Windows, MSVCRT's 15-bit LCG →hi·2¹⁵+lo, the FIRST call is the low part). Then MWC1616 in four sub-forms (<<16/36969-high →<<14→<<16/18273-high → 18030 "Marsaglia-3D" fix), then xorshift128+ from Chrome 49: first the nonlinears0+s1sum (z3); the linear single-words0>>12from V8 7.1 / Chrome ~71 (GF(2));>>11 / 2⁵³from V8 13.5; and thes0+s1add resurrected in V8 14.4 (Nov 2025), nonlinear again. - Presto (Opera) is the lone holdout — it deliberately uses a SNOW 2.0
CSPRNG continuously reseeded with entropy, so its
Math.random()is genuinely unpredictable (no fixed state to recover). Every other engine here is breakable. - Flutter/Dart is one unchanging MWC (
A=0xffffda61, since ~Dart 0.8, 2013 — no eras). BecauseA = 2³² − 0x259f, one step islo' = (hi − 9631·lo) mod 2³², so two consecutivelowords pin the 64-bit state → a 2¹¹ brute over the truncated high bits recovers it with no z3. Seed too (Random(seed)runs a Thomas-Wangmix64+ 4 warm-up cranks, all invertible). The one platform quirk: seedless Flutter web uses the browser'sMath.random(), not the MWC. Seedocs/dart-flutter-random.md. - Hermes (React Native) is two eras of a stdlib engine, not a custom PRNG.
Math.randomjust wrapsstd::uniform_real_distributionover a stdlib engine that changed once (2023-11, PR #1175):std::minstd_rand→std::mt19937_64. Era 1's MINSTD LCG folds 2 draws into((g0−1)+(g1−1)·R)/R²(R=2³¹−2), so it sits on a unique non-dyadic grid andfloor(v·R)hands you the whole 31-bit state from one value — the cheapest crack in this repo. Era 2 is a 64-bit Mersenne Twister (out/2⁶⁴); reproducible, but recovery needs a bulk GF(2) solve. Both eras cross-validated bit-for-bit against libstdc++/libc++std::uniform_real_distribution. Seedocs/hermes-math-random.md.
docs/v8-math-random.mdis authoritative for which generator ships in which V8 version (reconstructed from the V8 git tree with exact commit/version/date). The map below is the sample-sweep view — useful for the Chrome-version→algo mapping and the repo-specific recovery notes, but where it disagrees on a boundary, the doc wins.
The relab id classifier over the full sweep dates every switch:
Chrome: v1 libc rand()x2 (2⁻³⁰)
v10 MWC <<16, hi=36969 (the original V8 1.2 form)
v20–32 MWC <<14, hi=18273 (era 1)
v33–38 MWC <<16, hi=18273 (era 2)
v40–46 MWC <<16, hi=18030 (era 3, the "Marsaglia-3D" fix)
v48 MWC 18030 + %_ConstructDouble conv (4.9 "Stage A") ✅ z3
v49–50 xorshift128+ in-order, (s0+s1)&mask52 (4.9 "Stage B") ✅ z3
v52–53 xorshift128+, (s0+s1)&mask52, batch of 62 served
REVERSED (V8 5.1–5.3) ✅ z3
v77+ xorshift128+ reversed cache, s0>>12 (V8 ~7.0+ stable) ✅ GF(2)
Firefox: v1–26 drand48 → v50+ xorshift128+ (switch at FF48, late 2015)
Opera: v10–11.60 Presto/SNOW2 → v16–22 MWC → v40+ xorshift128+
The V8 4.9 transitional band is fully cracked, and it turned out to be two
changes back-to-back (per the source): Stage A (Chrome 48) was a conversion-only
refactor — same MWC, but the double is mantissa-stuffed via %_ConstructDouble
((r0&0xFFFFF)<<32 | (r1&0xFFF00000), grid 2⁻³²). Stage B (Chrome 49–~55) is the
real rewrite: xorshift128+ served in order with ToDouble = (s0+s1)&mantissaMask
(low 52 bits of the sum — nonlinear, so z3). Only later (V8 7.1 / Chrome ~71,
per the source) did V8 switch to the single-word s0>>12 conversion — the stable
linear form that recovers with plain GF(2).
There's a third xorshift variant in between: V8 5.1–5.3 (Chrome 52–53, Opera
38–40, ~mid-2016). It keeps the Stage-B (s0+s1)&mask52 conversion but reverses
the cache serving order: each batch fills slots 2..63 forward (62 outputs) and is
served top-down (slot 63 first). So within each batch of 62, observed order is the
reverse of generation order. Recovery de-reverses a batch window, solves the in-order
Stage-B system with z3, and searches the batch offset. The single-word s0>>12
conversion arrives at V8 7.1 / Chrome ~71 (the "drop the s0+s1 add" refactor,
ac66c97cfdd) — that's the stable form v8::recover handles with plain GF(2). The
xorshift shift triple (23,17,26) is constant from 4.9 onward.
Full source-verified timeline:
docs/v8-math-random.mdreconstructs every era from the V8 git tree with exact commit/version/date and code. It pins two boundaries the sample sweep could only interpolate — the<<14combine began at 3.4.9 (not 3.14) and the linears0>>12form at 7.1 / Chrome ~71 (not 77) — and records a 4-day 2011 dev-only 4-lane MWC (multipliers 23208/27753, never shipped). New open target: V8 14.4 (0596ead5b04, Nov 2025, ≈ Chrome 144) resurrected thes0+s1add, so the newest Chrome is nonlinear again (z3, like modern Firefox) and outside the GF(2) path.The MWC multiplier
30903has never been used by V8 in any version — its only shipped MWC multipliers are36969/18273/18030(verify withgit grep 30903in the V8 tree: zero RNG hits). It gets wrongly attached to V8 4.x because the V8 blog's illustrative MWC1616 snippet prints30903, whereas the shipped code used36969. Lesson: trust the engine's git tree for constants, not vendor blog snippets. Details indocs/v8-math-random.md.
The generators' state recovers from outputs regardless of seeding. Recovering
the original seed (everything from page load) is a separate question, and we
checked it against each capture's epoch:
- Not recoverable from wall-clock time, but IE is timer-seeded. Every engine's
seed involves time, just not wall-clock: old Firefox XORs
PRMJ_Now()µs with/dev/urandom/rand_s; Chrome 1'ssrandis notsrand(time())(a tight ±600s brute over 2M steps finds nothing). So the capture epoch alone can't brute the seed. - IE6 seed =
(RDTSC << 13) | 0x6F— proven with two captures from the same machine 5.735 s apart (samples/ie/ie6-twice-winxp.txt,relab seedpair): the low 13 bits are a fixed constant (0x6F, identical across both) and the high 35 bits advanced by 21.85 billion → 3.810 GHz, i.e. the CPU cycle counter. So within a boot session the seed is a high-resolution timer: knowing one seed + the elapsed time predicts the next to within timing jitter (a same-session brute of the low unpredictable RDTSC bits; the boot offset blocks absolute wall-clock recovery). - Chrome 1 is wall-clock-ms seeded —
srand((unsigned)TimeCurrentMillis())once at startup. On Windows V8'srandom()is literally arand()shim (int random(){return rand();}), which is why this project's MSVCRT-LCG model reproduces chrome1 exactly. Confirmed: stepping the recovered rand state back lands on values just underepoch_ms & 0xFFFFFFFF. But the CRT stream is process-global, so a mid-session capture has a large unknown warmup (~20k prior Math.random here) and the(startup_ms, warmup_k)tradeoff leaves many consistent candidates — a unique seed needs a capture taken near startup (small warmup). - The V8 seed-quality cliff is at V8 3.24 (late 2013). Before it, default/standalone
V8 seeds from
srandom(TimeCurrentMillis())→random()(POSIX) orsrand(time)→rand()shim (Windows): ms granularity, time-predictable. From 3.24 on,Math.randomseeds come from a per-isolate RNG (/dev/urandomon POSIX,rand_s()/RtlGenRandom CSPRNG on Windows), MurmurHash3-whitened — so 4.9–5.3 streams are not time- reconstructable on any platform, only state-recoverable from outputs. (The time-only Windows weak fallback that briefly existed in 2013 never shipped forMath.random— it predated 3.24 wiring the isolate RNG into it.) In-browser, the cliff is earlier: ~Chrome 15 (July 2011), when Blink first installed an OS-CSPRNG entropy source viaV8::SetEntropySource(cryptographicallyRandomValues→ todaybase::RandBytes, unchanged randomness through years of plumbing churn). So Chrome ≥ 15 is always CSPRNG-seeded; only Chrome < 15 (Chrome 1, andchrome10/March-2011 here) bottoms out atsrand(TimeCurrentMillis)— same time-seed class, same process-global warmup caveat as Chrome 1. - Old Firefox (drand48) has its own seed-quality cliff at FF24/25. The LCG never
changed, only the seed: FF14–20
(PRMJ_Now()/1000) ^ cx ^ cx->next(ms + heap pointers); FF21–~24(PRMJ_Now()_µs << 8) ^ rngNonce++(µs + counter, predictable by design); FF25–34/dev/urandom/rand_s()(time only as fallback). Empirically:firefox24/firefox26show no time match → Era 3 (urandom), confirming the cliff;firefox1/firefox3(pre-FF14 Era 0) have states sharing their top 24 bits across two different machines/timezones captured ~9 min apart in absolute time — a chance of ~6e-8, so those are time-seeded (the exact Era-0 formula isn't one of the FF14+ ones and needs the older source). So drand48 streams from FF ≤ ~23 are seed-predictable; FF25+ only state-recoverable from outputs. - Safari GameRand has a real 32-bit seed weakness. Its entire 64-bit state
derives from one 32-bit seed (
m_low = seed ^ 0x49616E42,m_high = seed), sohigh ^ low == 0x49616E42exactly at seed time.jsc::recover_seedsteps the state backward to that invariant and recovers the exact per-page seed (validated onsafari5.1.7:0x5C99A462, 0 draws of warmup). 32 bits of entropy means the whole stream is brute-forceable even without observing outputs.
- xorshift128+ / MWC / LCG / GameRand generators (forward + recover)
- Structural fingerprinting (grid) + UA prior; ES3 collector (MSIE6 → modern)
- GF(2) linear solver (modern V8); z3 SMT backend (modern SpiderMonkey)
-
src/bin/relab.rsreverse-engineering harness (z3 experiments) - Recovery for every engine except Presto (which is a CSPRNG — not breakable)
- A Safari capture to validate the GameRand model + pin the modern-JSC extraction
- Optional: transition-boundary captures (Chrome 49/39, Firefox 49, legacy Edge)