Introduction
The src/security/WSSecurity.ts file is responsible for generating WS-Security headers that authenticate SOAP requests — a job that demands cryptographic rigor. But buried inside the toXML() method at line 88 was a subtle, critical flaw: the nonce used for UsernameToken PasswordDigest authentication was derived from Math.random(), a pseudo-random number generator that has no business anywhere near a security primitive.
// Vulnerable code — before the fix
const nHash = crypto.createHash('sha1');
nHash.update(created + Math.random());
nonce = nHash.digest('base64');
At first glance, this looks reasonable — it uses crypto.createHash, after all. But the entropy source feeding that hash is Math.random(), which is explicitly documented by every major JavaScript runtime as not suitable for cryptographic use. The SHA1 wrapper doesn't rescue it; it just obscures the weakness.
This matters because WSSecurity.ts is part of a Node.js SOAP library used by downstream applications. Every service that relies on WS-Security PasswordDigest authentication to protect its endpoints inherited this flaw.
The Vulnerability Explained
What Is a WS-Security Nonce?
WS-Security UsernameToken authentication with PasswordDigest works like this:
- The client generates a random nonce (a one-time value).
- The client computes:
PasswordDigest = Base64(SHA1(nonce + created + password)) - The server verifies the digest and checks that the nonce has not been seen before.
The nonce's entire purpose is replay prevention. If an attacker captures a valid SOAP request, they cannot reuse it because the server tracks seen nonces. But this protection collapses entirely if the nonce is predictable.
Why Math.random() Breaks This
Math.random() in V8 (Node.js's JavaScript engine) uses an internal PRNG algorithm (xorshift128+). While it produces values that look random, the state space is small enough that an observer who collects 10–20 nonce values can reconstruct the generator's internal state using statistical analysis — a well-documented attack against V8's PRNG.
Here is the exact vulnerable block from WSSecurity.ts (lines 84–88 before the fix):
if (this._hasNonce || this._passwordType !== 'PasswordText') {
// nonce = base64 ( sha1 ( created + random ) )
const nHash = crypto.createHash('sha1');
nHash.update(created + Math.random());
nonce = nHash.digest('base64');
}
The comment even explains the intent: sha1(created + random). But Math.random() produces a 64-bit floating-point number from a deterministic state machine. Hashing it with SHA1 does not increase entropy — it just changes the representation. An attacker who knows the structure of the input can still enumerate the PRNG state space.
Concrete Attack Scenario
- An attacker intercepts 15–20 SOAP requests to a WS-Security protected endpoint.
- They extract the
<wsse:Nonce>values from the<wsse:UsernameToken>headers. - Using a V8 PRNG state-recovery tool (several are publicly available), they reconstruct the internal xorshift128+ state.
- They predict the next nonce value the client will generate.
- They craft a forged SOAP request using the predicted nonce, the observed
<wsu:Created>timestamp pattern, and a known password (or replay a captured digest). - The server accepts the forged request because the nonce and digest are structurally valid.
This is not a theoretical attack. Tools for recovering V8 Math.random() state from observed outputs are documented in security research and available as open-source utilities.
Real-World Impact
Because this is a library (not an application), the blast radius extends to every downstream service using this package with PasswordDigest authentication:
- Authentication bypass: Forged requests can impersonate legitimate clients.
- Replay attacks: Once nonce prediction is possible, replay protection is effectively disabled.
- Data integrity: Authenticated SOAP operations (financial transactions, medical record updates, etc.) become forgeable.
The Fix
The fix is a surgical three-line removal and one-line replacement inside the nonce generation block of WSSecurity.toXML():
Before
// nonce = base64 ( sha1 ( created + random ) )
const nHash = crypto.createHash('sha1');
nHash.update(created + Math.random());
nonce = nHash.digest('base64');
After
nonce = crypto.randomBytes(16).toString('base64');
Why This Fix Works
crypto.randomBytes(16) requests 16 bytes (128 bits) of randomness directly from the operating system's cryptographically secure entropy source (/dev/urandom on Linux/macOS, CryptGenRandom on Windows). This is the same entropy pool used for TLS key generation and other security-critical operations.
Key improvements:
| Property | Before | After |
|---|---|---|
| Entropy source | V8 xorshift128+ PRNG | OS CSPRNG |
| Entropy bits | ~64 bits (PRNG state) | 128 bits (true randomness) |
| Predictable? | Yes, with 15–20 observations | No |
| Cryptographically secure? | No | Yes |
| SHA1 hash overhead | Required (obscures weak input) | Removed (not needed) |
The fix also removes the unnecessary SHA1 hashing step. The original code hashed Math.random() output, likely as a misguided attempt to make the nonce look more random. With crypto.randomBytes(), the output is already cryptographically strong raw bytes, and base64-encoding them directly is both correct and more efficient.
Test Coverage Added
The PR also adds targeted tests in test/security/WSSecurity.js to validate the new nonce generation:
it('should generate a nonce that is a valid base64-encoded 16-byte value', function () {
var instance = new WSSecurity('user', 'pass', { hasNonce: true });
var xml = instance.toXML();
var match = xml.match(/<wsse:Nonce[^>]*>([^<]+)<\/wsse:Nonce>/);
var nonce = match[1];
// 16 random bytes encode to 24 base64 chars (with == padding)
nonce.should.match(/^[A-Za-z0-9+/]{22}[A-Za-z0-9+/=]{2}$/);
});
This test validates both the format (valid base64) and the length (16 bytes = 24 base64 characters), ensuring the fix produces structurally correct nonces.
Prevention & Best Practices
Rule #1: Never Use Math.random() for Security Purposes
In Node.js, Math.random() is documented as producing "a floating-point, pseudo-random number that's greater than or equal to 0 and less than 1." The MDN documentation explicitly states it "does not provide cryptographically secure random numbers."
For any security-sensitive value — nonces, tokens, salts, IVs, session IDs — always use:
import crypto from 'crypto';
// Correct: 16 bytes of cryptographic randomness
const nonce = crypto.randomBytes(16).toString('base64');
// Also correct: hex encoding
const token = crypto.randomBytes(32).toString('hex');
// For Web Crypto API (browser or modern Node.js)
const array = new Uint8Array(16);
crypto.getRandomValues(array);
Rule #2: Hashing Weak Randomness Doesn't Fix It
A common misconception is that running Math.random() output through SHA1 or SHA256 makes it cryptographically secure. It does not. The hash function is deterministic — the same input always produces the same output. If the input space is enumerable (as with V8's PRNG), the output space is equally enumerable.
Rule #3: Audit Security Primitives in Libraries
Library code that generates authentication headers, tokens, or cryptographic material deserves extra scrutiny. A single weak primitive in a shared library multiplies the attack surface across every downstream consumer.
Detection Tools
- Semgrep: Rules for
Math.random()in security contexts — search formath-random-security - ESLint:
eslint-plugin-securityflagsMath.random()usage - Node.js built-in audit:
npm auditfor known vulnerable dependency patterns - SAST scanners: Most commercial SAST tools include CWE-338 detection rules
Relevant Standards
- CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)
- CWE-330: Use of Insufficiently Random Values
- OWASP Cryptographic Failures (formerly A3): Covers improper use of cryptographic primitives
- NIST SP 800-90A: Recommendation for Random Number Generation Using Deterministic Random Bit Generators
Key Takeaways
Math.random()inWSSecurity.toXML()is the root cause: The vulnerability was not in the SHA1 hash or the base64 encoding — it was in the entropy source feeding the entire nonce generation pipeline.- Hashing weak randomness with SHA1 provides no security benefit: The comment
// nonce = base64 ( sha1 ( created + random ) )in the original code suggested intentional design, but the design was fundamentally flawed. crypto.randomBytes(16)is the correct primitive for nonces in Node.js: It provides 128 bits of OS-level entropy, which is the same source used for TLS and other production cryptographic operations.- Library vulnerabilities have multiplied blast radius: Because WSSecurity.ts ships as part of a reusable SOAP library, this single flaw affected every application using PasswordDigest authentication downstream.
- Test the nonce format, not just its presence: The new tests validate that the nonce is a correctly formatted 16-byte base64 value, not just that a non-empty string exists — a meaningful improvement in test quality.
How Orbis AppSec Detected This
- Source: The
toXML()method inWSSecurity.tsgenerates the WS-Security header, callingMath.random()as the entropy input at line 88. - Sink:
nHash.update(created + Math.random())— the insecure PRNG output is fed directly into the nonce value embedded in the<wsse:Nonce>SOAP header element. - Missing control: No cryptographically secure random number generator was used;
Math.random()was the sole source of nonce entropy with no additional entropy mixing or validation. - CWE: CWE-338 — Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG).
- Fix: Replaced the four-line SHA1-hashed
Math.random()block with a single call tocrypto.randomBytes(16).toString('base64').
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
The vulnerability in WSSecurity.ts is a textbook example of how a single misplaced primitive can undermine an entire security mechanism. WS-Security's replay protection is mathematically sound — but only when the nonce is actually unpredictable. Substituting Math.random() for a CSPRNG silently voided that guarantee for every application relying on this library.
The fix is minimal and surgical: four lines become one, and Math.random() is replaced by crypto.randomBytes(16). But the security improvement is substantial — moving from a predictable 64-bit PRNG state to 128 bits of OS-level cryptographic entropy.
For developers working with authentication libraries, the lesson is clear: treat every random value in a security context as a potential vulnerability until you can confirm it comes from a cryptographically secure source. In Node.js, that means crypto.randomBytes() — always.
References
- CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)
- CWE-330: Use of Insufficiently Random Values
- OWASP Cryptographic Failures Cheat Sheet
- Node.js
crypto.randomBytes()Official Documentation - Semgrep rules for Math.random() in security contexts
- fix: the ws-security implementation uses math in WSSecurity.ts