TL;DR
recovering 150 unknown letters under a strict UTF-8 decoding barrier: touching any ciphertext block beyond the IV randomizes an entire plaintext block and almost always causes decrypt error
Analysis
Service behavior
From server.py
- The server generates: a random AES key - secrets.token_bytes(16)
- a random 150-letter secret from string.ascii_letters
- magic_key =
json.dumps({"key": secret})
- It prints encrypted_word:
<base64(IV||AES-CBC(pad^3(magic_key)))> - It runs 10000 rounds of
- read a line q
- if q == magic_key: print the flag
- then decode as base64 and print a decryption oracle result
- decrypt error if AES-CBC decrypt / any of the three unpad calls / UTF-8 decode fails
- json error if UTF-8 succeeds but JSON parse fails
- ok if JSON parse succeeds and has key
make any target block become
I can only safely work on , but the secret spans many blocks. The server accepts an IV from the ciphertext we submit, so we can reframe the ciphertext.
Let the original ciphertext be
For any , build a new ciphertext by dropping the prefix and setting the IV to the previous ciphertext block
Now the first plaintext block under is
So we can attack each original block as if it were the first block.
This is why truncation is safe
- AES-CBC decrypt works block-by-block; dropping leading blocks does not affect later blocks.
- PKCS#7 unpadding only checks the end, which we keep intact.
- The server only requires the input length to be at least 3 blocks
The two oracles we build from the server responses
1. Quote-injection JSON oracle (exact letter recovery from an “ok” baseline)
Suppose we have a ciphertext that decrypts to valid JSON and thus returns “ok”
Inside JSON, the key value is a string. If we turn one of its bytes into a literal quote “(0x22), the string terminates early and the remaining characters break JSON syntax, causing “json error”.
Because IV flips let us XOR any chosen byte of , we can test is the plaintext byte equal to candidate ? by applying
The solver avoids false positives by first probing with a/b If at least one of those probes still returns “ok”, the real letter is lowercase.
Otherwise, treat it as uppercase.
2. UTF-8 lead-byte oracle
For suffix ciphertexts , the plaintext is not JSON, so the baseline is json error
I can turn UTF-8 decoding into a 1-bit test by forcing a 2-byte UTF-8 sequence
- Valid 2-byte UTF-8: lead in , continuation in .
- Any ASCII letter satisfies .
So for a position pos in the first block, we do two IV flips
Flip byte pos by
Flip byte pos+1 by
Then UTF-8 decoding succeeds iff the modified pos byte is a valid lead
The server tells us whether decoding succeeded via decrypt error
- decrypt error means invalid UTF-8:
- json error (ok) means UTF-8 survived:
This provides a membership test over the unknown byte .
Limitation (XOR-1 ambiguity): The interval is invariant under XOR 1, meaning
for any in that interval, is also in the interval. Therefore cannot distinguish from for any choice of .
A-Za-z, XOR 1 partitions letters into 28 classes: most are pairs like {b,c}, {d,e}, ..., {B,C}, ... plus four singletons {a}, {A}, {z}, {Z}.
The UTF-8 oracle can identify which class the byte belongs to, but not which element of the pair.
The solver performs a decision tree over these classes by choosing deltas that split the remaining class set as evenly as possible until one class remains.
Bridging the oracles
The quote-injection oracle only works when the baseline is ok, but a suffix plaintext starts with letters and therefore returns json error
We fix this by rewriting the first 9 bytes into the known JSON prefix {"key": " using IV flips.
For a suffix , the first plaintext block is . Its first 9 bytes are unknown letters . Using the UTF-8 oracle we narrow each to a set of size 1 or 2.
So there are at most
choices for .
Exploit
HOST_DEFAULT = "cbc-magic-word.seccon.games"PORT_DEFAULT = 3333
MAGIC_LEN = 150MAGIC_PREFIX = '{"key": "'MAGIC_SUFFIX = '"}'MAGIC_PREFIX_BYTES = MAGIC_PREFIX.encode()QUOTE = ord('"')
@dataclassclass Remote: sock: socket.socket buf: bytearray
@classmethod def connect(cls, host: str, port: int, timeout: float) -> "Remote": sock = socket.create_connection((host, port), timeout=timeout) sock.settimeout(timeout) return cls(sock=sock, buf=bytearray())
def close(self) -> None: try: self.sock.close() except OSError: pass
def _recv_more(self) -> None: chunk = self.sock.recv(4096) if not chunk: raise EOFError("connection closed") self.buf.extend(chunk)
def recv_until(self, token: bytes) -> bytes: while True: idx = self.buf.find(token) if idx != -1: end = idx + len(token) out = bytes(self.buf[:end]) del self.buf[:end] return out self._recv_more()
def recv_line(self) -> bytes: while True: idx = self.buf.find(b"\n") if idx != -1: end = idx + 1 out = bytes(self.buf[:end]) del self.buf[:end] return out self._recv_more()
def send_line(self, line: str) -> None: self.sock.sendall(line.encode() + b"\n")
def flip_plaintext_byte(iv_ciphertext: bytes, plaintext_pos: int, delta: int) -> bytes: if not (0 <= delta <= 0xFF): raise ValueError("delta must fit in a byte") if len(iv_ciphertext) < 16 + 16: raise ValueError("ciphertext too short")
out = bytearray(iv_ciphertext) block_index = plaintext_pos // 16 # 0-based plaintext block (IV is block -1) offset = plaintext_pos % 16
if block_index == 0: out[offset] ^= delta # IV return bytes(out)
prev_cipher_offset = 16 + (block_index - 1) * 16 + offset if prev_cipher_offset >= len(out): raise ValueError("plaintext_pos out of range for ciphertext") out[prev_cipher_offset] ^= delta return bytes(out)
def query(remote: Remote, iv_ciphertext: bytes) -> str: remote.recv_until(b"> ") remote.send_line(base64.b64encode(iv_ciphertext).decode()) line = remote.recv_line().decode(errors="replace").strip() if line not in {"ok", "json error", "decrypt error"}: raise ValueError(f"unexpected response: {line!r}") return line
def split_iv_ciphertext(iv_ciphertext: bytes) -> tuple[bytes, list[bytes]]: if len(iv_ciphertext) < 32 or len(iv_ciphertext) % 16 != 0: raise ValueError("unexpected ciphertext length") iv = iv_ciphertext[:16] ct = iv_ciphertext[16:] blocks = [ct[i : i + 16] for i in range(0, len(ct), 16)] return iv, blocks
def build_suffix_ciphertext(blocks: list[bytes], iv0: bytes, start_block: int) -> bytes: if not (0 <= start_block < len(blocks)): raise ValueError("start_block out of range") if start_block == 0: return iv0 + b"".join(blocks) return blocks[start_block - 1] + b"".join(blocks[start_block:])
def xor1_letter_classes() -> list[tuple[str, ...]]: letters = set(string.ascii_letters) seen: set[str] = set() classes: list[tuple[str, ...]] = [] for ch in string.ascii_letters: if ch in seen: continue mate = chr(ord(ch) ^ 1) if mate in letters: cls = tuple(sorted((ch, mate))) else: cls = (ch,) for c in cls: seen.add(c) classes.append(cls) classes.sort() return classes
XOR1_LETTER_CLASSES = xor1_letter_classes()_LEAD2_BYTES = frozenset(range(0xC2, 0xE0))
def _class_expected_bit(cls: tuple[str, ...], delta: int) -> int: rep = cls[0] return 1 if ((ord(rep) ^ delta) in _LEAD2_BYTES) else 0
def _choose_delta_best_split(candidates: list[tuple[str, ...]]) -> int: best: tuple[int, int, int] | None = None best_delta: int | None = None for delta in range(256): ones = sum(_class_expected_bit(cls, delta) for cls in candidates) zeros = len(candidates) - ones if zeros == 0 or ones == 0: continue key = (abs(zeros - ones), -min(zeros, ones), delta) if best is None or key < best: best = key best_delta = delta if best_delta is None: raise RuntimeError("no splitting delta found; bug") return best_delta
def _lead2_utf8_oracle(remote: Remote, base_ct: bytes, pos: int, delta: int) -> bool: if not (0 <= pos < 15): raise ValueError("pos must be within first block (0..14)") b = bytearray(base_ct) b[pos] ^= delta b[pos + 1] ^= 0xC0 return query(remote, bytes(b)) != "decrypt error"
def recover_xor1_class(remote: Remote, base_ct: bytes, pos: int) -> tuple[str, ...]: candidates = list(XOR1_LETTER_CLASSES) while len(candidates) > 1: delta = _choose_delta_best_split(candidates) bit = 1 if _lead2_utf8_oracle(remote, base_ct, pos, delta) else 0 candidates = [cls for cls in candidates if _class_expected_bit(cls, delta) == bit] return candidates[0]
def build_ok_ciphertext_from_prefix_guess( base_ct: bytes, guessed_first9: bytes, prefix_bytes: bytes) -> bytes: if len(guessed_first9) != 9: raise ValueError("guessed_first9 must be 9 bytes") if len(prefix_bytes) != 9: raise ValueError("prefix_bytes must be 9 bytes") iv_base = base_ct[:16] rest = base_ct[16:] iv = bytearray(iv_base) for j in range(9): iv[j] = iv_base[j] ^ guessed_first9[j] ^ prefix_bytes[j] return bytes(iv) + rest
def quote_oracle(remote: Remote, base_ct: bytes, plaintext_pos: int, candidate: str) -> bool: delta = ord(candidate) ^ QUOTE ct = flip_plaintext_byte(base_ct, plaintext_pos, delta) return query(remote, ct) == "ok"
def recover_letter(remote: Remote, base_ct: bytes, plaintext_pos: int) -> str: ok_a = quote_oracle(remote, base_ct, plaintext_pos, "a") ok_b = quote_oracle(remote, base_ct, plaintext_pos, "b")
if ok_a or ok_b: alphabet = string.ascii_lowercase if not ok_a: return "a" if not ok_b: return "b" candidates = alphabet[2:] else: alphabet = string.ascii_uppercase candidates = alphabet
for ch in candidates: if not quote_oracle(remote, base_ct, plaintext_pos, ch): return ch
raise RuntimeError("no candidate matched; oracle assumptions broken")
def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--host", default=HOST_DEFAULT) parser.add_argument("--port", default=PORT_DEFAULT, type=int) parser.add_argument("--timeout", default=30.0, type=float) args = parser.parse_args()
remote = Remote.connect(args.host, args.port, timeout=args.timeout) try: banner = remote.recv_line().decode(errors="replace").strip() if not banner.startswith("encrypted_word: "): raise ValueError(f"unexpected banner: {banner!r}") b64 = banner.split("encrypted_word: ", 1)[1].strip() full_ct = base64.b64decode(b64) iv0, blocks = split_iv_ciphertext(full_ct) if len(blocks) != 13: raise ValueError(f"unexpected block count: {len(blocks)}")
recovered_key = [] for k in range(7): pos = len(MAGIC_PREFIX) + k ch = recover_letter(remote, full_ct, pos) recovered_key.append(ch) print(f"[block0 {k+1}/7] {''.join(recovered_key)}", file=sys.stderr)
for i in range(1, 10): base_ct = build_suffix_ciphertext(blocks, iv0, i) classes = [recover_xor1_class(remote, base_ct, pos) for pos in range(9)] options = [[ord(ch) for ch in cls] for cls in classes]
ok_ct = None ok_first9 = None for combo in itertools.product(*options): guessed = bytes(combo) ct_try = build_ok_ciphertext_from_prefix_guess(base_ct, guessed, MAGIC_PREFIX_BYTES) if query(remote, ct_try) == "ok": ok_ct = ct_try ok_first9 = guessed break
start = 9 end = 16 if i < 9 else 15 tail = [] for pos in range(start, end): tail.append(recover_letter(remote, ok_ct, pos)) block_letters = ok_first9.decode() + "".join(tail) recovered_key.append(block_letters) print(f"[block{i}] {block_letters}", file=sys.stderr)
key_str = "".join(recovered_key) if len(key_str) != MAGIC_LEN: raise RuntimeError(f"internal length mismatch: {len(key_str)} != {MAGIC_LEN}")
magic = MAGIC_PREFIX + key_str + MAGIC_SUFFIX print(magic, file=sys.stderr)
remote.recv_until(b"> ") remote.send_line(magic)
out = [] while True: try: out.append(remote.recv_line()) except EOFError: break sys.stdout.write(b"".join(out).decode(errors="replace")) return 0 finally: remote.close()
if __name__ == "__main__": raise SystemExit(main())