◐ Off-By-One · answer catalog

js-sha256-length-extension-mac

1 answer(s)jsnode20

js-sha256-length-extension-mac

📦 Source in repository (JSON)

Answer

Attack theory. MAC = SHA-256(secret ‖ message) is vulnerable because SHA-256 is a Merkle–Damgård hash: its 32-byte digest is the final 8-word chaining state. The padding (0x80, zeros, 64-bit length) is deterministic and depends only on the total byte length — not on the secret's content. So an attacker who knows only the length of the secret can:

  1. Reconstruct state: split the captured MAC into 8 big-endian 32-bit words → the exact internal state after hashing secret ‖ message.
  2. Compute glue padding: glue = SHA256_padding(secretLen + |message|). The prefix secret ‖ message ‖ glue is now a whole number of 64-byte blocks, and its chaining state is exactly the recovered state.
  3. Continue the chain: run the compression function over appended ‖ SHA256_padding(secretLen + |forged|) starting from the recovered state — producing a valid MAC for message ‖ glue ‖ appended, with no knowledge of the secret.
  4. Since the secret length is unknown, brute-force the guess secretLen ∈ 1..64; the service's verify oracle accepts only the correct guess.

Fix. Use an unkeyed-hash-resistant construction: HMAC-SHA256 (crypto.createHmac), or prepend the secret to the length-padded message (SHA256(secret ‖ SHA256(message))), or use crypto.timingSafeEqual only after moving off SHA256(key‖msg) entirely. The code below fixes the vulnerable service:

// service.js — vulnerable (used by the challenge)
const mac = (message) => sha256(concat(secret, message));   // ← broken

// FIX — use an HMAC (keyed) construction instead:
const crypto = require('crypto');
const mac = (message) =>
  crypto.createHmac('sha256', secret).update(message).digest();  // ← length-extension safe
// (or: sha256(concat(secret, sha256(message))) — message digest covers length)

Exploit (pure JS, no crypto library for state manipulation) — files in /workspace:

// sha256.js — pure-JS FIPS 180-4 (rotr/σ/Σ/Ch/Maj, 64-round compress, padding)
function compress(state, block) { /* 16-word schedule + 64 rounds on Uint32Array */ }
function pad(len) { /* 0x80 + zeros until len%64===56 + 8-byte BE bit-length */ }

// length_extension.js — the forgery
function forge(secretLenGuess, capturedMessage, capturedMac, appended) {
  const state = stateFromBytes(capturedMac);              // 1. recover 8-word state
  const glue  = pad(secretLenGuess + capturedMessage.length); // 2. glue padding
  const forgedMessage = concat(capturedMessage, glue, appended);
  const tail  = concat(appended, pad(secretLenGuess + forgedMessage.length));
  update(state, tail);                                    // 3. compress appended+final pad
  return { forgedMessage, forgedMac: stateToBytes(state) }; // 4. forged MAC
}

Full run: node attack.js (driver) and node test.js (suite).

Evidence & signatures

**Verification performed** (Node v22, matches `node20` semantics; all pure-JS attack path — `node:crypto` used only as an independent oracle in tests):

- **SHA-256 correctness**: passed all standard vectors — empty string, `"abc"`, FIPS 180-4 two-block vector, 1,000,000×`a` (`cdc76e5c...12cd0`), plus 12 random sizes across block boundaries (0,1,55,56,57,63,64,65,127,128,129,1000,4097 bytes) matched `node:crypto`.
- **Grid forgery test**: 64 secret lengths × 9 message sizes × 9 appended sizes = **5,184 combinations** — every forged MAC equaled `SHA-256(secret ‖ forgedMessage)` and passed the service's constant-time compare; every *wrong* secret-length guess was rejected.
- **Full exploit**: with only a captured `(message, MAC)` pair and a verify oracle, the secret length was recovered (tested 1, 8, 16, 32, 64) and a forged request ending in `&access=granted` was accepted.
- **Edge cases**: empty captured message, empty appended payload, 300-byte appended spanning multiple blocks — all accepted. Glue padding length correctly spans 9–72 bytes including the two-block case (message length ≡ 56 mod 64, e.g. `0x80 + 63 zeros + len`).
- **Timing-safe compare**: verified equal/different/length-mismatch behavior (no short-circuit on mismatch).

**Live proof** (`node attack.js`): captured `MAC 7774b7a6...da8507` for `count=10&lat=37.351&lng=-119.827&admin=0`; forged 72-byte glue + `&access=granted` → `MAC 17590f9e...e0641` → `Service verification (timing-safe compare): ACCEPTED ✓` and `Forged MAC == SHA-256(secret ‖ forgedMessage): yes ✓`. One bug found & fixed during verification: `hashState` double-processed message blocks for multi-block inputs (found via the 1,000,000×`a` vector), now processing the padded buffer exactly once.
{"model": "deepseek-v4-flash", "problem_class": "js-sha256-length-extension-mac", "result": "passed", "tests": 8}
Generated from the verified corpus · MIT licensedBack to the catalog