Lesson 5.2: Length Extension Attack
Many people build their own MAC (message authentication code) by hashing the key joined to the message: tag = H(secret || msg). It looks reasonable, because the secret is mixed in and someone without the key should not be able to forge a tag. It is wrong. With MD5, SHA-1 and SHA-256, an attacker can append data to the message and compute a new valid tag without knowing the key. The flaw is in how these hash functions are built. This lesson takes apart the Merkle-Damgard construction, shows why H(secret || msg) leaks the internal state, and then rebuilds the hash state by hand to forge a cookie, running from start to end.
The tag of H(secret || msg) is the internal state, so an attacker can load it and keep hashing.
| Part 5 (Hashes, MACs and passwords) | Time: about 45 minutes | Difficulty: hard |
Prerequisites: Lesson 5.1 (hashes, their properties, and SHA-2). Be comfortable with bytes, hex and big-endian.
Tools: Python 3 (only hashlib and struct from the standard library).
Goals
After this lesson you can explain the Merkle-Damgard construction (the message is split into blocks, the state is updated step by step, and the digest is the final state), see why knowing the digest means you can continue hashing, state the conditions for length extension and what data the attacker needs, rebuild the state from a tag and compute a valid tag for a longer message, and explain why HMAC stops this attack and how to recognize H(secret || msg) in a challenge.
1. Theory
Merkle-Damgard: how a hash runs inside
MD5, SHA-1 and SHA-256 all follow a pattern called Merkle-Damgard. The idea:
- Initialize an internal state
Hwith fixed constants (the IV). For SHA-256,His eight 32-bit numbers. - Pad the message up to a multiple of the block size (SHA-256 uses 64-byte blocks). The padding is one byte
0x80, then0x00bytes, then eight final bytes holding the original message length in bits (big-endian). Because the length is part of the padding, we will have to reproduce it. - Split the padded message into 64-byte blocks. For each block run the compression function:
H = compress(H, block). The new state depends on the old state and the current block. - After the last block, the digest is
H.
The key point is that the digest is nothing other than the final internal state. When a server gives you tag = H(secret || msg), it has handed you exactly the state of the hash function right after hashing secret || msg || padding.
Why knowing the digest lets you keep hashing
Suppose I know the state H of the hash function at some point. I load H as the initial state in place of the IV, then run compress on my own block. The result is the digest of “all the earlier data plus the data I appended”. I do not need to know the earlier data, only the state and the total length.
Applied to the homemade MAC H(secret || msg):
- I know
tag, which is the state after hashingsecret || msg || pad1, wherepad1is the padding of thesecret || msgblock. - I load that state and hash a chunk
appendthat I choose. - The result is
H(secret || msg || pad1 || append), a valid tag for the new messagemsg || pad1 || append.
When the server receives it, it computes H(secret || (msg || pad1 || append)) and gets exactly the tag I sent. I forged a tag without ever knowing secret.
What I need and what I do not
Needed: the original message msg, its tag, and the length of secret in bytes. Not needed: the value of secret.
If the secret length is unknown, scan for it. Secrets are usually short (8 to 32 bytes), so try each length, build a forgery for each, and send it to the server. The one that is accepted has the correct length. That is only a few dozen attempts.
pad1 is the “glue padding” that you must compute yourself and place between msg and append. It looks like garbage (\x80\x00... and eight length bytes), but for many formats (query strings, cookies) the server still parses the append part at the end, for example &role=admin overriding role=user.
Why HMAC is immune
HMAC does not hash secret || msg. It hashes twice with two derived keys: HMAC(k, m) = H((k xor opad) || H((k xor ipad) || m)). The outer hash wraps the inner digest, so the tag the attacker sees is the output of the outer hash, not a state that matches secret || msg. There is no state to continue from, so length extension stops working. In short, do not build a MAC by concatenating with a hash, use HMAC. Alternatively use a sponge-style hash (SHA-3), which does not expose its whole state as the digest.
2. Demo
To rebuild the state by hand we need a SHA-256 that lets us load an initial state and account for the length already hashed. The script below implements SHA-256 in pure Python for that purpose, sets up a server that signs a cookie with H(secret || data), and then forges the cookie &role=admin without the secret. Paste it and run it directly, it only needs the standard library.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# length_ext.py: length extension attack on SHA-256, stdlib only
import struct, hashlib, os
K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]
R = lambda x,n: ((x>>n)|(x<<(32-n))) & 0xffffffff
def compress(H, blk):
w = list(struct.unpack(">16I", blk))
for i in range(16,64):
s0 = R(w[i-15],7) ^ R(w[i-15],18) ^ (w[i-15]>>3)
s1 = R(w[i-2],17) ^ R(w[i-2],19) ^ (w[i-2]>>10)
w.append((w[i-16]+s0+w[i-7]+s1) & 0xffffffff)
a,b,c,d,e,f,g,h = H
for i in range(64):
t1 = (h + (R(e,6)^R(e,11)^R(e,25)) + ((e&f)^(~e&g)) + K[i] + w[i]) & 0xffffffff
t2 = ((R(a,2)^R(a,13)^R(a,22)) + ((a&b)^(a&c)^(b&c))) & 0xffffffff
h,g,f,e,d,c,b,a = g,f,e,(d+t1)&0xffffffff,c,b,a,(t1+t2)&0xffffffff
return [(x+y)&0xffffffff for x,y in zip(H,[a,b,c,d,e,f,g,h])]
def md_pad(total_len): # Merkle-Damgard padding for a message of total_len bytes
p = b"\x80" + b"\x00"*((56-(total_len+1))%64) + struct.pack(">Q", total_len*8)
return p
def sha256(msg, H=None, prelen=0):
H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19] if H is None else list(H)
data = msg + md_pad(prelen+len(msg))
for i in range(0,len(data),64):
H = compress(H, data[i:i+64])
return b"".join(struct.pack(">I",x) for x in H)
# --- server: signs the cookie with H(secret||data), secret is hidden ---
SECRET = os.urandom(16)
sign = lambda data: hashlib.sha256(SECRET+data).hexdigest()
verify = lambda data,tag: hashlib.sha256(SECRET+data).hexdigest()==tag
data = b"user=guest&role=user"
tag = sign(data)
print("original cookie:", data.decode())
print("original tag :", tag)
# --- attack: knows data, tag, secret length (=16), does NOT know secret ---
append = b"&role=admin"
slen = 16
H = struct.unpack(">8I", bytes.fromhex(tag)) # reload state from the tag
glue = md_pad(slen + len(data)) # padding server da them
forged_data = data + glue + append
forged_tag = sha256(append, H=H, prelen=slen+len(data)+len(glue)).hex()
print("forged cookie:", forged_data)
print("forged tag :", forged_tag)
print("server accepts?", verify(forged_data, forged_tag))
Running it gives this output (the secret is random each time so the tags differ, but the last line is always True):
1
2
3
4
5
original cookie: user=guest&role=user
original tag : 6c81f2503987250234dbffb5f5a4d1318c1905090b3f6bd1cd5a7b50b4a0c8de
forged cookie: b'user=guest&role=user\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01 &role=admin'
forged tag : b666a4040eb4987fdb3934cb7accbc32b8947e70af860d3752c3a3b1d84717ca
server accepts? True
Reading the output, we never touch SECRET. We only reload eight 32-bit numbers from the original tag with struct.unpack(">8I", ...) and hash on the segment &role=admin. The forged cookie contains the glue padding in the middle, which looks like garbage, but because the query string takes the last occurrence of a key, role=admin overrides role=user. The server recomputes H(SECRET || forged_data), gets exactly the forged tag, and accepts it.
3. Lab
- Task: An API accepts
GET /order?data=<data>&sig=<tag>withtag = SHA256(secret || data). Thedatafield looks likeitem=book&qty=1. Append&qty=1000&admin=1and produce a valid signature without knowing the secret. The server treats the later occurrence of a key as the effective one. - Files: write a self-contained
solve.pywith a pure Python SHA-256, a server, and aforgefunction, as in the demo. If the secret length is unknown, the scan for it goes in the same script. - Hints, step by step:
- Use a SHA-256 that lets you load a state and set the already-hashed length. Check that it matches
hashlib.sha256on a few inputs before you trust it. - From the tag,
struct.unpack(">8I", ...)gives the eight state words. - Compute the glue padding for the
secret || datablock with an assumed secret length. Remember the length is in bits and big-endian. - Hash
appendfrom the loaded state withprelen = len(secret) + len(data) + len(glue). If the secret length is unknown, scan from 8 to 32.
- Use a SHA-256 that lets you load a state and set the already-hashed length. Check that it matches
- Done when: the server accepts the forged cookie, and you can explain why the glue padding sits in the middle and the message is still valid.
4. Key takeaways
- MD5, SHA-1 and SHA-2 follow Merkle-Damgard: the digest is the final internal state.
- Knowing the digest and the length is enough to continue hashing, without the original data.
H(secret || msg)is not a safe MAC because of length extension.- Needed:
msg,tag, and the length ofsecret. Not needed: the value ofsecret. If the length is unknown, scan it. - Glue padding (
\x80, the\x00bytes, eight bytes of bit length) must be inserted betweenmsgandappend. - Defense: use HMAC, or SHA-3 (sponge). Putting the key at the end,
H(msg || secret), also blocks this attack, but HMAC remains the standard.
5. Common pitfalls
- Forgetting that the length in the padding is in BITS, not bytes. Multiply by eight. A mistake here makes the forgery fail with no clear reason.
- Mixing up endianness. SHA-256 uses big-endian for both the state words and the length field. MD5 and SHA-1 differ: MD5 uses little-endian for the length field. Using the wrong one breaks the forgery.
- Computing the glue padding from the length of
dataonly and forgetting to add the length ofsecret.prelenmust belen(secret) + len(data). - Thinking that HMAC is vulnerable too. It is the opposite: HMAC is what blocks length extension. This attack only works on homemade
H(secret || msg). - In a real challenge the server may reject non-printable bytes in
data. In that case pick a format that allows them (hex, base64) or find a place that accepts raw bytes. - Using SHA-3 and still worrying about length extension. There is no need: the sponge does not expose the whole state as the digest.
6. Further reading
- Wikipedia, “Length extension attack”, a short description that lists which hashes are affected.
- The
hashpumptool and the Python packagehashpumpybuild forgeries automatically for MD5/SHA-1/SHA-256 (depending on the Python version it may not build, in which case code the state continuation yourself as in the demo). - Cryptopals Set 4, Challenge 29 (Break a SHA-1 keyed MAC using length extension), the standard practice exercise.
- RFC 2104 (HMAC) to see how the two-layer hash design blocks this attack.
