CVE-2026-24850
Description
The ML-DSA crate is a Rust implementation of the Module-Lattice-Based Digital Signature Standard (ML-DSA). Starting in version 0.0.4 and prior to version 0.1.0-rc.4, the ML-DSA signature verification implementation in the RustCrypto ml-dsa crate incorrectly accepts signatures with repeated (duplicate) hint indices. According to the ML-DSA specification (FIPS 204 / RFC 9881), hint indices within each polynomial must be strictly increasing. The current implementation uses a non-strict monotonic check (<= instead of <), allowing duplicate indices. This is a regression bug. The original implementation was correct, but a commit in version 0.0.4 inadvertently changed the strict < comparison to <=, introducing the vulnerability. Version 0.1.0-rc.4 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
ml-dsacrates.io | >= 0.0.4, < 0.1.0-rc.4 | 0.1.0-rc.4 |
Affected products
1- Range: dsa-v0.7.0-rc.10, dsa/v0.2.0, dsa/v0.3.0, …
Patches
2400961412be2ml-dsa: fix Wycheproof verification test failures (#1187)
2 files changed · +6 −27
ml-dsa/src/hint.rs+5 −6 modified@@ -115,17 +115,15 @@ where y } - fn monotonic(a: &[usize]) -> bool { - a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] <= *x) - } - pub(crate) fn bit_unpack(y: &EncodedHint<P>) -> Option<Self> { let (indices, cuts) = P::split_hint(y); let cuts: Array<usize, P::K> = cuts.iter().map(|x| usize::from(*x)).collect(); let indices: Array<usize, P::Omega> = indices.iter().map(|x| usize::from(*x)).collect(); let max_cut: usize = cuts.iter().copied().max().unwrap(); - if !Self::monotonic(&cuts) + + // cuts must be monotonic but can repeat + if !cuts.windows(2).all(|w| w[0] <= w[1]) || max_cut > indices.len() || indices[max_cut..].iter().copied().max().unwrap_or(0) > 0 { @@ -137,7 +135,8 @@ where for (i, &end) in cuts.iter().enumerate() { let indices = &indices[start..end]; - if !Self::monotonic(indices) { + // indices must be strictly increasing + if !indices.windows(2).all(|w| w[0] < w[1]) { return None; }
ml-dsa/tests/wycheproof.rs+1 −21 modified@@ -103,9 +103,6 @@ macro_rules! mldsa_sign_seed_test { macro_rules! mldsa_verify_test { ($name:ident, $json_file:expr, $keypair:ident) => { - mldsa_verify_test!($name, $json_file, $keypair, []); - }; - ($name:ident, $json_file:expr, $keypair:ident, $skip:expr) => { #[test] fn $name() { let tests = load_json_file!($json_file); @@ -116,11 +113,6 @@ macro_rules! mldsa_verify_test { for test in &group.tests { println!("Test #{}: {} ({:?})", test.id, &test.comment, &test.result); - if $skip.contains(&test.id) { - println!("Test #{} is in skip list, skipping!", test.id); - continue; - } - if let Some(sig) = test .sig .as_slice() @@ -150,10 +142,6 @@ macro_rules! mldsa_verify_test { }; } -// TODO(tarcieri): debug these test failures -const SIGNATURE_WITH_A_REPEATED_HINT_TEST_ID: usize = 18; -const PUBLIC_KEY_WITH_T1_COMPONENT_SET_TO_ZERO_TEST_ID: usize = 68; - mldsa_sign_seed_test!( mldsa_44_sign_seed_test, "mldsa_44_sign_seed_test.json", @@ -169,14 +157,6 @@ mldsa_sign_seed_test!( "mldsa_87_sign_seed_test.json", MlDsa87 ); -mldsa_verify_test!( - mldsa_44_verify_test, - "mldsa_44_verify_test.json", - MlDsa44, - [ - SIGNATURE_WITH_A_REPEATED_HINT_TEST_ID, - PUBLIC_KEY_WITH_T1_COMPONENT_SET_TO_ZERO_TEST_ID - ] -); +mldsa_verify_test!(mldsa_44_verify_test, "mldsa_44_verify_test.json", MlDsa44); mldsa_verify_test!(mldsa_65_verify_test, "mldsa_65_verify_test.json", MlDsa65); mldsa_verify_test!(mldsa_87_verify_test, "mldsa_87_verify_test.json", MlDsa87);
b01c3b73dd08Make ML-DSA signature decoding follow the spec (#895)
3 files changed · +93 −5
ml-dsa/src/algebra.rs+50 −0 modified@@ -210,3 +210,53 @@ impl<K: ArraySize> AlgebraExt for Vector<K> { ) } } + +#[cfg(test)] +mod test { + use super::*; + + use crate::{MlDsa65, ParameterSet}; + + type Mod = <MlDsa65 as ParameterSet>::TwoGamma2; + const MOD: u32 = Mod::U32; + const MOD_ELEM: Elem = Elem::new(MOD); + + #[test] + fn mod_plus_minus() { + for x in 0..MOD { + // BaseField::Q { + let x = Elem::new(x); + let x0 = x.mod_plus_minus::<Mod>(); + + // Outputs from mod+- should be in the half-open interval (-gamma2, gamma2] + let positive_bound = x0.0 <= MOD / 2; + let negative_bound = x0.0 > BaseField::Q - MOD / 2; + assert!(positive_bound || negative_bound); + + // The output should be equivalent to the input, mod 2 * gamma2. We add 2 * gamma2 + // before comparing so that both values are "positive", avoiding interactions between + // the mod-Q and mod-M operations. + let xn = x + MOD_ELEM; + let x0n = x0 + MOD_ELEM; + assert_eq!(xn.0 % MOD, x0n.0 % MOD); + } + } + + #[test] + fn decompose() { + for x in 0..MOD { + let x = Elem::new(x); + let (x1, x0) = x.decompose::<Mod>(); + + // The low-order output from decompose() is a mod+- output, optionally minus one. So + // they should be in the closed interval [-gamma2, gamma2]. + let positive_bound = x0.0 <= MOD / 2; + let negative_bound = x0.0 >= BaseField::Q - MOD / 2; + assert!(positive_bound || negative_bound); + + // The low-order and high-order outputs should combine to form the input. + let xx = (MOD * x1.0 + x0.0) % BaseField::Q; + assert_eq!(xx, x.0); + } + } +}
ml-dsa/src/hint.rs+4 −4 modified@@ -22,18 +22,18 @@ fn use_hint<TwoGamma2: Unsigned>(h: bool, r: Elem) -> Elem { let gamma2 = TwoGamma2::U32 / 2; if h && r0.0 <= gamma2 { Elem::new((r1.0 + 1) % m) - } else if h && r0.0 > BaseField::Q - gamma2 { + } else if h && r0.0 >= BaseField::Q - gamma2 { Elem::new((r1.0 + m - 1) % m) } else if h { // We use the Elem encoding even for signed integers. Since r0 is computed - // mod+- 2*gamma2, it is guaranteed to be in (gamma2, gamma2]. + // mod+- 2*gamma2 (possibly minus 1), it is guaranteed to be in [-gamma2, gamma2]. unreachable!(); } else { r1 } } -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Debug)] pub struct Hint<P>(pub Array<Array<bool, U256>, P::K>) where P: SignatureParams; @@ -116,7 +116,7 @@ where } fn monotonic(a: &[usize]) -> bool { - a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] < *x) + a.iter().enumerate().all(|(i, x)| i == 0 || a[i - 1] <= *x) } pub fn bit_unpack(y: &EncodedHint<P>) -> Option<Self> {
ml-dsa/src/lib.rs+39 −1 modified@@ -89,7 +89,7 @@ pub use crate::util::B32; pub use signature::Error; /// An ML-DSA signature -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Debug)] pub struct Signature<P: MlDsaParams> { c_tilde: Array<u8, P::Lambda>, z: Vector<P::L>, @@ -899,4 +899,42 @@ mod test { sign_verify_round_trip_test::<MlDsa65>(); sign_verify_round_trip_test::<MlDsa87>(); } + + fn many_round_trip_test<P>() + where + P: MlDsaParams, + { + use rand::Rng; + + const ITERATIONS: usize = 1000; + + let mut rng = rand::thread_rng(); + let mut seed = B32::default(); + + for _i in 0..ITERATIONS { + let seed_data: &mut [u8] = seed.as_mut(); + rng.fill(seed_data); + + let kp = P::key_gen_internal(&seed); + let sk = kp.signing_key; + let vk = kp.verifying_key; + + let M = b"Hello world"; + let rnd = Array([0u8; 32]); + let sig = sk.sign_internal(&[M], &rnd); + + let sig_enc = sig.encode(); + let sig_dec = Signature::<P>::decode(&sig_enc).unwrap(); + + assert_eq!(sig_dec, sig); + assert!(vk.verify_internal(&[M], &sig_dec)); + } + } + + #[test] + fn many_round_trip() { + many_round_trip_test::<MlDsa44>(); + many_round_trip_test::<MlDsa65>(); + many_round_trip_test::<MlDsa87>(); + } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
12- github.com/advisories/GHSA-5x2r-hc65-25f9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24850ghsaADVISORY
- csrc.nist.gov/pubs/fips/204/finalnvdWEB
- datatracker.ietf.org/doc/html/rfc9881nvdWEB
- github.com/C2SP/wycheproof/blob/master/testvectors_v1/mldsa_44_verify_test.jsonnvdWEB
- github.com/C2SP/wycheproof/blob/master/testvectors_v1/mldsa_65_verify_test.jsonnvdWEB
- github.com/C2SP/wycheproof/blob/master/testvectors_v1/mldsa_87_verify_test.jsonnvdWEB
- github.com/RustCrypto/signatures/commit/400961412be2e2ab787942cf30e0a9b66b37a54anvdWEB
- github.com/RustCrypto/signatures/commit/b01c3b73dd08d0094e089aa234f78b6089ec1f38nvdWEB
- github.com/RustCrypto/signatures/issues/894nvdWEB
- github.com/RustCrypto/signatures/pull/895nvdWEB
- github.com/RustCrypto/signatures/security/advisories/GHSA-5x2r-hc65-25f9nvdWEB
News mentions
0No linked articles in our index yet.