$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: DOSBox-themed 3DES-ECB service encrypts FLAG under Key A and offers a decryption oracle under Key B, with four guards blocking symmetric typed keys. Solution: exploit the DES complementation property by making the final (post-permutation) keys bitwise complements, cancelling the secret MASK and defeating typed-order guards via a random permutation applied after the checks, then recover FLAG from the oracle.
DESBox README.TXT
KEYB.COM accepts two different 24-byte 3DES keys, written as hex. Before a key is loaded, the keyboard controller randomly permutes its bytes and xors the result with a resident secret table. Commands: DIR, TYPE README.TXT, KEYB, DOOM, EXIT
English summary: A TLS interactive service (ncat --ssl, port 1337) themed on DOSBox. The KEYB command reads two 24-byte 3DES keys A and B as hex, encrypts the secret FLAG with 3DES-ECB under Key A and prints the ciphertext, then acts as a decryption oracle under Key B for an attacker-supplied "debug" ciphertext. The goal is to recover FLAG (format FortID{...}).
Both user keys are processed by:
load_key(user_key) = adjust_key_parity( xor( permute(user_key), MASK ) )
permute applies an independent random secrets.SystemRandom().shuffle of the 24 bytes, once per key — so A and B each get a different random permutation.MASK is a fixed secret 24-byte table XORed into both keys.adjust_key_parity only alters bit 0 of each byte; DES ignores parity, so only the top 7 effective bits (b & 0xFE) matter.The service then:
ct_A = 3DES-ECB_Enc(KeyA, FLAG).Four guards run on the typed keys using effective bytes effective_bytes(k) = [b & 0xFE]:
key_a == key_b → rejected.effective_bytes(a) == effective_bytes(b) → rejected.sorted(effective_bytes(a)) == sorted(effective_bytes(b)) → rejected (same multiset).is_effective_complement(a, b) i.e. effective_bytes(a) == effective_bytes(complement(b)) → rejected (too symmetric).For single DES and (per-stage, hence for 3DES as a whole with complemented key triple):
E_{~K}(~P) = ~E_K(P) and equivalently D_{~K}(~C) = ~D_K(C)
where ~ is complementation of the top-7 effective key/data bits. Therefore, if the final Key B equals the complement of the final Key A (on effective bits), then:
FLAG = D_A(ct_A) = complement( D_B( complement(ct_A) ) )
So feeding complement(ct_A) to the Key-B oracle and complementing the result yields FLAG — no knowledge of the keys or MASK required.
Three analytical observations make the attack work:
MASK cancels. The final-key relationship depends only on permute(A) XOR permute(B); the shared secret MASK XORed into both keys cancels in the key difference. So MASK cannot prevent a complement relationship between the final keys.
Constant arrays are blocked. The obvious exact complement A = [v]*24, B = [~v]*24 makes the final keys complementary for every permutation, but on the top-7 bits this is exactly what check 4 (is_effective_complement) forbids. There is no "clean" typed complement that slips past check 4.
Checks are on typed order; the property is needed on the permuted order. permute reorders each key after the guard checks. We do not need the complement to hold in the typed order — only in the final permuted order. Since each key is shuffled independently and randomly, we choose complementary multisets that are not sorted-equal and not a position-wise complement in typed order (passing all four guards), then simply retry connections until the two independent shuffles happen to align into a position-wise complement.
If Key A's effective-byte multiset has multiplicities m_i, the probability that a fresh random shuffle of the complementary multiset lands as the exact position-wise complement is prod(m_i!) / 24!. To keep success practical while still breaking the constant case (which is blocked), use multiplicities (23, 1):
23! / 24! = 1/24 per connection
(Balanced (12,12) gives ~1/2.7M — far too rare.)
Key choice (one odd byte per key, placed at different indices so the typed keys are neither sorted-equal nor position-wise complement):
v = 0x10, v2 = 0x20A = bytes([v2] + [v]*23) → multiset = 23×0x10 + 1×0x20, odd byte at index 0B = bytes([~v & 0xff]*23 + [~v2 & 0xff]) → 0xef×23 + 0xdf, odd byte at index 23These pass all four guards. Each connection: send KEYB, send A and B hex, read ct_A, send complement(ct_A) to the debug oracle, read the output, and complement it. With success rate ~1/24 per connection, loop reconnections until the result is printable / matches the flag prefix (~24 attempts expected).
#!/usr/bin/env python3 import socket, ssl, sys, time HOST = "<target-host>" PORT = 1337 v, v2 = 0x10, 0x20 A = bytes([v2] + [v] * 23) B = bytes([(~v) & 0xff] * 23 + [(~v2) & 0xff]) def comp(b): return bytes(x ^ 0xff for x in b) def recv_until(sock, token, timeout=15): sock.settimeout(timeout) data = b"" while token not in data: try: chunk = sock.recv(4096) except socket.timeout: break if not chunk: break data += chunk return data def first_hex_line(text): for line in text.splitlines(): line = line.strip() if len(line) >= 16 and all(c in "0123456789abcdef" for c in line.lower()): return line.lower() return None def attempt(): ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE raw = socket.create_connection((HOST, PORT), timeout=15) s = ctx.wrap_socket(raw, server_hostname=HOST) try: recv_until(s, b"C:\\> ") s.sendall(b"KEYB\n") recv_until(s, b"KEY A>") s.sendall(A.hex().encode() + b"\n") recv_until(s, b"KEY B>") s.sendall(B.hex().encode() + b"\n") data = recv_until(s, b"Debug ciphertext>").decode(errors="replace") if "under Key A:" not in data: return None ct_hex = first_hex_line(data.split("under Key A:", 1)[1]) if not ct_hex: return None s.sendall(comp(bytes.fromhex(ct_hex)).hex().encode() + b"\n") out = recv_until(s, b"\n", timeout=10) + recv_until(s, b"C:\\", timeout=3) otxt = out.decode(errors="replace") if "Debug output:" in otxt: otxt = otxt.split("Debug output:", 1)[1] cand_hex = first_hex_line(otxt) if not cand_hex: return None return comp(bytes.fromhex(cand_hex)) finally: s.close() def main(): n = 0 while True: n += 1 try: cand = attempt() except Exception as e: print(f"[{n}] err {e}", file=sys.stderr) time.sleep(0.3) continue if not cand: continue marker = any(m in cand.lower() for m in (b"fortid{", b"flag{", b"ctf{")) if marker or all(32 <= c < 127 for c in cand): print(f"[{n}] CANDIDATE: {cand!r}", flush=True) if marker: return if __name__ == "__main__": main()
Running the loop yields a printable candidate with the FortID{...} prefix after roughly two dozen reconnections → FortID{REDACTED}.
Use this technique when:
D_{~K}(~C) = ~D_K(C).b & 0xFE, adjust_key_parity) — a reminder that only the top-7 effective bits matter for the attack.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar