Skip to content

fix(rsa): reject garbage in nested DigestAlgorithm SEQUENCE - #1160

Closed
shaswata56 wants to merge 3 commits into
digitalbazaar:mainfrom
shaswata56:fix/pkcs1-nested-digestalgorithm-validation
Closed

shaswata56 wants to merge 3 commits into
digitalbazaar:mainfrom
shaswata56:fix/pkcs1-nested-digestalgorithm-validation

Conversation

@shaswata56

@shaswata56 shaswata56 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Fix RSA PKCS#1 v1.5 forgery via nested DigestAlgorithm garbage (CVE-2026-85393)

Summary

RSASSA-PKCS1-v1_5 verification accepted signatures whose decrypted block contains a structurally "valid" but non-canonical DigestInfo. forge's BER parser and asn1.validate() intentionally tolerate malformed encodings, which leaves attacker-controlled slack bytes inside the block — a NULL parameters field carrying arbitrary content, non-minimal long-form length octets, or extra/garbage elements in the nested SEQUENCEs. That slack is exactly what a low-exponent (e.g. e = 3) PKCS#1 v1.5 forgery needs to control the digest bytes while keeping validation happy.

Approach

After asn1.validate() confirms the DigestInfo structure, the expected DigestInfo is re-encoded canonically from the parsed OID and digest (_encodeDigestInfo) and compared byte-for-byte against the decoded block. One comparison rejects every lenient-encoding variant at once — there is no enumeration of malformed shapes to get wrong.

Details:

  • Both DigestAlgorithm forms that occur in practice are accepted: with the empty NULL parameters field (as encoded by forge and OpenSSL) and, for the algorithms that permit it, without it — selected by whether the validator captured a parameters element.
  • The comparison is a prefix comparison so the internal _parseAllDigestBytes testing flag keeps tolerating trailing bytes; in the default mode asn1.fromDer() has already rejected them, making this an exact match.
  • This replaces the element-count check from the first revision of this PR, which @matthewscottconroy and @subvincdebian showed was bypassable with count-preserving variants (NULL-with-content, long-form length octets) — see the review discussion. Canonical comparison is the approach proposed in Fix CVE-2026-85393: reject non-canonical DigestInfo in RSA PKCS#1 v1.5 verification #1159.

Tests

  • tests/unit/rsa.js: regression vectors for the count-preserving variants — NULL parameters with content bytes (100-byte payload), non-minimal long-form length octets on the inner/outer SEQUENCE, garbage element in the parameters slot — plus the original forged low-exponent vectors and positive controls (forge- and OpenSSL-encoded valid signatures).
  • tests/pocs/cve-2026-85393.js: self-contained PoC demonstrating the forgery against the previous behavior.
  • Full suite: 834 passing, 0 failing.

Credits & relationship to other PRs

The fix for CVE-2026-33894 only checked the element count of the outer
DigestInfo SEQUENCE. asn1.validate() ignores extra children, so extra
unconsumed elements inside the nested DigestAlgorithm SEQUENCE were not
rejected, leaving enough slack for the same class of low public exponent
(e=3) PKCS#1 v1.5 signature forgery (Bleichenbacher-style) via garbage
bytes placed inside the nested structure instead of the outer one.

Require the nested DigestAlgorithm SEQUENCE to contain exactly the
algorithm OID plus an optional NULL parameters element, rejecting
anything else. Regression test vectors generated with
tests/pocs/cve-2026-85393.js model the forgery from issue digitalbazaar#1149.

Fixes CVE-2026-85393 (GHSA-86w9-cpqp-85rv).

Signed-off-by: Shaswata Das <shaswata56@gmail.com>
@matthewscottconroy

Copy link
Copy Markdown
Contributor

Thanks for adding the PoC script and the positive DigestInfo tests. Having those in the repo is useful.

A couple of things that should help whoever reviews this:

Overlap with #1152. The lib/rsa.js change here is the same as the one in #1152 (Krysthyan, opened 2026-09-09), comment text included. Both diffs produce the same file (a4264822b..430905451). If this builds on #1152, it would help to say so in the description. It might also be simpler to offer the PoC and the new tests to #1152, so maintainers have one fewer duplicate PR to sort through.

The count-only check leaves some cases open. Requiring the inner DigestAlgorithm SEQUENCE to have 2 elements (or 1) doesn't cover two variants, because both keep the element count valid:

NULL with content: forge's parser doesn't require a NULL to be empty, so the parameters field can carry attacker-chosen bytes (100 bytes is tested) while the inner SEQUENCE stays at exactly 2 elements.
Non-minimal length octets: long-form lengths on the inner or outer SEQUENCE also add slack without changing the count.
subvincdebian confirmed both independently on #1159 for e=3 and e=65537: comment. That comment also links a separate harness that tests these cases.

For transparency: I'm the author of #1159. It takes a different approach: it re-encodes the expected DigestInfo canonically from the parsed OID and digest, then requires a byte-for-byte match. That rejects all of these variants with one comparison. I'm not attached to which PR lands. Whichever one does, it would be good to include regression tests for the NULL-with-content and long-form-length cases.

Element-count checks alone do not close CVE-2026-85393: forge's parser
accepts a NULL parameters field that carries content bytes and
non-minimal long-form length octets, so a count-valid DigestInfo can
still hold attacker-chosen slack bytes usable for the same low-exponent
(e=3) forgery. Identified by Matthew Conroy in review of this PR and
confirmed independently by subvincdebian in digitalbazaar#1159.

Replace the element-count checks with a single canonical-encoding
comparison: re-encode the expected DigestInfo from the parsed OID and
digest (_encodeDigestInfo) and require the decoded bytes to match. Any
encoding slack -- extra elements in either SEQUENCE, NULL parameters
with content, non-minimal length octets -- changes the re-encoded
bytes, so one comparison rejects all variants. Both DigestAlgorithm
forms that occur in practice (with NULL parameters, and without them)
remain accepted.

Add regression tests for the count-preserving variants: NULL
parameters with content bytes, non-minimal long-form length octets,
and a garbage element in the parameters slot.

Fixes CVE-2026-85393 (GHSA-86w9-cpqp-85rv).

Signed-off-by: Shaswata Das <shaswata56@gmail.com>
@shaswata56

Copy link
Copy Markdown
Contributor Author

Thanks for adding the PoC script and the positive DigestInfo tests. Having those in the repo is useful.

A couple of things that should help whoever reviews this:

Overlap with #1152. The lib/rsa.js change here is the same as the one in #1152 (Krysthyan, opened 2026-09-09), comment text included. Both diffs produce the same file (a4264822b..430905451). If this builds on #1152, it would help to say so in the description. It might also be simpler to offer the PoC and the new tests to #1152, so maintainers have one fewer duplicate PR to sort through.

The count-only check leaves some cases open. Requiring the inner DigestAlgorithm SEQUENCE to have 2 elements (or 1) doesn't cover two variants, because both keep the element count valid:

NULL with content: forge's parser doesn't require a NULL to be empty, so the parameters field can carry attacker-chosen bytes (100 bytes is tested) while the inner SEQUENCE stays at exactly 2 elements. Non-minimal length octets: long-form lengths on the inner or outer SEQUENCE also add slack without changing the count. subvincdebian confirmed both independently on #1159 for e=3 and e=65537: comment. That comment also links a separate harness that tests these cases.

For transparency: I'm the author of #1159. It takes a different approach: it re-encodes the expected DigestInfo canonically from the parsed OID and digest, then requires a byte-for-byte match. That rejects all of these variants with one comparison. I'm not attached to which PR lands. Whichever one does, it would be good to include regression tests for the NULL-with-content and long-form-length cases.

Thanks @matthewscottconroy — you're right on both counts, and the count-only check is now gone.

I reproduced both count-preserving bypasses locally against the previous patch using the
regression vectors from #1159's test suite: a NULL parameters field carrying content bytes
and non-minimal long-form length octets both verified as true. The element-count approach
was never going to enumerate its way out of a lenient BER parser — as you and @subvincdebian
demonstrated.

889cee0 replaces the count checks with the canonical re-encoding comparison from your review
(and #1159): after asn1.validate(), the expected DigestInfo is re-encoded from the parsed
OID and digest (_encodeDigestInfo) and compared byte-for-byte against the decoded bytes.
One small difference from #1159's implementation: the expected form is selected based on
whether the NULL parameters were captured (with-parameters vs. parameter-less), since a
captured parameters element means the bytes can't match the parameter-less re-encoding
anyway. Like #1159, the comparison is a prefix comparison so the legacy tolerance of the
internal _parseAllDigestBytes testing flag is preserved; in the default mode
asn1.fromDer() has already rejected trailing bytes, making it an exact match.

Regression tests added for all three count-preserving variants (NULL with content, long-form
length octets, garbage element in the parameters slot), alongside the existing forged-vector
and positive-control tests. Full node suite: 834 passing, 0 failing.

On #1152: the description has credited it since the PR was opened ("Supersedes #1152 — fix
logic reused with credit to @Krysthyan"), together with the reasons for opening a fresh PR
(stale base, a describe.only that masked the whole RSA suite, and a regression vector that
errors at the wrong check when actually executed). Happy to make that more prominent if it
reads as buried.

And agreed on consolidation: if maintainers would rather land #1159, no objection from me.
This branch's PoC, positive controls, and the regression vectors for the NULL-content and
long-form cases are available to cherry-pick wherever they're wanted — whichever PR lands,
those two cases need regression coverage.

@shaswata56

Copy link
Copy Markdown
Contributor Author

Some ecosystem context that may help review, since I checked whether this lenience exists elsewhere: forged the same malformed-DigestInfo vectors (e=3, garbage element in the inner DigestAlgorithm SEQUENCE; NULL parameters carrying content bytes; garbage element in the outer SEQUENCE; non-minimal long-form outer length) against the other mainstream JS RSA verifiers —

  • node:crypto (OpenSSL): rejects all four.
  • jsrsasign 11.1.5: rejects all four (its ASN1HEX parsing is strict on these shapes).
  • node-forge 1.4.0: accepts the first two — exactly the variants this PR's canonical re-encoding comparison rejects.

So forge appears to be the outlier on DigestInfo lenience among JS verifiers, which suggests the canonical-compare direction here matches where the rest of the ecosystem already is. Happy to share the vector-generation harness if useful.

avb-is-me added a commit to keyboard-dev/forge that referenced this pull request Oct 20, 2026
…E-2026-85393)

