MACs (hash-only auth, length-extension)
Security & Cryptography · Nutthakorn Chalaemwongwan
Today
- Hashes vs. MACs vs. signatures — what "authenticity" actually requires
- Why
H(secret ‖ data)is not a MAC — SHA-256 length-extension - HMAC's nested construction — why it closes the gap
- 🔓 Game: Forge the Admin Cookie
Recap — Week 2
- A hash proves integrity only if the attacker can't recompute it
- Bare
H(password)broke because attackers could recompute — at scale, with rainbow tables - Fix was salting + slow KDFs (bcrypt/Argon2) — but hashing still had no secret
The spine of this course
Textbook-secure primitive → real-system failure → correct construction.
- SHA-256 is a mathematically sound, collision-resistant hash function. Full stop.
- Stick a secret in front of it to build authentication, and the system breaks anyway
- Today's failure: length-extension — misuse, not a flaw in SHA-256 itself
What a MAC needs to guarantee
- Integrity — data wasn't modified in transit
- Authenticity — data came from someone who knows a secret key
- A hash alone gives you neither if the attacker can compute the same hash function
cookie = username || SHA-3(username)— no secret at all. Anyone can recompute it forusername=admin.
The tempting-but-wrong fix: secret-prefix hash
sig = SHA256(MAC_SECRET + data)
- Looks reasonable: now there IS a secret in the computation
- This is exactly the vulnerable construction in
vulnerable_app.py - CWE-347 — Improper Verification of Cryptographic Signature
Why: Merkle-Damgard hashes process in chunks
- SHA-256 absorbs input in 64-byte blocks, updating internal state block by block
- The final internal state IS the digest — nothing more, nothing less
- If you know a valid
SHA256(secret ‖ data), you know the exact internal state after processingsecret ‖ data
Length-extension: resume, don't restart
- Attacker doesn't need
secret— onlysig,len(secret), andlen(data) - Attacker resumes SHA-256 compression from that known state
- Appends glue padding (the
0x80+ zero bytes + 8-byte bit-length SHA-256 always adds) then their own extra bytes (&admin=true) - Result: a valid
SHA256(secret ‖ data ‖ glue ‖ extra)— computed with zero knowledge ofsecret
CWE-290: this IS authentication bypass by spoofing
- The server's
/admincheck: doessigmatch AND doesdatacontainadmin=true? - Forged cookie satisfies both — without ever learning
MAC_SECRET - The server can't tell "legitimately signed" from "resumed from a leaked digest"
The fix: HMAC's nested construction
HMAC(key, msg) = H( (key⊕opad) ‖ H( (key⊕ipad) ‖ msg ) )
- Inner hash processes
key⊕ipad ‖ msg→ produces an intermediate digest - Outer hash re-hashes that digest with a fresh key-derived prefix — a brand-new Merkle-Damgard chain
- Attacker never sees the state the outer hash starts from — it depends on
key⊕opad, which they don't have
Fixed vs. vulnerable — one line differs
# vulnerable_app.py
sig = hashlib.sha256(MAC_SECRET.encode() + data).hexdigest()
# fixed_app.py
sig = hmac.new(MAC_SECRET.encode(), data, hashlib.sha256).hexdigest()
- Same secret, same data, same SHA-256 — only the construction changed
- Length-extension against
:8093(HMAC) → rejected, 403, no flag
One fix doesn't fix everything
if sig != expected: # plain string comparison — in BOTH apps
return jsonify({"error": "bad signature"}), 403
- HMAC defeats length-extension — it does not make
!=constant-time - Byte-by-byte early-exit comparison leaks timing information → an attacker can recover the tag one byte at a time
- Real fix:
hmac.compare_digest()(constant-time by design)
MACs alone don't stop everything else
- A valid
(data, sig)pair can be replayed later — MAC says nothing about when - Short tags (e.g. 64-bit) are forgeable via birthday-bound brute force
- Today's fix is length-extension only — a secure cookie design needs more (nonces/timestamps, tag length, comparison)
🔓 Game — Forge the Admin Cookie
| Service | Port | Scheme | Length-extension? |
|---|---|---|---|
vulnerable_app.py | :8092 | SHA256(MAC_SECRET + data) | Yes |
fixed_app.py | :8093 | HMAC-SHA256(MAC_SECRET, data) | No |
- Log in as
gueston:8092, capture(data, sig) - Forge
&admin=trueappended, via length-extension — noMAC_SECRETneeded - Cash in at
/admin→ flag - Replay the same trick against
:8093→ must be rejected
Lab today
📋 Worksheet 3 —
labs/week03-macs/worksheet.md· kickoff:docker compose up -dthenpython exploit.py
- Tasks 0–5: read the vulnerable construction, capture a cookie, hand-compute the glue padding, forge it, cash in the flag, confirm HMAC rejects it
- + Audit the AI — critique an AI's flawed Q6 cookie-design answer (replay claim +
==comparison) - + EiPE (explain length-extension in plain English) + Prompt Problem on HMAC vs. bare hash
Key takeaways
- SHA-256 is textbook-sound — the misuse (
H(secret ‖ data)) is what breaks - Length-extension resumes hash state the attacker was never supposed to see
- HMAC's nested construction fixes this — but doesn't fix comparison timing or replay
Questions?
Next week: AES — CBC bit-flipping and why unauthenticated encryption fails