Logo
Overview
[SECCON CTF 14] cbc-magic-word writeup

[SECCON CTF 14] cbc-magic-word writeup

December 14, 2025
7 min read

✨ 크립토 공짜 다운로드 ✨

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

  1. 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})
  2. It prints encrypted_word: <base64(IV||AES-CBC(pad^3(magic_key)))>
  3. 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 P0P_0

I can only safely work on P0P_0, 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 IV0C0C1C12IV_0 \| C_0 \| C_1 \| \dots \| C_{12}

For any i1i \ge 1, build a new ciphertext by dropping the prefix and setting the IV to the previous ciphertext block

CTi=Ci1CiCi+1C12CT_i = C_{i-1} \| C_i \| C_{i+1} \| \dots \| C_{12}

Now the first plaintext block under CTiCT_i is P0=DK(Ci)Ci1=PiP'_0 = D_K(C_i) \oplus C_{i-1} = P_i

So we can attack each original block PiP_i as if it were the first block.

This is why truncation is safe

  1. AES-CBC decrypt works block-by-block; dropping leading blocks does not affect later blocks.
  2. PKCS#7 unpadding only checks the end, which we keep intact.
  3. 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 P0P_0, we can test is the plaintext byte equal to candidate cc? by applying

Δ=ord(c)0x22\Delta = \text{ord}(c) \oplus 0x22 IV[j]IV[j]ΔIV[j] \leftarrow IV[j] \oplus \Delta

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 CTiCT_i, 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 [0xC2,0xDF][0xC2,0xDF], continuation in [0x80,0xBF][0x80,0xBF].
  • Any ASCII letter xx satisfies x0xC0[0x80,0xBF]x \oplus 0xC0 \in [0x80,0xBF].

So for a position pos in the first block, we do two IV flips

Flip byte pos by Δ\Delta

Flip byte pos+1 by 0xC00xC0

Then UTF-8 decoding succeeds iff the modified pos byte is a valid lead

b(Δ)=1[(pΔ)[0xC2,0xDF]]b(\Delta) = \mathbf{1}\big[(p \oplus \Delta) \in [0xC2,0xDF]\big]

The server tells us whether decoding succeeded via decrypt error

  • decrypt error means invalid UTF-8: b(Δ)=0b(\Delta)=0
  • json error (ok) means UTF-8 survived: b(Δ)=1b(\Delta)=1

This provides a membership test over the unknown byte pp.

Limitation (XOR-1 ambiguity): The interval [0xC2,0xDF][0xC2,0xDF] is invariant under XOR 1, meaning

for any xx in that interval, x1x \oplus 1 is also in the interval. Therefore b(Δ)b(\Delta) cannot distinguish pp from p1p \oplus 1 for any choice of Δ\Delta.

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 CTiCT_i, the first plaintext block is PiP_i. Its first 9 bytes are unknown letters u0..u8u_0..u_8. Using the UTF-8 oracle we narrow each uju_j to a set SjS_j of size 1 or 2.

So there are at most S0××S829=512|S_0 \times \dots \times S_8| \le 2^9 = 512

choices for (u0..u8)(u_0..u_8).

Exploit

HOST_DEFAULT = "cbc-magic-word.seccon.games"
PORT_DEFAULT = 3333
MAGIC_LEN = 150
MAGIC_PREFIX = '{"key": "'
MAGIC_SUFFIX = '"}'
MAGIC_PREFIX_BYTES = MAGIC_PREFIX.encode()
QUOTE = ord('"')
@dataclass
class 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())

flag