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
- 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 acceptscheck <hex>. The token is AES-CBC + PKCS#7: 8 blocks (IV + 7 ciphertext blocks). - For each ciphertext block
C_iwith preceding blockC_{i-1}, recover the intermediateD(C_i)byte by byte. Forge a preceding block whose tail bytes produce padding0x01, then0x02 0x02, etc.; theguessthat makes the oracle sayvalidgivesD(C_i)[pos] = guess XOR pad, andplaintext = D(C_i) XOR C_{i-1}. A guard atpad==1disambiguates the0x02 0x02false positive by flipping the prior byte. - 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()
- 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}