Rolling the Dice on Security: The Pitfalls of Using Math.random() in JavaScript

Esteve segura
7 min readApr 2, 2024

--

Understanding Randomness

Randomness implies an absence of predictability and discernable patterns in data or events. In technological contexts, it safeguards information, ensuring unpredictability in cryptographic keys and various computing processes. However, reliable randomness is paramount to preserve security, especially in delicate data handling.

The forthcoming sections will scrutinize the efficacy and reliability of JavaScript’s Math.random() in maintaining the armor of true randomness and, consequently, data security.

Chaos and Randomness: Coin Tossing as a Paradigm

Tossing a coin into the air exemplifies a chaotic system, inherently dependent on numerous factors, such as:

  • The initial force propelling the coin
  • Launch angle
  • The resistance presented by wind
  • Launch height
  • Landing surface

If you’re acquainted with the initial conditions of this chaotic system, prediction becomes markedly more accurate regarding which side the coin will land on, given that chaotic systems, while complex, are deterministically modelable.

Randomness, then, essentially mirrors our ignorance toward the initial values of a chaotic system, enveloping our predictive capacities in a veil of uncertainty and unknowability, thereby materializing as unpredictability in outcomes.

The Intricacies of Computer-Generated Randomness

The concept of computers, inherently deterministic devices, generating random numbers poses a fascinating paradox, blurring the lines between their predictable operations and the enigma of randomness.

Enter the world of pseudo-random number generators (PRNGs), algorithms that leverage a starting point, referred to as a “seed,” to produce a sequence of numbers that approximates true randomness. The seed serves as the initial input that determines the subsequent sequence of generated numbers, albeit in a way that is deliberately obscured to appear random. Seeds can originate from various sources, such as:

  • Computer’s internal temperature
  • Current date and time
  • Mouse movements or keyboard presses
  • … and other system or environmental variables.

This seemingly erratic sequence is, in fact, entirely reproducible if the initial seed is known, laying bare its deterministic underbelly and distinguishing it from true randomness.

Pseudo-random numbers are apt for non-sensitive use-cases like choosing random colors for animations or generating non-crucial test data, where genuine unpredictability isn’t paramount. Conversely, in cryptographic applications, where unequivocal unpredictability is crucial due to elevated security stakes, utilizing pseudo-random numbers introduces a significant and unacceptable risk.

Unveiling the Mechanism Behind Math.random() in JavaScript

One of the omnipresent functions in the JavaScript (JS) landscape is Math.random(), standing out as the go-to utility for generating random numbers in numerous JS applications. Its widespread use and accessibility have solidified its status as the popular choice among developers navigating the realms of randomness within their applications.

Peering into the core of JavaScript, the V8 engine emerges predominantly, driving the language’s execution in environments like Node.js and the Chrome browser. It’s worthwhile noting that the V8 engine isn’t merely a performer of JS code but also a translator, transmuting high-level JS into machine code that computers can natively understand and execute.

Venturing into the V8 blog provides a window into the intricacies of how it implements randomness, revealing the utilization of the xorshift128+ algorithm. The xorshift128+ algorithm is a member of the xorshift family of pseudo-random number generators, characterized by their use of bitwise XOR and bit-shift operations. Notably, xorshift generators are acclaimed for their efficiency and strong statistical properties, offering a performant means of generating pseudo-random numbers.

This is v8’s implementation of xorshift128+:

  static inline void XorShift128(uint64_t* state0, uint64_t* state1) {
uint64_t s1 = *state0;
uint64_t s0 = *state1;
*state0 = s0;
s1 ^= s1 << 23;
s1 ^= s1 >> 17;
s1 ^= s0;
s1 ^= s0 >> 26;
*state1 = s1;
}

xorshift128+, particularly, employs bit-wise shifts and XOR operations on 128-bit state, providing a high-period and fast computational pathway for generating sequences of numbers that, on the surface, appear random. While it may present commendable statistical properties and is suitable for various general-use scenarios, it's crucial to underscore the "pseudo" in its designation as a pseudo-random number generator. The numbers generated by xorshift128+, despite their statistical reliability in several contexts, aren’t insulated from predictability, especially if the initial state (or seed) is known or can be deduced.

Hence, while Math.random() and, by extension, xorshift128+ might serve well in numerous non-critical applications, it's crucial to ponder on their limitations and applicability, especially when traversing the domains of cryptographic security and other high-stakes computational arenas.

No need to understand xorshift128+ to predict it — Introducing Z3 and the power of SMT solvers

While xorshift128+ might be proficient at generating seemingly arbitrary number sequences, it’s critical to understand that it isn’t impervious to prediction or reverse engineering, particularly when we leverage powerful tools like Z3, an SMT (Satisfiability Modulo Theories) Solver.

Z3, developed by Microsoft Research, can be envisioned as a “black box” that is not only proficient at solving mathematical problems but also modeling them, especially those embedded within computational logic and arithmetic. This robust solver doesn’t require you to understand the algorithm it’s dissecting (like xorshift128+) to predict its outcomes or validate its possibilities. The user’s task boils down to three key steps, and Z3 handles the intricate mathematical heavy lifting:

  1. Defining Unknown Variables: Identify and declare the variables within the problem that you don’t have values for. In the context of a pseudo-random number generator, these could be seeds or state variables that the algorithm uses internally.
  2. Establishing Constraints: Lay down the limitations or boundaries within which the solution should reside. Constraints can include mathematical relations among variables or defined boundaries for possible values.
  3. Proposing an Equation to Solve: Provide Z3 with an equation or a system of equations to decipher. Given the unknown variables and constraints, Z3 will seek a solution that satisfies all provided conditions, effectively “solving” the problem.

