Back to Cases
MediumCWE-208

CBC/ECB PKCS#7 Padding Validation Timing Oracle

noble-ciphers2026-02-23CWE-208Medium

CVSS v4.0

Score: CVSS-BT 6.3 / Medium Vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P

MetricValueRationale
AVNetworkThe padding oracle attack targets network-accessible decryption services (APIs, servers) that use this library for CBC/ECB decryption
ACLowNo security mechanisms (ASLR, DEP, etc.) need to be bypassed; the timing difference is ~4.5x (750ns vs 3300ns), making the oracle deterministic
ATPresentExploitation requires the application to expose a CBC/ECB decryption endpoint where the attacker can submit arbitrary ciphertexts and observe timing. This is an architectural deployment condition beyond the attacker's control
PRNoneNo authentication needed to submit ciphertexts to the decryption oracle (in the standard padding oracle threat model)
UINoneNo user interaction required
VCHighFull plaintext recovery of any CBC-encrypted message — complete break of confidentiality demonstrated via Vaudenay padding oracle attack
VINoneThe attack recovers plaintext but does not allow data modification
VANoneThe attack requires many oracle queries (~2048 per block) but does not cause denial of service
SCNoneNo direct impact on downstream/subsequent systems from the padding oracle itself
SINoneNo downstream integrity impact
SANoneNo downstream availability impact
EPoCA working PoC exists that recovers full plaintext with 100% success rate across multiple runs

CWE

CWE-208: Observable Timing Discrepancy

Affected Files

  • src/aes.ts:404-414validatePCKS function with data-dependent iteration count
  • src/aes.ts:470 — Called in ECB decrypt path
  • src/aes.ts:535 — Called in CBC decrypt path

Description

The validatePCKS function in aes.ts validates PKCS#7 padding on decrypted data using a variable-time algorithm. The function at line 408-412 reads the last byte of the plaintext to determine the expected padding length, performs an early return if the byte is out of range (<= 0 or > 16), and then checks each padding byte in a loop that terminates early on the first mismatch via throw.

function validatePCKS(data: Uint8Array, pcks5: boolean) {
  if (!pcks5) return data;
  const len = data.length;
  if (!len) throw new Error('aes/pcks5: empty ciphertext not allowed');
  const lastByte = data[len - 1];
  if (lastByte <= 0 || lastByte > 16) throw new Error('aes/pcks5: wrong padding');
  const out = data.subarray(0, -lastByte);
  for (let i = 0; i < lastByte; i++)
    if (data[len - i - 1] !== lastByte) throw new Error('aes/pcks5: wrong padding');
  return out;
}

The timing characteristics leak information about the padding:

  1. Valid padding path (line 413): returns data.subarray(0, -lastByte) — no exception thrown (~750 ns).
  2. Invalid padding path (lines 409, 412): throws new Error(...) — JavaScript exception construction with stack trace capture (~3,125–3,500 ns).

The dominant timing signal is JavaScript exception creation overhead (stack trace capture), not the variable loop iteration count. The throw new Error(...) on invalid padding involves ~2,500 ns more than the return data.subarray() on valid padding. The ratio is ~4.1–4.7x, making the oracle trivially distinguishable.

This is the classic padding oracle attack surface described by Vaudenay (2002). An attacker who can observe the timing of decrypt operations can iteratively decrypt arbitrary CBC-encrypted ciphertext without knowing the key.

Reproduction Steps

  1. Set up a server that uses @noble/ciphers/aes.js CBC mode with PKCS#7 padding to decrypt user-supplied ciphertext.
  2. Send ciphertexts with different manipulated last blocks and measure response times.
  3. A ciphertext whose decrypted padding is valid returns fastest (~750 ns, no exception thrown).
  4. A ciphertext whose decrypted padding is invalid returns slower (~3,300 ns, exception creation overhead).
  5. Use timing differences to distinguish between valid and invalid padding, then apply the standard Vaudenay padding oracle attack to decrypt byte-by-byte.
import { cbc } from '@noble/ciphers/aes.js';
import { randomBytes } from '@noble/ciphers/utils.js';

const key = randomBytes(16);
const iv = randomBytes(16);

// Encrypt a message
const encrypted = cbc(key, iv, { disablePadding: false }).encrypt(
  new TextEncoder().encode('secret message!!')
);

// Attacker has encrypted and iv, but not key
// For each byte position, try all 256 possible values for the last byte of IV
// and measure timing of decrypt to determine padding validity
for (let guess = 0; guess < 256; guess++) {
  const modifiedIv = Uint8Array.from(iv);
  modifiedIv[15] ^= guess;
  const start = process.hrtime.bigint();
  try {
    cbc(key, modifiedIv).decrypt(encrypted);
  } catch (e) {}
  const elapsed = process.hrtime.bigint() - start;
  // Correlate timing with guess value to find valid padding
}

Impact

An attacker who can submit ciphertexts for decryption and observe timing (directly or via network-level timing) can recover the full plaintext of any CBC-encrypted message without knowing the key. This is a complete break of confidentiality for CBC mode with PKCS#7 padding.

The PoC demonstrates full plaintext recovery of all 16 bytes with 100% success rate across multiple runs. Per-byte recovery requires ~128 oracle queries on average (256 in worst case), so a 16-byte block requires ~2,048 queries. Both cbc and ecb modes are affected when PKCS#7 padding is enabled (the default).

Suggested Fix

The fix must address both the variable-time loop AND the exception-vs-return timing difference:

  1. Implement constant-time PKCS#7 padding validation that always performs the same operations regardless of padding validity.
  2. Avoid data-dependent exception throwing — restructure so the function returns a status indicator rather than throwing, or ensure the same code path is taken for valid and invalid padding.
function validatePCKS(data: Uint8Array, pcks5: boolean) {
  if (!pcks5) return data;
  const len = data.length;
  if (!len) throw new Error('aes/pcks5: empty ciphertext not allowed');
  const lastByte = data[len - 1];
  // Constant-time: always check all 16 possible padding bytes
  let valid = 1; // 1 = valid, 0 = invalid
  // Check lastByte is in range [1, 16]
  valid &= ((lastByte - 1) >>> 31) ^ 1; // lastByte >= 1
  valid &= ((16 - lastByte) >>> 31) ^ 1; // lastByte <= 16
  // Check all padding bytes (always check 16 bytes)
  for (let i = 0; i < 16; i++) {
    const shouldCheck = ((i - lastByte) >>> 31); // 1 if i < lastByte
    const byteOk = ((data[len - 1 - i] ^ lastByte) === 0) ? 1 : 0;
    valid &= byteOk | (shouldCheck ^ 1);
  }
  if (!valid) throw new Error('aes/pcks5: wrong padding');
  return data.subarray(0, -lastByte);
}