algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

hash length extension — appending to a MAC you can't compute

2026-03-203 min readcrypto ctf

A tempting homemade MAC: tag = SHA256(secret ‖ message). It looks sound — you'd need the secret to recompute it. But for any Merkle–Damgård hash (MD5, SHA-1, the SHA-2 family), the digest is the internal state at the end, and that's all you need to keep hashing from where it left off.

target
construction
tag = H(secret ‖ message), H ∈ {MD5, SHA-1, SHA-256, ...}
known
the message, its tag, and len(secret) (or just brute it)
goal
a valid tag for message ‖ glue-padding ‖ attacker-data — no secret

why the digest is a resume point#

A Merkle–Damgård hash chops the input into fixed blocks and folds each into a running state with a compression function; the final state, serialized, is the digest. There's no finalization that mixes in a secret key — the output is just the chaining variable after the last block.

So if you know H(secret ‖ message), you know the exact internal state the hash reached after consuming secret ‖ message ‖ glue_padding (the padding the hash appended to reach a block boundary). Load that state back into the algorithm and keep feeding it bytes, and you compute H(secret ‖ message ‖ glue_padding ‖extension) — a valid digest for a message you've extended, without ever knowing the secret.

what you can forgetxt
1
2
3
original signed bytes : secret ‖ message
glue padding          : the 0x80 .. 0x00 .. len bits H appended internally
your forgery          : message ‖ glue_padding ‖ extension      <- valid tag!

The only unknown is len(secret) (it shifts the glue padding). You either know it, or you try lengths 0,1,2,… and submit each forgery until one is accepted.

doing it#

The arithmetic is fiddly (reconstructing the exact glue padding, byte-swapping the state into the right endianness), so the tools do it:

hashpump / hash_extendertxt
1
2
3
4
$ hashpump -s <known_tag> -d "message=...&role=guest" \
           -k <len(secret)> -a "&role=admin"
<new_tag>
message=...&role=guest\x80\x00...\x00<lenbits>&role=admin     # send these bytes
by hand: resume from the digestpython
1
2
3
4
h = sha256_clone_from_state(bytes.fromhex(known_tag))  # load state from the digest
h.set_length(len(secret) + len(message) + len(glue))   # account for what was hashed
h.update(b"&role=admin")
forged_tag = h.hexdigest()                              # valid for the extended msg
guest to admin, no key
txt
1
2
3
4
[*] tried secret lengths 8..20
[*] len(secret)=16 accepted
forged: ...&role=guest<glue>&role=admin   tag=4f2c...  -> server: 200 OK, role=admin
segfault{merkle_damgard_never_finalized}

Last-write-wins parsers (role=guest…&role=admin) make the appended field override the original — which is why this maps so cleanly onto signed query strings and cookies.

the fix is HMAC#

  • Use HMAC. HMAC(k, m) = H(k⊕opad ‖ H(k⊕ipad ‖ m)) hashes twice with the key wrapping both ends, so the published tag isn't a resumable state for the outer message — length extension dies.
  • Or a sponge hash. SHA-3/Keccak isn't Merkle–Damgård and isn't extendable; BLAKE⅔ have keyed modes designed for MACs.
  • Never roll H(secret ‖ message). It's the textbook footgun this attack exists to punish.

The takeaway: a hash digest is not a sealed fingerprint, it's a snapshot of a resumable computation. Treat H(secret ‖ m) as a MAC and you've published the exact state an attacker needs to keep typing where your secret left off.