ML-KEM Decapsulate Leaks Re-encryption Randomness via Subarray Buffer
CVSS v4.0
Vector: CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N
Score: ~5.1 (Medium)
Rationale: The attack vector is Local because exploitation requires access to the returned sharedSecret object reference within the same JavaScript execution context (or memory inspection capability). The leaked data is cryptographic randomness rather than direct plaintext, resulting in Low confidentiality impact to both the vulnerable and subsequent systems.
CWE
CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
Affected Files
src/ml-kem.ts:324-331—decapsulatereturnsKhat = kr.subarray(0, 32)without cleaningkr[32..64]src/ml-kem.ts:330—cleanBytescall cleans the wrong key but not the randomness inkr
Description
The decapsulate function computes kr = HASH512(msg || publicKeyHash), producing a 64-byte value. The first 32 bytes (Khat = kr.subarray(0, 32)) become the shared secret, and the last 32 bytes (kr.subarray(32, 64)) are the re-encryption randomness used to verify the ciphertext.
When decapsulation succeeds (valid ciphertext), the function returns Khat, which is a TypedArray view (subarray) into the 64-byte kr buffer. The code at line 330 calls cleanBytes(msg, cipherText2, !isValid ? Khat : Kbar), which in the valid case cleans Kbar (the rejection key), but does not clean kr.subarray(32, 64).
The returned sharedSecret is a 32-byte view into a 64-byte ArrayBuffer. A caller (or any code with access to the returned value) can access the full 64-byte buffer via sharedSecret.buffer, exposing the 32-byte re-encryption randomness at bytes 32-63. This randomness is security-critical: it is the seed used to re-encrypt the message, and knowing it allows an attacker to recover the plaintext message msg from the ciphertext.
By contrast, the encapsulate function correctly calls cleanBytes(kr.subarray(32)) before returning, zeroing out the randomness portion. The asymmetry indicates this is an oversight in decapsulate.
Reproduction Steps
- Generate a keypair and perform encapsulation:
import { ml_kem768 } from './src/ml-kem.ts'; const keys = ml_kem768.keygen(); const { cipherText } = ml_kem768.encapsulate(keys.publicKey); - Decapsulate and examine the underlying buffer:
const sharedSecret = ml_kem768.decapsulate(cipherText, keys.secretKey); console.log(sharedSecret.length); // 32 console.log(sharedSecret.buffer.byteLength); // 64 const leaked = new Uint8Array(sharedSecret.buffer).subarray(32, 64); console.log(leaked); // Non-zero 32-byte re-encryption randomness is visible! - The leaked 32 bytes at positions 32-63 are the re-encryption randomness
kr[32..64].
Impact
- Secret information disclosure: The 32 bytes of re-encryption randomness leaked through the buffer are sufficient to recover the plaintext message
msg. An attacker with memory read access (e.g., through a memory dump, garbage collector artifact, or shared WebAssembly memory) can extract this randomness. - FO-Transform security degradation: The Fujisaki-Okamoto transform's CCA2 security relies on the re-encryption randomness being secret. Leaking it degrades the security guarantee.
- Inconsistency with encapsulate: The
encapsulatefunction properly cleanskr.subarray(32), showing the developer intended to protect this value. The omission indecapsulateis an inconsistency.
Suggested Fix
Clean the randomness portion of kr before returning, and return an independent copy:
const Khat = kr.subarray(0, 32);
const cipherText2 = KPKE.encrypt(publicKey, msg, kr.subarray(32, 64));
const isValid = equalBytes(cipherText, cipherText2);
const Kbar = KDF.create({ dkLen: 32 }).update(z).update(cipherText).digest();
const result = Uint8Array.from(isValid ? Khat : Kbar);
cleanBytes(msg, cipherText2, kr, Kbar);
return result;