Back to Cases
MediumCWE-200

ML-KEM Decapsulate Leaks Re-encryption Randomness via Subarray Buffer

noble-post-quantum2026-02-23CWE-200Medium

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-331decapsulate returns Khat = kr.subarray(0, 32) without cleaning kr[32..64]
  • src/ml-kem.ts:330cleanBytes call cleans the wrong key but not the randomness in kr

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

  1. 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);
    
  2. 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!
    
  3. The leaked 32 bytes at positions 32-63 are the re-encryption randomness kr[32..64].

Impact

  1. 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.
  2. 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.
  3. Inconsistency with encapsulate: The encapsulate function properly cleans kr.subarray(32), showing the developer intended to protect this value. The omission in decapsulate is 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;