Let’s look at a practical example:

import z3
s = z3.Solver()

x,y = z3.Ints('x, y') # 1. Defining Unknown Variables

s.add(x > 0, y > 0) # 2. Establishing Constraints
s.add(2*x + 3*y == 19) # 3. Proposing an Equation to Solve


print(s.model()) # >>> [x, =8, y = 1]

Cracking Random Numbers with a SAT Solver

Z3 can be employed to predict numbers generated by pseudo-random number generators. The essence of this approach lies in understanding that while these numbers appear random, their generation process follows a deterministic algorithm, making prediction possible under certain conditions.

Here’s a simplified explanation of how the code works:

  1. Initialization: The code begins by importing necessary libraries and defining a sequence of numbers previously generated by Math.random(). These numbers serve as our target for prediction.
  2. Setting up the SAT Solver: We initialize a Z3 solver instance, which will be used to find the internal state of the pseudo-random number generator based on the provided sequence.
  3. Defining Variables: Two 64-bit variables (se_state0 and se_state1) are declared, representing the internal state of the xorshift128+ algorithm, which is presumed to underlie Math.random().
  4. Reconstructing the Algorithm: The core loop of the code mimics the xorshift128+ algorithm’s operations in reverse, applying bitwise transformations to ‘guess’ the original state from the given random numbers.
  5. Constraints Setup: For each number in the sequence, the code converts it back from a floating-point representation to its underlying binary form. It then extracts the mantissa and sets up a constraint that the mantissa should equal the right-shifted state variable, adjusted for the floating-point representation.
  6. Solving: The SAT solver attempts to find values for the state variables that satisfy all constraints — i.e., that could have produced the observed sequence of numbers.
  7. Prediction: If successful, the solver returns the internal state of the generator. The code then uses this state to predict the next number in the sequence.
#!/usr/bin/python3
import z3
import struct
import sys

# Introduce here 5 numbers generated by math.random() and run the script, this will guess the numbers
# Array.from(Array(5), Math.random)
sequence = [
0.8069960111439729, 0.5339338821641808, 0.9337576223518915,
0.9606577839163146, 0.9878893969604046
]

sequence = sequence[::-1]

solver = z3.Solver()

se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)

for i in range(len(sequence)):

se_s1 = se_state0
se_s0 = se_state1
se_state0 = se_s0
se_s1 ^= se_s1 << 23
se_s1 ^= z3.LShR(se_s1, 17)
se_s1 ^= se_s0
se_s1 ^= z3.LShR(se_s0, 26)
se_state1 = se_s1

float_64 = struct.pack("d", sequence[i] + 1)
u_long_long_64 = struct.unpack("<Q", float_64)[0]

mantissa = u_long_long_64 & ((1 << 52) - 1)
solver.add(int(mantissa) == z3.LShR(se_state0, 12))

if solver.check() == z3.sat:
model = solver.model()

states = {}
for state in model.decls():
states[state.__str__()] = model[state]

state0 = states["se_state0"].as_long()

u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000
float_64 = struct.pack("<Q", u_long_long_64)
next_sequence = struct.unpack("d", float_64)[0]
next_sequence -= 1

print(next_sequence)

This procedure exemplifies the power of SAT solvers in penetrating the veil of pseudo-randomness. By understanding the deterministic nature of such generators and applying logical constraints, it’s possible to unveil their future outputs, challenging their suitability for security-critical applications.

Stepping Up Security with crypto libraries

For enhanced security in JavaScript applications, especially where unpredictability is crucial, crypto.getRandomValues() is the go-to method. This function, part of the Web Cryptography API, provides cryptographically strong random numbers, making it far superior to Math.random() for tasks like cryptographic operations and secure password generation.

To ensure and monitor the use of secure randomness, developers can implement a simple check in their JavaScript environment (ex: Web Developer Tools):

const originalGetRandomValues = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = function() {
console.log('crypto.getRandomValues() was called');
return originalGetRandomValues.apply(crypto, arguments);
};

// console.log output => crypto.getRandomValues() was calles

This adjustment helps in verifying that crypto.getRandomValues() is used whenever secure random numbers are needed, reinforcing the application's security posture.

We can see an example of the use of this little tool in the Avast password generator:

Avast random password generator

Conclusion

In the realm of software development, the quality of randomness can significantly impact security and functionality. Embracing truly random sources like crypto.getRandomValues() is essential for applications where security is paramount. This approach ensures that the unpredictability required for secure operations is maintained, safeguarding against vulnerabilities that stem from predictable patterns in pseudo-random numbers.

--

--

Esteve segura
Esteve segura

Written by Esteve segura

Hi there! I'm Esteve Segura, Software Engineer based in Barcelona. I work at Voicemod as Tech Lead. I love swimming and cycling.

No responses yet