Back to Blog
critical SEVERITY8 min read

How Weak Randomness Happens in Node.js WS-Security and How to Fix It

A critical vulnerability in `src/security/WSSecurity.ts` used `Math.random()` to generate nonces for WS-Security UsernameToken authentication, making nonces statistically predictable and defeating replay protection. By replacing the insecure SHA1-hashed random value with `crypto.randomBytes(16)`, the fix ensures nonces are cryptographically unpredictable. This change protects all downstream consumers of this Node.js SOAP library from nonce-prediction attacks on WS-Security authenticated endpoint

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

This vulnerability is a use of a cryptographically weak pseudo-random number generator (CWE-338) in the WS-Security nonce generation logic inside `WSSecurity.ts`. The Node.js `Math.random()` function is not cryptographically secure; an attacker who observes multiple SOAP requests can statistically predict future nonce values, forging authenticated requests and bypassing replay protection. The fix replaces the insecure `crypto.createHash('sha1').update(created + Math.random())` pattern with `crypto.randomBytes(16).toString('base64')`, which generates 128 bits of cryptographically strong randomness directly from the OS entropy pool.

Vulnerability at a Glance

cweCWE-338
fixReplaced with `crypto.randomBytes(16).toString('base64')` for OS-level cryptographic entropy
riskAttackers can predict future nonces, forge WS-Security authenticated SOAP requests, and bypass replay protection
languageTypeScript / Node.js
root cause`Math.random()` used as entropy source inside `WSSecurity.toXML()` nonce generation at line 88
vulnerabilityUse of Cryptographically Weak PRNG for Nonce Generation

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:

  1. The client generates a random nonce (a one-time value).
  2. The client computes: PasswordDigest = Base64(SHA1(nonce + created + password))
  3. 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

  1. An attacker intercepts 15–20 SOAP requests to a WS-Security protected endpoint.
  2. They extract the <wsse:Nonce> values from the <wsse:UsernameToken> headers.
  3. Using a V8 PRNG state-recovery tool (several are publicly available), they reconstruct the internal xorshift128+ state.
  4. They predict the next nonce value the client will generate.
  5. 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).
  6. 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.


Key Takeaways

  • Math.random() in WSSecurity.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 in WSSecurity.ts generates the WS-Security header, calling Math.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 to crypto.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.


Prevention and further reading

Frequently Asked Questions

Is hashing Math.random() output with SHA1 enough to make it secure?

No. Hashing a predictable value does not add entropy — it only obscures the output. An attacker who can enumerate or predict the Math.random() seed space can still reconstruct the nonce, making the SHA1 wrapper ineffective as a security control.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1516

Related Articles

high

file_md5() MD5 Digest Lets Agent Docs Collide (CWE-328)

A first-party maintenance script used `hashlib.md5()` inside a `file_md5(path)` helper to decide whether the per-vendor agent instruction documents in a repository were byte-equivalent. Because MD5 is collision-broken, two meaningfully different documents could be crafted to produce the same digest and pass the "these files are in sync" gate. The fix replaces the MD5 call with `hashlib.sha256()` over the same `path.read_bytes()` input.

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

high

undici 7.29.0 TLS Bypass: BalancedPool Drops Connect Options

A critical flaw in undici's BalancedPool implementation silently discards TLS certificate validation settings when routing requests through certain connection paths. Attackers on the network could intercept HTTPS traffic without triggering validation errors. The fix preserves connect options throughout the connection lifecycle.

critical

Model Fetching Without Integrity Verification in ONNX Loading

A machine learning application was fetching ONNX model files and chunks over the network without verifying their integrity, creating an opening for model poisoning attacks. The fix adds cryptographic integrity verification at the point where downloaded chunks are reassembled and cached, ensuring models have not been modified in transit or at rest.

high

nanoid 3.3.11 Integer Overflow: Predictable ID Generation

An integer overflow in nanoid 3.3.11's internal randomness generation causes the library to fall back to predictable ID sequences, undermining the cryptographic guarantees of its supposedly unguessable identifiers. The fix upgrades the dependency tree to patched versions 3.3.12 or 5.1.11.