GHSA-VH45-F885-3848
Vulnerability from github – Published: 2026-07-24 21:50 – Updated: 2026-08-13 17:41Summary
sm-crypto (npm package 0.4.0, the latest release, published 2026-01-20)
generates SM2 private keys and signing ephemeral scalars from a single
module-wide RNG instance (src/sm2/utils.js: const rng = new SecureRandom()).
SecureRandom is jsbn's PRNG, which seeds an ARC4 stream from
window.crypto.getRandomValues when available. In Node.js — sm-crypto's
primary runtime — window is undefined, so the CSPRNG branch is skipped
and the seed pool is instead filled from Math.random() (V8 xorshift128+,
recoverable from a few outputs) plus new Date().getTime() (wall clock,
attacker-estimable).
Node does expose Web Crypto as globalThis.crypto, but jsbn checks
window.crypto, not globalThis.crypto, so the secure path is never taken.
Consequently every SM2 private key produced by the default
sm2.generateKeyPairHex() and every signing ephemeral scalar is derived from
non-cryptographic sources and is predictable by an attacker who can observe
a few Math.random() outputs and estimate the generation time.
This is the library's default (no-argument) path; no caller-selected
parameter or configuration is required to trigger it. It is reproduced
end-to-end against the unmodified real npm packages (sm-crypto@0.4.0 +
jsbn@1.1.0); the PoC below runs against the real installed package, not a
copy. The defect is still present on the latest published version (0.4.0) and
is not covered by any existing JuneAndGreen/sm-crypto issue (0 afldl issues
exist; the most recent issues are unrelated SM3/HKDF/PBKDF2 feature requests).
Details
jsbn@1.1.0 index.js — RNG pool initialization (fallback taken in Node):
if (rng_pool == null) {
rng_pool = new Array(); rng_pptr = 0; var t;
if (typeof window !== "undefined" && window.crypto) { // <-- false in Node
if (window.crypto.getRandomValues) { /* webcrypto */ }
...
}
while (rng_pptr < rng_psize) { // <-- fallback path
t = Math.floor(65536 * Math.random()); // Math.random()
rng_pool[rng_pptr++] = t >>> 8;
rng_pool[rng_pptr++] = t & 255;
}
rng_pptr = 0;
rng_seed_time(); // + Date.getTime()
}
sm-crypto src/sm2/utils.js:
const { SecureRandom } = require('jsbn');
const rng = new SecureRandom(); // single module-wide RNG
...
function generateKeyPairHex(a, b, c) {
const random = a ? new BigInteger(a, b, c)
: new BigInteger(n.bitLength(), rng); // uses rng
const d = random.mod(n.subtract(BigInteger.ONE)).add(BigInteger.ONE); // private key
...
}
The default (no-argument) call path uses rng, the jsbn ARC4 instance seeded
from Math.random() + time. The same rng feeds the signing ephemeral
scalar during SM2 signing.
PoC
The PoC runs against the real installed npm packages. It pins Math.random
and Date before require('sm-crypto') so jsbn's seed pool is built from
controlled inputs. Three independent fresh Node processes then produce the
same SM2 private key, proving the key is a pure deterministic function of
those non-cryptographic sources. It also prints a probe confirming the fallback
branch is taken in Node.
One-line reproducer
WORK=$(mktemp -d) && cd "$WORK" && npm init -y >/dev/null \
&& npm install sm-crypto@0.4.0 jsbn@1.1.0 >/dev/null \
&& export NODE_PATH="$WORK/node_modules" \
&& node poc.js probe && node poc.js deterministic && node poc.js deterministic
poc.js
/*
* PoC for sm-crypto predictable default RNG in Node.js.
*
* sm-crypto (npm 0.4.0) generates SM2 private keys / ephemeral scalars using
* jsbn's SecureRandom. In a browser jsbn seeds ARC4 from window.crypto, but in
* Node.js `window` is undefined so the CSPRNG branch is skipped and the pool is
* filled from Math.random() plus new Date().getTime(). Both are
* non-cryptographic; the time is attacker-estimable and V8's Math.random is a
* recoverable xorshift128+ stream. Consequently SM2 keys produced by the
* default path are predictable.
*
* This PoC proves the key is a deterministic function of those two inputs: we
* pin Math.random and the clock to fixed values BEFORE sm-crypto (and therefore
* jsbn) is loaded, then generate a keypair. Re-running with the same pinned
* values reproduces the exact same private key.
*/
const MODE = process.argv[2] || 'probe'; // 'probe' | 'deterministic'
if (MODE === 'deterministic') {
// --- pin entropy sources BEFORE requiring sm-crypto/jsbn ---
const fixedTime = 1700000000000;
let s = 0x12345678 >>> 0;
Math.random = function () {
// tiny deterministic LCG standing in for the (already non-crypto) Math.random
s = (Math.imul(s, 1103515245) + 12345) >>> 0;
return s / 0x100000000;
};
const RealDate = globalThis.Date;
class FixedDate extends RealDate {
constructor(...a) { super(...(a.length === 0 ? [fixedTime] : a)); }
}
FixedDate.now = () => fixedTime;
globalThis.Date = FixedDate;
}
const sm2 = require('sm-crypto').sm2;
const kp = sm2.generateKeyPairHex();
console.log('PRIVATE=' + kp.privateKey);
if (MODE === 'probe') {
console.log('--- probe ---');
console.log('typeof window =', typeof window, '(undefined in Node => jsbn CSPRNG branch skipped)');
console.log('typeof globalThis.crypto =', typeof globalThis.crypto, '(Node Web Crypto exists but jsbn checks window.crypto, not globalThis.crypto)');
console.log('Math.random sample =', Math.random());
console.log('Date.now() =', Date.now(), '(attacker-estimable, mixed into ARC4 seed)');
}
Real captured output:
===== PROBE (real default path, no patching) =====
PRIVATE=6072e45733a4187791ec28ce906fef18c7d33c8529969e1a852833c4349cfc38
--- probe ---
typeof window = undefined (undefined in Node => jsbn CSPRNG branch skipped)
typeof globalThis.crypto = object (Node Web Crypto exists but jsbn checks window.crypto, not globalThis.crypto)
Math.random sample = 0.704452488761137
Date.now() = 1784455686795 (attacker-estimable, mixed into ARC4 seed)
===== DETERMINISTIC (Math.random + Date pinned before require sm-crypto) =====
--- run #1 --- PRIVATE=143268fa0939b4da09eab8c9a2e027a04555b6c433fef4f54fc5edd517c0a6b1
--- run #2 --- PRIVATE=143268fa0939b4da09eab8c9a2e027a04555b6c433fef4f54fc5edd517c0a6b1
The two deterministic runs produce the identical SM2 private key,
demonstrating the key is a pure function of Math.random() + wall-clock time.
Impact
Private-key recovery / signature forgery of any SM2 keypair generated with the default API in Node.js. This is the most serious class of defect for a maintained SM2 library: the default key-generation path is non-cryptographic on its primary runtime.
- Private-key recovery. Any SM2 keypair generated with the default API in
Node is derived from
Math.random()+ wall-clock time. An attacker who can observe a fewMath.random()outputs (V8xorshift128+state is recoverable from ~4 observed doubles) and estimate the generation time can reproduce the private key and forge signatures. - Signing ephemeral reuse / forgery. The same RNG feeds the ephemeral
scalar
kduring SM2 signing; a predictablekleaks the private key from a single signature (SM2 is EC-Schnorr-like:s = (k^-1)(e + d·r) mod n). - Pre-authentication / no privilege required: anyone who can induce a victim to generate a key or sign a message (the normal API use) is positioned to predict the secret material.
Suggested fix
Seed the RNG from a CSPRNG in Node. The simplest fix in sm-crypto is to
replace the jsbn ARC4 instance with Web Crypto / crypto.randomBytes:
// src/sm2/utils.js
const nodeCrypto = (typeof require === 'function') ? require('crypto') : null;
function csrandBytes(n) {
if (nodeCrypto) return nodeCrypto.randomBytes(n); // Node
if (globalThis.crypto) { // Web Crypto (browser/Node ≥ 19)
const b = new Uint8Array(n); globalThis.crypto.getRandomValues(b); return b;
}
throw new Error('no CSPRNG available');
}
and use it to generate the private key / ephemeral directly, or to reseed the
jsbn pool. A separate (upstream) fix belongs in jsbn to check
globalThis.crypto in addition to window.crypto.
Affected versions
- npm
sm-crypto0.4.0 (latest, published 2026-01-20). Depends onjsbn ^1.1.0(jsbn@1.1.0, whoseindex.jsRNG is the root cause). - Runtime: Node.js (the primary runtime; in a browser the jsbn CSPRNG branch is taken).
Credit
Reported by the diff/ambidiff security research effort (afldl).
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "sm-crypto"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.5.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-73567"
],
"database_specific": {
"cwe_ids": [
"CWE-338"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-24T21:50:45Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Summary\n\n`sm-crypto` (npm package **0.4.0**, the latest release, published 2026-01-20)\ngenerates SM2 private keys and signing ephemeral scalars from a single\nmodule-wide RNG instance (`src/sm2/utils.js`: `const rng = new SecureRandom()`).\n`SecureRandom` is jsbn\u0027s PRNG, which seeds an **ARC4** stream from\n`window.crypto.getRandomValues` when available. **In Node.js \u2014 sm-crypto\u0027s\nprimary runtime \u2014 `window` is `undefined`, so the CSPRNG branch is skipped**\nand the seed pool is instead filled from `Math.random()` (V8 `xorshift128+`,\nrecoverable from a few outputs) plus `new Date().getTime()` (wall clock,\nattacker-estimable).\n\nNode *does* expose Web Crypto as `globalThis.crypto`, but jsbn checks\n`window.crypto`, not `globalThis.crypto`, so the secure path is never taken.\nConsequently every SM2 private key produced by the default\n`sm2.generateKeyPairHex()` and every signing ephemeral scalar is derived from\nnon-cryptographic sources and is **predictable** by an attacker who can observe\na few `Math.random()` outputs and estimate the generation time.\n\nThis is the library\u0027s **default** (no-argument) path; no caller-selected\nparameter or configuration is required to trigger it. It is reproduced\nend-to-end against the unmodified real npm packages (`sm-crypto@0.4.0` +\n`jsbn@1.1.0`); the PoC below runs against the real installed package, not a\ncopy. The defect is still present on the latest published version (0.4.0) and\nis not covered by any existing `JuneAndGreen/sm-crypto` issue (0 afldl issues\nexist; the most recent issues are unrelated SM3/HKDF/PBKDF2 feature requests).\n\n## Details\n\n`jsbn@1.1.0` `index.js` \u2014 RNG pool initialization (fallback taken in Node):\n\n```js\nif (rng_pool == null) {\n rng_pool = new Array(); rng_pptr = 0; var t;\n if (typeof window !== \"undefined\" \u0026\u0026 window.crypto) { // \u003c-- false in Node\n if (window.crypto.getRandomValues) { /* webcrypto */ }\n ...\n }\n while (rng_pptr \u003c rng_psize) { // \u003c-- fallback path\n t = Math.floor(65536 * Math.random()); // Math.random()\n rng_pool[rng_pptr++] = t \u003e\u003e\u003e 8;\n rng_pool[rng_pptr++] = t \u0026 255;\n }\n rng_pptr = 0;\n rng_seed_time(); // + Date.getTime()\n}\n```\n\n`sm-crypto` `src/sm2/utils.js`:\n\n```js\nconst { SecureRandom } = require(\u0027jsbn\u0027);\nconst rng = new SecureRandom(); // single module-wide RNG\n...\nfunction generateKeyPairHex(a, b, c) {\n const random = a ? new BigInteger(a, b, c)\n : new BigInteger(n.bitLength(), rng); // uses rng\n const d = random.mod(n.subtract(BigInteger.ONE)).add(BigInteger.ONE); // private key\n ...\n}\n```\n\nThe default (no-argument) call path uses `rng`, the jsbn ARC4 instance seeded\nfrom `Math.random()` + time. The same `rng` feeds the signing ephemeral\nscalar during SM2 signing.\n\n## PoC\n\nThe PoC runs against the real installed npm packages. It pins `Math.random`\nand `Date` **before** `require(\u0027sm-crypto\u0027)` so jsbn\u0027s seed pool is built from\ncontrolled inputs. Three independent fresh Node processes then produce the\n**same** SM2 private key, proving the key is a pure deterministic function of\nthose non-cryptographic sources. It also prints a probe confirming the fallback\nbranch is taken in Node.\n\n### One-line reproducer\n\n```bash\nWORK=$(mktemp -d) \u0026\u0026 cd \"$WORK\" \u0026\u0026 npm init -y \u003e/dev/null \\\n \u0026\u0026 npm install sm-crypto@0.4.0 jsbn@1.1.0 \u003e/dev/null \\\n \u0026\u0026 export NODE_PATH=\"$WORK/node_modules\" \\\n \u0026\u0026 node poc.js probe \u0026\u0026 node poc.js deterministic \u0026\u0026 node poc.js deterministic\n```\n\n### `poc.js`\n\n```js\n/*\n * PoC for sm-crypto predictable default RNG in Node.js.\n *\n * sm-crypto (npm 0.4.0) generates SM2 private keys / ephemeral scalars using\n * jsbn\u0027s SecureRandom. In a browser jsbn seeds ARC4 from window.crypto, but in\n * Node.js `window` is undefined so the CSPRNG branch is skipped and the pool is\n * filled from Math.random() plus new Date().getTime(). Both are\n * non-cryptographic; the time is attacker-estimable and V8\u0027s Math.random is a\n * recoverable xorshift128+ stream. Consequently SM2 keys produced by the\n * default path are predictable.\n *\n * This PoC proves the key is a deterministic function of those two inputs: we\n * pin Math.random and the clock to fixed values BEFORE sm-crypto (and therefore\n * jsbn) is loaded, then generate a keypair. Re-running with the same pinned\n * values reproduces the exact same private key.\n */\n\nconst MODE = process.argv[2] || \u0027probe\u0027; // \u0027probe\u0027 | \u0027deterministic\u0027\n\nif (MODE === \u0027deterministic\u0027) {\n // --- pin entropy sources BEFORE requiring sm-crypto/jsbn ---\n const fixedTime = 1700000000000;\n let s = 0x12345678 \u003e\u003e\u003e 0;\n Math.random = function () {\n // tiny deterministic LCG standing in for the (already non-crypto) Math.random\n s = (Math.imul(s, 1103515245) + 12345) \u003e\u003e\u003e 0;\n return s / 0x100000000;\n };\n const RealDate = globalThis.Date;\n class FixedDate extends RealDate {\n constructor(...a) { super(...(a.length === 0 ? [fixedTime] : a)); }\n }\n FixedDate.now = () =\u003e fixedTime;\n globalThis.Date = FixedDate;\n}\n\nconst sm2 = require(\u0027sm-crypto\u0027).sm2;\nconst kp = sm2.generateKeyPairHex();\nconsole.log(\u0027PRIVATE=\u0027 + kp.privateKey);\n\nif (MODE === \u0027probe\u0027) {\n console.log(\u0027--- probe ---\u0027);\n console.log(\u0027typeof window =\u0027, typeof window, \u0027(undefined in Node =\u003e jsbn CSPRNG branch skipped)\u0027);\n console.log(\u0027typeof globalThis.crypto =\u0027, typeof globalThis.crypto, \u0027(Node Web Crypto exists but jsbn checks window.crypto, not globalThis.crypto)\u0027);\n console.log(\u0027Math.random sample =\u0027, Math.random());\n console.log(\u0027Date.now() =\u0027, Date.now(), \u0027(attacker-estimable, mixed into ARC4 seed)\u0027);\n}\n```\n\nReal captured output:\n\n```\n===== PROBE (real default path, no patching) =====\nPRIVATE=6072e45733a4187791ec28ce906fef18c7d33c8529969e1a852833c4349cfc38\n--- probe ---\ntypeof window = undefined (undefined in Node =\u003e jsbn CSPRNG branch skipped)\ntypeof globalThis.crypto = object (Node Web Crypto exists but jsbn checks window.crypto, not globalThis.crypto)\nMath.random sample = 0.704452488761137\nDate.now() = 1784455686795 (attacker-estimable, mixed into ARC4 seed)\n\n===== DETERMINISTIC (Math.random + Date pinned before require sm-crypto) =====\n--- run #1 --- PRIVATE=143268fa0939b4da09eab8c9a2e027a04555b6c433fef4f54fc5edd517c0a6b1\n--- run #2 --- PRIVATE=143268fa0939b4da09eab8c9a2e027a04555b6c433fef4f54fc5edd517c0a6b1\n```\n\nThe two deterministic runs produce the **identical** SM2 private key,\ndemonstrating the key is a pure function of `Math.random()` + wall-clock time.\n\n## Impact\n\n**Private-key recovery / signature forgery of any SM2 keypair generated with\nthe default API in Node.js.** This is the most serious class of defect for a\nmaintained SM2 library: the *default* key-generation path is\nnon-cryptographic on its primary runtime.\n\n- **Private-key recovery.** Any SM2 keypair generated with the default API in\n Node is derived from `Math.random()` + wall-clock time. An attacker who can\n observe a few `Math.random()` outputs (V8 `xorshift128+` state is\n recoverable from ~4 observed doubles) and estimate the generation time can\n reproduce the private key and forge signatures.\n- **Signing ephemeral reuse / forgery.** The same RNG feeds the ephemeral\n scalar `k` during SM2 signing; a predictable `k` leaks the private key from a\n single signature (SM2 is EC-Schnorr-like: `s = (k^-1)(e + d\u00b7r) mod n`).\n- Pre-authentication / no privilege required: anyone who can induce a victim\n to generate a key or sign a message (the normal API use) is positioned to\n predict the secret material.\n\n### Suggested fix\n\nSeed the RNG from a CSPRNG in Node. The simplest fix in `sm-crypto` is to\nreplace the jsbn ARC4 instance with Web Crypto / `crypto.randomBytes`:\n\n```js\n// src/sm2/utils.js\nconst nodeCrypto = (typeof require === \u0027function\u0027) ? require(\u0027crypto\u0027) : null;\nfunction csrandBytes(n) {\n if (nodeCrypto) return nodeCrypto.randomBytes(n); // Node\n if (globalThis.crypto) { // Web Crypto (browser/Node \u2265 19)\n const b = new Uint8Array(n); globalThis.crypto.getRandomValues(b); return b;\n }\n throw new Error(\u0027no CSPRNG available\u0027);\n}\n```\n\nand use it to generate the private key / ephemeral directly, or to reseed the\njsbn pool. A separate (upstream) fix belongs in jsbn to check\n`globalThis.crypto` in addition to `window.crypto`.\n\n### Affected versions\n\n- npm `sm-crypto` **0.4.0** (latest, published 2026-01-20). Depends on\n `jsbn ^1.1.0` (`jsbn@1.1.0`, whose `index.js` RNG is the root cause).\n- Runtime: Node.js (the primary runtime; in a browser the jsbn CSPRNG branch\n is taken).\n\n## Credit\n\nReported by the diff/ambidiff security research effort (afldl).",
"id": "GHSA-vh45-f885-3848",
"modified": "2026-08-13T17:41:00Z",
"published": "2026-07-24T21:50:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/JuneAndGreen/sm-crypto/security/advisories/GHSA-vh45-f885-3848"
},
{
"type": "WEB",
"url": "https://github.com/JuneAndGreen/sm-crypto/commit/1f9bd7bd160c24efd9c26c8f7fda997c68c823d0"
},
{
"type": "PACKAGE",
"url": "https://github.com/JuneAndGreen/sm-crypto"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "sm-crypto: Predictable SM2 key generation in Node.js: default RNG uses Math.random + wall clock"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.