arrow_backUS Cyber Games 2026
Crypto / AEADhard

Dispatch Token

by Pavel Knespl (capyplivl)

A token validation service (nc challenge.ctf.uscybergames.com 38681) hands you an intercepted admin session token as AES-CBC ciphertext and, for any ciphertext you submit with check <hex>, replies only valid / invalid depending on whether the PKCS#7 padding decrypts cleanly. That one-bit leak is a textbook CBC padding oracle, which recovers the plaintext without ever touching the key.

Solve

  1. Connect and read the banner. The service prints TARGET <hex> (a fresh random IV + ciphertext, regenerated every connection, but always the same admin plaintext) and accepts check <hex>. The token is AES-CBC + PKCS#7: 8 blocks (IV + 7 ciphertext blocks).
  2. For each ciphertext block C_i with preceding block C_{i-1}, recover the intermediate D(C_i) byte by byte. Forge a preceding block whose tail bytes produce padding 0x01, then 0x02 0x02, etc.; the guess that makes the oracle say valid gives D(C_i)[pos] = guess XOR pad, and plaintext = D(C_i) XOR C_{i-1}. A guard at pad==1 disambiguates the 0x02 0x02 false positive by flipping the prior byte.
  3. The attack must stay inside one connection (the target changes per connect), and a naive byte-at-a-time loop is RTT-bound and times out. Pipeline all 256 guesses for a position in a single write, then read 256 responses — one round trip per byte instead of per guess. Save as solve.py:
import socket

HOST, PORT = "challenge.ctf.uscybergames.com", 38681

class Oracle:
    def __init__(self):
        self.s = socket.create_connection((HOST, PORT))
        self.f = self.s.makefile("rwb")
        target = None
        while True:
            line = self.f.readline()
            if not line:
                break
            if line.startswith(b"TARGET"):
                target = line.split()[1].decode()
            if b"check <hex>" in line:
                break
        self.target = bytes.fromhex(target)

    def check_batch(self, datas):
        out = bytearray()
        for d in datas:
            out += b"check " + d.hex().encode() + b"\n"
        self.f.write(out)
        self.f.flush()
        res = []
        for _ in datas:
            res.append(self.f.readline().strip() == b"valid")
        return res

def main():
    o = Oracle()
    ct = o.target
    bs = 16
    blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)]
    recovered = b""
    for bi in range(1, len(blocks)):
        prev = blocks[bi-1]
        target = blocks[bi]
        inter = bytearray(bs)
        plain = bytearray(bs)
        for pad in range(1, bs+1):
            pos = bs - pad
            base = bytearray(bs)
            for k in range(pos+1, bs):
                base[k] = inter[k] ^ pad
            cands = []
            for guess in range(256):
                forged = bytearray(base)
                forged[pos] = guess
                cands.append(bytes(forged) + target)
            res = o.check_batch(cands)
            valids = [g for g in range(256) if res[g]]
            chosen = None
            if pad == 1 and len(valids) > 1:
                tests = []
                for g in valids:
                    f2 = bytearray(base); f2[pos] = g; f2[pos-1] ^= 0xff
                    tests.append(bytes(f2) + target)
                r2 = o.check_batch(tests)
                for i, g in enumerate(valids):
                    if r2[i]:
                        chosen = g; break
            if chosen is None:
                chosen = valids[0]
            inter[pos] = chosen ^ pad
            plain[pos] = inter[pos] ^ prev[pos]
        recovered += bytes(plain)
    padlen = recovered[-1]
    if 1 <= padlen <= 16:
        recovered = recovered[:-padlen]
    print(recovered.decode())

if __name__ == "__main__":
    main()
  1. Run python3 solve.py. The recovered plaintext is the full admin token:
tideline-dispatch session v2
uid=1;role=dispatch-admin
flag=SVIUSCG{4225c0ca3a038863829b2a38a4cf1df5}

Flag: SVIUSCG{4225c0ca3a038863829b2a38a4cf1df5}