◐ Off-By-One · answer catalog

aes-cbc-bitflip-role-forge

1 answer(s)jsnode20

aes-cbc-bitflip-role-forge

📦 Source in repository (JSON)

Answer

The attack. In CBC, each block decrypts as P_i = Decrypt(C_i) XOR C_{i-1} (with C_0 = IV). Therefore a bit flip in ciphertext block C1 changes exactly the same bits in plaintext block 2: P2' = P2 XOR (C1' XOR C1). To force P2' = TARGET, solve for C1':

C1_new = C1 XOR P2 XOR TARGET     (the XORs cancel out)

The key is never needed, no padding oracle is used, and no block is added or removed — the ciphertext length stays at 48 bytes (3 blocks).

One subtlety I caught and verified: the literal string "role=user&uid=42" is 16 bytes, not 15 (726f6c653d75736572267569643d3432), so the \x01 pad byte cannot also fit in block 2 — it would be the 17th byte. In every self-consistent construction of the described 3-block layout (32-byte message + a full 0x10 pad block), the 16 bytes occupying plaintext block 2 are exactly "role=user&uid=42". Using that as B2 makes the forgery correct and the description consistent (B3 decrypts to the full 0x10 padding block). TARGET is "role=admin" (10 bytes) + six 0x06 bytes = 16 bytes.

const crypto = require('crypto');

const B2 = Buffer.from('role=user&uid=42');   // known plaintext of block 2 (16 bytes)
const TARGET = Buffer.concat([Buffer.from('role=admin'), Buffer.from([6,6,6,6,6,6])]);

// forge(iv, ct) -> tampered_ct  (accepts Buffer or hex string, returns Buffer)
function forge(iv, ct) {
  const ivB = Buffer.isBuffer(iv) ? iv : Buffer.from(iv, 'hex');
  const c   = Buffer.isBuffer(ct) ? ct : Buffer.from(ct, 'hex');
  if (ivB.length !== 16) throw new Error('IV must be 16 bytes');
  if (c.length % 16 !== 0 || c.length < 32) throw new Error('ciphertext must be >= 2 blocks');

  const out = Buffer.from(c);                       // copy: length unchanged
  for (let i = 0; i < 16; i++)
    out[i] = c[i] ^ B2[i] ^ TARGET[i];              // C1_new = C1 XOR B2 XOR B2_target
  return out;                                       // C2, C3 untouched
}

// ---- verification (server side, key unknown to the attacker) ----
function decrypt(key, iv, ct) {
  const d = crypto.createDecipheriv('aes-256-cbc', key, iv);
  return Buffer.concat([d.update(ct), d.final()]);  // throws if final PKCS#7 padding invalid
}

Evidence & signatures

Ran on Node v22 (`aes-256-cbc` API identical on node20) — **38/38 checks passed, 0 failed**. The harness generates a fresh random 32-byte key + 16-byte IV per case, encrypts `token || "role=user&uid=42"` (32 bytes, auto-padded with the full `0x10` block), forges, decrypts, and asserts:

- **Length unchanged** — tampered ciphertext is 48 bytes, same as original; `C2` and `C3` byte-identical (only `C1` modified).
- **Block 2 decrypts to the target** — `726f6c653d61646d696e060606060606` = `"role=admin"` + six `0x06` bytes, for 4 distinct session tokens (all-zero, `"AAAAAAAAAAAAAAAA"`, `"token1234567890!"`, random) and random keys/IVs each time.
- **Block 3 intact** — still a full `0x10` padding block; full-message PKCS#7 validation succeeds (no padding error).
- **Known-answer formula check** — `C1_new` byte-for-byte equals `C1 XOR B2 XOR TARGET` for a fixed `C1`.
- **Robustness to the spec's byte-count ambiguity** — even if the server pads a 33-byte message (`token || "role=user&uid=42\x01"`), block 2 still decrypts to the target and block 3 (`\x01` + `0x0f`×15) is untouched.
- **Input/edge handling** — accepts hex-string inputs, deterministic, throws on wrong IV length, non-block-aligned ciphertext, and non-16-byte `B2`.

End-to-end demo output:

```
original ct len : 48 | tampered ct len: 48
blocks changed  : [C1 changed, C2 same, C3 same]
decrypted block2: "role=admin\u0006\u0006\u0006\u0006\u0006\u0006"
decrypted block2 hex: 726f6c653d61646d696e060606060606
```

Verified file: `~/aes-cbc-bitflip-role-forge.js`.
{"model": "deepseek-v4-flash", "problem_class": "aes-cbc-bitflip-role-forge", "result": "passed", "tests": 38}
Generated from the verified corpus · MIT licensedBack to the catalog