Web / Cryptoeasy
Lost In Translation
by Pavel Knespl (capyplivl)
A Flask "Travel Notes" app issues a hex-encoded session cookie user=<name>&role=guest plus a sig cookie set to MD5(SECRET || data). The index page shows the flag only when the parsed session has role=admin. The secret-prefix MD5 MAC is vulnerable to a hash length extension attack, and parse_session keeps the last value for duplicate keys, so an appended &role=admin wins.
Solve
- Review
app.py. Routes are/,/login,/logout. The signature issig = MD5(SECRET || data)(sign_session),SECRET = b"secret_change_me"(16 bytes, but you do not need to know it).parse_sessionsplits on&then=, storing into a dict, so a trailing&role=adminoverrides the earlier&role=guest./loginblocks the literal usernameadminbut that check is irrelevant; onlyrolematters. - Log in as any non-admin user to get a legitimately signed cookie pair. Logging in as
haxoryields:
(session = 757365723d6861786f7226726f6c653d6775657374 sig = 89e9feb402c4e2e5d12462e0f43fbad8sessiondecodes touser=haxor&role=guest,sigisMD5(SECRET || data).) - Run a hash length extension attack to append
&role=admin. The forged session isdata || md5_glue_padding || &role=admin, and the new signature is computed by resuming MD5 from the published digest's internal state, with no knowledge ofSECRET. The binary glue padding decodes as latin-1; the chunkrole=guest<padding>setsroleto junk, then the final&role=adminoverrides it (last duplicate key wins). The padding's length bytes contain no&(0x26) for this message, so no extra split occurs. - The secret length is unknown, so brute-force lengths 0 to 39 and submit each forged pair to
/. The script below uses a self-contained pure-Python MD5 with settable internal state (no external dependency):#!/usr/bin/env python3 import struct, math ORIG_DATA = b"user=haxor&role=guest" ORIG_SIG = "89e9feb402c4e2e5d12462e0f43fbad8" APPEND = b"&role=admin" _s = [7,12,17,22]*4 + [5,9,14,20]*4 + [4,11,16,23]*4 + [6,10,15,21]*4 _K = [int(abs(math.sin(i+1)) * 2**32) & 0xFFFFFFFF for i in range(64)] def _lrot(x, c): x &= 0xFFFFFFFF return ((x << c) | (x >> (32 - c))) & 0xFFFFFFFF def _compress(state, block): a, b, c, d = state M = struct.unpack("<16I", block) for i in range(64): if i < 16: f = (b & c) | (~b & d); g = i elif i < 32: f = (d & b) | (~d & c); g = (5*i + 1) % 16 elif i < 48: f = b ^ c ^ d; g = (3*i + 5) % 16 else: f = c ^ (b | (~d & 0xFFFFFFFF)); g = (7*i) % 16 f = (f + a + _K[i] + M[g]) & 0xFFFFFFFF a, d, c = d, c, b b = (b + _lrot(f, _s[i])) & 0xFFFFFFFF return [(state[j] + v) & 0xFFFFFFFF for j, v in enumerate((a, b, c, d))] def _pad(n): p = b"\x80" + b"\x00" * ((56 - (n + 1) % 64) % 64) return p + struct.pack("<Q", (n * 8) & 0xFFFFFFFFFFFFFFFF) def length_extension(sig_hex, orig_len, append): state = list(struct.unpack("<4I", bytes.fromhex(sig_hex))) glue = _pad(orig_len) msg = append + _pad(orig_len + len(glue) + len(append)) for off in range(0, len(msg), 64): state = _compress(state, msg[off:off+64]) return glue, struct.pack("<4I", *state).hex() for slen in range(40): glue, sig = length_extension(ORIG_SIG, slen + len(ORIG_DATA), APPEND) forged = ORIG_DATA + glue + APPEND print(f"[len={slen:2d}] session={forged.hex()} sig={sig}") - Submit each candidate to
/:
Secret length 16 produces the accepted pair below; the index renders withcurl -s http://HOST:PORT/ -b 'session=<hex>; sig=<sig>' | grep -i svicusgis_admin = Trueand the flag appears in theadmin-card:session = 757365723d6861786f7226726f6c653d677565737480000000000000000000000000000000000000280100000000000026726f6c653d61646d696e sig = 5e0088a9243beeee04ccb71f5051548c
A self-contained copy of the script is saved as solve.py.
Flag: SVIUSCG{f2d4993d593855ad6cc6b1d6d8754025}