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
Published August 26, 2026Reviewed 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.


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 for math-random-security
  • ESLint: eslint-plugin-security flags Math.random() usage
  • Node.js built-in audit: npm audit for 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() 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.


References

Frequently Asked Questions

What is a weak PRNG vulnerability in WS-Security?

WS-Security UsernameToken PasswordDigest authentication relies on a random nonce to prevent replay attacks. Using a predictable PRNG like Math.random() means an attacker can observe several nonces and statistically reconstruct the generator state to predict future values, forging valid authentication headers.

How do you prevent weak PRNG vulnerabilities in Node.js?

Always use Node.js's built-in `crypto.randomBytes(n)` for any security-sensitive random value such as nonces, tokens, or salts. Never use `Math.random()`, `Date.now()`, or other deterministic sources for cryptographic purposes.

What CWE is weak PRNG usage?

CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG). It falls under the broader CWE-330 (Use of Insufficiently Random Values) family.

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.

Can static analysis detect weak PRNG usage?

Yes. Tools like Semgrep, ESLint with security plugins, and dedicated SAST scanners can flag uses of `Math.random()` in security-sensitive contexts. Orbis AppSec's multi-agent AI scanner detected this exact pattern in WSSecurity.ts automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1516

Related Articles

critical

How Unsafe Random Functions Happen in Node.js Form Data and How to Fix It

CVE-2025-7783 is a critical vulnerability in the `form-data` npm package caused by the use of an unsafe random number generator to produce multipart form boundaries, making those boundaries predictable by an attacker. The fix upgrades `form-data` to versions 2.5.4, 3.0.4, and 4.0.4, which replace the weak random function with a cryptographically secure alternative. This change was applied to the `example-apps/collector/package-lock.json` and `package.json` files in the Instana collector example

critical

How Plaintext Token Storage happens in TypeScript/Tauri and how to fix it

A critical vulnerability in a Tauri desktop application allowed GitHub API tokens with full `repo` scope to be written to plaintext local storage files via the `getAllSettings()` function in `src/config/settings.ts`. Any process with filesystem access — including malware, other apps, or a logged-in attacker — could silently extract these tokens. The fix introduces a `SENSITIVE_KEYS` exclusion set that prevents credentials from being serialized to disk.

critical

How Implicit TLS Certificate Verification Happens in Python and How to Fix It

A critical security vulnerability was discovered in `plugins/python-build/scripts/add_cpython.py` where `requests.get()` calls to the GitHub API and OpenSSL release endpoints lacked explicit TLS certificate verification enforcement and consistent error handling. While Python's `requests` library defaults to `verify=True`, the absence of explicit enforcement and centralized error handling left the build tool exposed to man-in-the-middle attacks that could inject malicious package data. The fix in

critical

How Unauthenticated API Endpoints happen in Node.js Express and how to fix it

The `/token` endpoint in `plugin/multiplex/index.js` generated presentation control tokens without verifying the requester's identity, allowing any attacker with network access to seize control of a live reveal.js presentation. The fix restricts token generation to localhost-only requests and replaces a broken cryptographic primitive with a proper SHA-256 hash. Together, these changes eliminate both the access-control gap and a secondary cryptographic weakness in a single targeted patch.

critical

How Unsafe Random Functions Happen in Node.js form-data and How to Fix It

CVE-2025-7783 is a critical vulnerability in the `form-data` npm package caused by its use of an unsafe random function to generate multipart form boundaries. This flaw allows attackers to predict boundary values, potentially enabling them to manipulate or inject content into multipart requests. The fix upgrades `form-data` to version 4.0.6 and enforces this version across the entire dependency tree using a `package.json` `overrides` directive.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript