Jan 28, 2026·6 min read·6 visits
The RustCrypto ML-DSA implementation allowed invalid signatures with duplicate hint indices due to a coding error (`<=` vs `<`). This creates signature malleability, allowing attackers to tweak a signature without invalidating it, effectively breaking applications that rely on signature hash uniqueness (like blockchains).
A subtle logic error in the Rust `ml-dsa` crate implementation of the Module-Lattice-Based Digital Signature Standard (ML-DSA) allowed for signature malleability. By relaxing a strict inequality check (`<`) to a non-strict one (`<=`) during hint verification, the implementation inadvertently accepted non-canonical signatures containing duplicate hint indices. While this does not compromise the private key, it breaks FIPS 204 compliance and poses significant risks to distributed ledgers and systems relying on signature uniqueness.
We are standing on the precipice of the Post-Quantum era. The algorithms that have guarded our secrets for decades (RSA, ECC) are being retired in favor of lattice-based beasts like ML-DSA (Module-Lattice-Based Digital Signature Standard), formerly known as Dilithium. The promise is simple: math so complex even a quantum computer gets a headache trying to solve it.
But here is the cynical reality of security engineering: you can have the most theoretically sound mathematical proof in the universe, printed on gold leaf and blessed by NIST, but if the developer typing it into VS Code makes a typo, it’s game over. The ml-dsa crate, a flagship implementation in the RustCrypto ecosystem, fell victim to exactly this. It wasn't a buffer overflow (this is Rust, after all). It wasn't a use-after-free. It was a single character.
This vulnerability, CVE-2026-24850, is a classic case of "implementation divergence." The spec said one thing, the code did another, and for a brief window, the cryptographic guarantees of uniqueness were thrown out the window. It serves as a stark reminder that in cryptography, "close enough" is the same as "completely broken."
To understand this bug, we have to look at how ML-DSA handles "hints." In lattice-based cryptography, signatures involve a lot of noise handling. When verifying a signature, the verifier needs a little help to reconstruct the high-order bits of the polynomial vector. These helpers are called "hints." They are essentially indices pointing to coefficients that need adjustment.
The FIPS 204 specification (Algorithm 26, HintBitUnpack) is explicitly clear about how these hints should be encoded: the indices must be strictly increasing. That means if your first hint is at index 5, the next one must be 6 or higher. You cannot have hint 5 followed by hint 5. Mathematically, the set of indices must be strictly ordered ($y_i < y_{i+1}$).
The developers of ml-dsa attempted to enforce this validation using a helper function. However, in version 0.0.4, a regression slipped in. Instead of checking if the sequence was strictly increasing, they checked if it was merely monotonic (non-decreasing). They used a less-than-or-equal-to operator (<=) where a strictly less-than operator (<) was required. This subtle distinction meant that a signature containing the hint sequence [1, 5, 5, 9] was accepted as valid, even though the spec—and logic—demands [1, 5, 9].
Let's look at the Rust code that caused the headache. The vulnerability lived in ml-dsa/src/hint.rs. The intention was to validate that the hint indices provided in the signature were sorted correctly.
The Vulnerable Code:
fn monotonic(a: &[usize]) -> bool {
// The bug: allowing equality means duplicates are okay.
// If a[i-1] == 5 and x == 5, this returns true.
a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] <= *x)
}By using <=, the check passes even if a[i-1] is equal to *x. This allows duplicate hints to slide past the goaltender. In a high-stakes crypto library, this is the equivalent of a bouncer checking IDs but letting in photocopies.
The Fix (Commit 4009614):
The remediation was swift and decisive. The developers replaced the custom monotonic check with a functional approach using Rust's windows iterator to enforce strict inequality.
// The fixed logic enforces strict ascending order
if !indices.windows(2).all(|w| w[0] < w[1]) {
return None;
}They also fixed a secondary issue in use_hint where a boundary check was too aggressive (>) instead of inclusive (>=), ensuring that edge-case values near the field modulus are handled correctly.
So, how do we weaponize a duplicated index? We aren't recovering the private key here, so we can't forge signatures from scratch. However, we can perform a Signature Malleability attack. This is where an attacker takes an existing, valid signature (r, s) and transforms it into (r, s') which is also valid for the same message, but has a different byte representation.
The Attack Scenario:
[10, 42]. The transaction ID is Hash(Message || S).[10, 10, 42]. Because of the bug, the validator accepts this.Hash(Message || S'). Alice's original wallet software, watching for the original ID, might assume the transaction failed and resend it, or the system might process the payment twice if deduplication logic relies solely on the signature hash.This specific vector was caught by Wycheproof Test ID 18, which is specifically designed to feed signatures with repeated hints to verifiers to see if they choke. In this case, ml-dsa didn't choke—it swallowed the bad data with a smile.
You might be thinking, "So what? The signature still verifies." In many contexts, like a simple JWT login, this is true. The server verifies the signature, sees it's valid, and logs you in. No harm done.
However, in financial systems, blockchain protocols, and distributed ledgers, uniqueness is security. Bitcoin faced a massive headache with transaction malleability (leading to the Mt. Gox implosion debates and eventually SegWit). If an attacker can change the unique identifier of a transaction without invalidating it, they can wreak havoc on accounting systems, bypass double-spend protections that rely on ID caching, and break smart contract logic that assumes SigHash is immutable.
Furthermore, this breaks FIPS 204 compliance. If you are a government contractor or a regulated entity claiming to use "Standardized Post-Quantum Crypto," running this version of the crate makes that claim false. You aren't running FIPS 204; you're running "FIPS 204-ish with loose validation."
The fix is straightforward: upgrade. The maintainers released version 0.1.0-rc.4 which includes the strict monotonicity check and the boundary fix.
For Developers:
Cargo.toml: ml-dsa = "0.1.0-rc.4".The Lesson:
This vulnerability reinforces the importance of using negative test vectors. The Wycheproof project (maintained by Google) specifically tests for these edge cases because developers constantly get them wrong. When implementing crypto, passing the "Happy Path" tests is meaningless. You are only as secure as your ability to reject the "Unhappy Path."
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
ml-dsa RustCrypto | >= 0.0.4, < 0.1.0-rc.4 | 0.1.0-rc.4 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-347 |
| CVSS | 5.3 (Medium) |
| Attack Vector | Network |
| Impact | Signature Malleability / Integrity |
| Exploit Status | Proof of Concept Available (Wycheproof) |
| Language | Rust |
Improper Verification of Cryptographic Signature