Backport of the unmerged upstream fix digitalbazaar#1160 for
GHSA-86w9-cpqp-85rv. node-forge <= 1.4.0 only checks the element count of
the outer DigestInfo SEQUENCE, so garbage inside the nested DigestAlgorithm
SEQUENCE, a NULL with content bytes, or non-minimal length octets all pass
validation and allow Bleichenbacher-style signature forgery against
low-exponent keys. Verification now re-encodes the expected DigestInfo from
the parsed OID and digest and requires a byte match.

Keyboard carries this fork only until upstream publishes a release that
includes the fix; drop the override in remote-oauth-server once it does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.29%. Comparing base (7232404) to head (9842c3d).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1160      +/-   ##
==========================================
+ Coverage   80.25%   80.29%   +0.03%     
==========================================
  Files          42       42              
  Lines        9597     9605       +8     
==========================================
+ Hits         7702     7712      +10     
+ Misses       1895     1893       -2     
Files with missing lines Coverage Δ
lib/rsa.js 78.97% <100.00%> (+0.90%) ⬆️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7232404...9842c3d. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Generating three 2048-bit RSA key pairs at runtime exceeds the 30s mocha
timeout on CI legs without native crypto (Node 6.x/8.x), causing flaky
timeouts. Use the existing PEM fixtures instead; the test now runs in
milliseconds.
@d4v1dst

d4v1dst commented Oct 22, 2026

Copy link
Copy Markdown

Hi, any chance this can be merged soon?
This may solve a lot of issues.

@shaswata56

Copy link
Copy Markdown
Contributor Author

The advisory for CVE-2026-85393 (GHSA-86w9-cpqp-85rv) has been public since Sep 3 with <= 1.4.0 affected and no patched release, and there are now six open PRs proposing fixes (#1157, #1158, #1159, #1160, #1161, #1162) — while downstream npm audit failures have started citing these PRs as the awaited fix. To make the eventual decision easier, here's the field:

Converged design — canonical re-encode + compare (build the expected DigestInfo with the library's own DER encoder, require the decrypted bytes to match):

Targeted element checks — #1157, #1158, #1161: subsets of canonical compare; #1159's vectors demonstrate bypasses of shape/count-only checks (non-empty NULL parameters, non-minimal lengths), which canonical compare rejects by construction.

Proposal: ship one of the canonical-compare PRs as 1.4.1. I'm happy to do the legwork either way — fold #1162's shared-encoder refactor into this branch (or rebase onto whichever PR is preferred) and assemble a combined regression suite from all six. @matthewscottconroy — you know these vectors best, would value your call on which candidate should land. @davidlehn — anything I can take off your plate to get a 1.4.1 out the door?

davidlehn added a commit that referenced this pull request Oct 27, 2026
- [rsa] Require a canonical DER DigestInfo in RSASSA PKCS#1 v1.5 signature
  verification by re-encoding the expected DigestInfo and comparing bytes,
  as in RFC 8017 8.2.2. Rejects extra DigestAlgorithm elements, NULL
  parameters with content, non-minimal lengths, and non-canonical OIDs.
- Address GHSA-2g46-22mc-6gwp (CVE-2026-85393):
  - GHSA-2g46-22mc-6gwp
- Address GHSA-6qrw-m8cv-hcxf:
  - GHSA-6qrw-m8cv-hcxf
- Based on work in PRs #1159 (@matthewscottconroy), #1160 (@shaswata56),
  #1161 (@nitrog7), and #1162 (@RoastFried-RF).
- Add PoC from PR #1160.
- Add tests for canonical and non-canonical DigestInfo encodings, and for
  every supported digest algorithm with and without NULL parameters.

Co-authored-by: Matthew Conroy <matthew.conroy@ibm.com>
Co-authored-by: Shaswata Das <shaswata56@gmail.com>
@davidlehn

davidlehn commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Thank you. The keyless e=3 forgery vector, the e=1 test approach and the PoC (now tests/pocs/ghsa-2g46-22mc-6gwp.js) are in node-forge 1.4.1. The commit lists you as co-author, and you're credited in GHSA-2g46-22mc-6gwp. Closing, as the fix landed in a combined security release.

@davidlehn davidlehn closed this Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants