ANT-2026-110HTZ5M · jsrsasign

other high

Severity Claude high · Security research firm high · Maintainer -

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Calif.

ANT-2026-110HTZ5M: jsrsasign npm — ECDSA nonce RNG seeded entirely from Math.random() in Node.js

The npm wrapper at lib/jsrsasign.js:2-5 declares a module-local window = {} with no .crypto, so the seed-pool initialiser in ext/rng.js falls through to filling all 256 ARC4 key bytes from 128 Math.random() calls plus Date.now(). Every ECDSA nonce k (ecdsa-modified-1.0.js:243), generated EC private key, DSA nonce, RSA-PSS salt, and RSA prime produced by jsrsasign in Node is therefore a deterministic function of V8's xorshift128+ state at require() time. An attacker who observes a few Math.random() outputs leaked by any co-resident code (request-ID middleware, jitter, shuffles, non-secure UUIDs) can solve the xorshift128+ state, rewind to the pool-fill window, bound the load timestamp, replay the ARC4 keystream, and recover the signing nonce k. From one observed ECDSA signature (r,s) on a known message, the private key follows directly as d = (s·k − e)·r⁻¹ mod n.

Target

Project: jsrsasign
Version: ≤ 11.1.1 (npm distribution)
Location: ext/rng.js:28-58; npm/lib/jsrsasign.js:2-5; src/ecdsa-modified-1.0.js:61,98-103,235-252
Discovery: static analysis — not yet dynamically reproduced

Technical Details

Root cause is the var window = {} shim in the npm build: ext/rng.js checks only window.crypto/window.msCrypto (both undefined on the stub) and never checks globalThis.crypto.getRandomValues (present in Node ≥19) or require('crypto').randomBytes (zero grep hits in the package). The while (rng_pptr < rng_psize) loop therefore always executes in Node, deriving the entire 256-byte ARC4 seed from Math.floor(65536 * Math.random()) — V8's non-cryptographic xorshift128+ — XORed with two correlated millisecond timestamps. This predictable seed flows into new SecureRandom()getBigRandom(n) → the ECDSA nonce k in signHex.

Reproduction

  1. Collect ~5 leaked Math.random() doubles from victim responses (request IDs, Retry-After jitter, shuffled arrays, non-secure UUIDs).
  2. Solve V8 xorshift128+ internal state from the doubles via linear algebra over GF(2) / Z3 (milliseconds; public tooling).
  3. Rewind the linear generator by 128+Δ calls to the require()-time pool-fill window, accounting for V8's reversed 64-entry cache.
  4. Iterate ~10³–10⁶ candidate Date.now() values for the two rng_seed_time() XORs; re-key ARC4 for each and generate candidate nonces k.
  5. Capture one ECDSA signature (r,s) on known hash e; for each candidate k compute d = (s·k − e)·r⁻¹ mod n and check against the public key.
  6. On match, attacker holds the private key; forge arbitrary signatures/JWTs for the lifetime of that key.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

In ext/rng.js, before the Math.random() fallback loop, check for globalThis.crypto?.getRandomValues (Node ≥19, all modern browsers) or require('crypto').randomBytes when process.versions.node is defined. Alternatively/additionally, adopt RFC 6979 deterministic nonce derivation for ECDSA/DSA to remove RNG dependence from signing. Until fixed, Node users should not use jsrsasign for key generation or ECDSA/DSA signing.

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-110HTZ5M.


Reference: ANT-2026-110HTZ5M
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Calif.

Verdict
true positive
Severity
high
TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-12 Sent to maintainer
  2. 2026-04-12 Patch released
  3. 2026-05-19 Reported to tracker
  4. 2026-05-28 Maintainer acknowledged
  5. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

16201330990313937c8b3a3daafb87057c94bbbed5c303c3b4c4da71a857d78c6775deb7527010b042ee68486a5a15a95a4ec8fa13319ecad7b6714567b11031

Committed 2026-05-28 08:09 PT

Revealed 2026-08-17 13:01 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-110HTZ5M",
  "bug_class": "Cryptographically Weak PRNG / Predictable ECDSA Nonce",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-05-20T01:48:55+00:00",
  "description": "The npm wrapper at lib/jsrsasign.js:2-5 declares a module-local `window = {}` with no `.crypto`, so the seed-pool initialiser in ext/rng.js falls through to filling all 256 ARC4 key bytes from 128 Math.random() calls plus Date.now(). Every ECDSA nonce k (ecdsa-modified-1.0.js:243), generated EC private key, DSA nonce, RSA-PSS salt, and RSA prime produced by jsrsasign in Node is therefore a deterministic function of V8's xorshift128+ state at require() time. An attacker who observes a few Math.random() outputs leaked by any co-resident code (request-ID middleware, jitter, shuffles, non-secure UUIDs) can solve the xorshift128+ state, rewind to the pool-fill window, bound the load timestamp, replay the ARC4 keystream, and recover the signing nonce k. From one observed ECDSA signature (r,s) on a known message, the private key follows directly as d = (s·k − e)·r⁻¹ mod n.",
  "discovered_at": "2026-03-28T00:00:00+00:00",
  "location": "ext/rng.js:28-58; npm/lib/jsrsasign.js:2-5; src/ecdsa-modified-1.0.js:61,98-103,235-252",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "jsrsasign",
  "reproduction": [
    "1. Collect ~5 leaked Math.random() doubles from victim responses (request IDs, Retry-After jitter, shuffled arrays, non-secure UUIDs).",
    "2. Solve V8 xorshift128+ internal state from the doubles via linear algebra over GF(2) / Z3 (milliseconds; public tooling).",
    "3. Rewind the linear generator by 128+Δ calls to the require()-time pool-fill window, accounting for V8's reversed 64-entry cache.",
    "4. Iterate ~10³–10⁶ candidate Date.now() values for the two rng_seed_time() XORs; re-key ARC4 for each and generate candidate nonces k.",
    "5. Capture one ECDSA signature (r,s) on known hash e; for each candidate k compute d = (s·k − e)·r⁻¹ mod n and check against the public key.",
    "6. On match, attacker holds the private key; forge arbitrary signatures/JWTs for the lifetime of that key."
  ],
  "technical_details": "Root cause is the `var window = {}` shim in the npm build: ext/rng.js checks only `window.crypto`/`window.msCrypto` (both undefined on the stub) and never checks `globalThis.crypto.getRandomValues` (present in Node ≥19) or `require('crypto').randomBytes` (zero grep hits in the package). The `while (rng_pptr < rng_psize)` loop therefore always executes in Node, deriving the entire 256-byte ARC4 seed from `Math.floor(65536 * Math.random())` — V8's non-cryptographic xorshift128+ — XORed with two correlated millisecond timestamps. This predictable seed flows into `new SecureRandom()` → `getBigRandom(n)` → the ECDSA nonce k in `signHex`.",
  "title": "jsrsasign npm — ECDSA nonce RNG seeded entirely from Math.random() in Node.js",
  "vendor_severity": "high"
}