How Insecure Nonce Generation with Math.random() Happens in Node.js HTTP Digest Authentication and How to Fix It
Introduction
The lib/cam.js file in an ONVIF camera control library handles HTTP Digest authentication for communicating with IP cameras over the network. At line 418, the digestAuth method on Cam.prototype generated a client nonce (cnonce) using Math.random().toString(36) fed into an MD5 hash — a pattern that looks secure at first glance but is fundamentally broken.
This vulnerability is particularly dangerous because this is a Node.js library consumed by downstream packages. Every application using this library to authenticate with security cameras was generating predictable nonces, potentially allowing an attacker on the network to forge authentication responses and gain unauthorized access to camera feeds.
The Vulnerability Explained
HTTP Digest authentication is a challenge-response protocol designed to avoid sending passwords in cleartext. When a server challenges a client, the client must respond with a hash that incorporates several values, including a client nonce (cnonce). The cnonce serves a critical purpose: it prevents chosen-plaintext attacks and replay attacks by ensuring the client contributes unpredictable randomness to the authentication exchange.
Here's the vulnerable code from lib/cam.js at line 418:
if (typeof challenge.qop === 'string' && challenge.qop === 'auth') {
const cnonceHash = crypto.createHash('md5');
cnonceHash.update(Math.random().toString(36));
cnonce = cnonceHash.digest('hex').substring(0, 8);
nc = this.updateNC();
}
Why this is broken:
-
Math.random()is not cryptographically secure. JavaScript'sMath.random()uses algorithms like xorshift128+ that are designed for speed, not security. The internal state can be reconstructed from observed outputs. -
MD5 hashing doesn't add entropy. Wrapping
Math.random()incrypto.createHash('md5')is security theater. A hash function is deterministic — if the input is predictable, the output is predictable. You cannot create entropy by hashing; you can only preserve it. -
The state space is limited.
Math.random()produces a 64-bit floating point number, but the actual entropy of the V8 PRNG state is reconstructable from as few as 3-4 observed outputs.
Attack scenario specific to this code:
An attacker positioned on the same network as a camera (common in corporate environments) observes several Digest authentication exchanges between the ONVIF client and the camera. By collecting multiple cnonce values from the authentication headers, the attacker can:
- Reconstruct the internal state of
Math.random()using known techniques (Z3 solver, lattice attacks on xorshift128+) - Predict future cnonce values
- Pre-compute valid Digest authentication responses
- Forge authentication to the camera, gaining access to video feeds, PTZ controls, or device configuration
This is especially concerning because ONVIF cameras are security devices — compromising their authentication defeats the entire purpose of the physical security system.
The Fix
The fix replaces the insecure random generation with Node.js's cryptographically secure random byte generator:
Before (vulnerable):
if (typeof challenge.qop === 'string' && challenge.qop === 'auth') {
const cnonceHash = crypto.createHash('md5');
cnonceHash.update(Math.random().toString(36));
cnonce = cnonceHash.digest('hex').substring(0, 8);
nc = this.updateNC();
}
After (fixed):
if (typeof challenge.qop === 'string' && challenge.qop === 'auth') {
cnonce = crypto.randomBytes(4).toString('hex');
nc = this.updateNC();
}
Why this fix works:
-
crypto.randomBytes(4)generates 4 bytes (32 bits) from the operating system's CSPRNG (/dev/urandomon Linux,CryptGenRandomon Windows). This output is cryptographically unpredictable. -
.toString('hex')converts the 4 random bytes to an 8-character hexadecimal string — exactly the same length as the originalsubstring(0, 8)on the MD5 hex digest. -
Behavioral equivalence is preserved. The cnonce is still an 8-character hex string, so the Digest authentication protocol exchange works identically. The only difference is the quality of randomness.
-
The code is simpler. Three lines become one. The unnecessary MD5 hash intermediate step is eliminated, making the code both more secure and more readable.
The crypto module was already imported in this file (it was being used for the MD5 hash), so no new dependencies were needed.
Prevention & Best Practices
Rule of thumb: If a value must be unpredictable to an attacker, never use Math.random().
Here's a decision framework for Node.js developers:
| Use Case | Safe API |
|---|---|
| Nonces, tokens, session IDs | crypto.randomBytes() |
| UUIDs | crypto.randomUUID() |
| Random integers in a range | crypto.randomInt(min, max) |
| Non-security shuffling/sampling | Math.random() (acceptable) |
Detection tools:
- ESLint: Use
eslint-plugin-securitywhich flagsMath.random()in security contexts - Semgrep: Rules like
javascript.lang.security.insecure-randomness.insecure-randomnesscatch this pattern - CodeQL: The
js/insecure-randomnessquery identifiesMath.random()flowing into security-sensitive operations
Security standards:
- OWASP: The Cryptographic Failures category (formerly "Sensitive Data Exposure") covers insufficient randomness
- CWE-330: Use of Insufficiently Random Values
- CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)
- NIST SP 800-90A: Recommends approved DRBG mechanisms for security applications
Key Takeaways
- Never use
Math.random()for authentication nonces — even when wrapped in a hash function like MD5, the output remains predictable because hashing cannot create entropy - The
cryptomodule is already available in Node.js — there's zero dependency cost to usingcrypto.randomBytes()instead ofMath.random() - HTTP Digest auth cnonce values are security-critical — they prevent replay attacks, so predictable cnonces undermine the entire authentication scheme
- Hashing insecure input is not a fix — the original code used
crypto.createHash('md5')which gave a false sense of security while providing none - Library vulnerabilities cascade downstream — this camera control library is used by many applications, so one insecure nonce generation affected every consumer
How Orbis AppSec Detected This
- Source:
Math.random()call atlib/cam.js:419, producing a predictable pseudo-random value - Sink: The cnonce variable used in HTTP Digest authentication response construction in
Cam.prototype.digestAuth - Missing control: No cryptographically secure random number generator (CSPRNG) was used for the security-critical nonce value
- CWE: CWE-330 (Use of Insufficiently Random Values)
- Fix: Replaced
Math.random().toString(36)piped through MD5 withcrypto.randomBytes(4).toString('hex')to generate a cryptographically unpredictable 8-character hex cnonce
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability demonstrates a common anti-pattern in JavaScript security: using Math.random() where cryptographic randomness is required, then attempting to "strengthen" it with a hash function. The hash adds complexity but not security. The fix is elegant in its simplicity — one line of crypto.randomBytes() replaces three lines of insecure code while maintaining identical protocol behavior.
For any developer working with authentication protocols, the lesson is clear: if a value's unpredictability is a security requirement, reach for your language's CSPRNG API from the start. In Node.js, that means crypto.randomBytes(), crypto.randomInt(), or crypto.randomUUID() — never Math.random().