$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: AES-CTR encryption with biased OTP (no 0x00 bytes) and broken decrypt oracle. Solution: Statistical byte elimination attack - collect ~2000 samples to identify the one missing byte value at each position, then XOR with keystream recovered from encrypting zeros.
"The decrypt function is broken and I lost my flag. Can you help me fix it?"
A Python server implements AES-CTR encryption with a twist. Players connect to a TCP service with 3 options: get encrypted flag, encrypt a message, or decrypt a message. The decrypt function is intentionally broken (calls .encode() on bytes, which crashes in Python 3).
key = os.urandom(0x10).replace(b'\x00', b'\xff') iv = os.urandom(0x10).replace(b'\x00', b'\xff') def encrypt(data): ctr = Counter.new(128, initial_value=int(iv.hex(), 16)) crypto = AES.new(key, AES.MODE_CTR, counter=ctr) if type(data) != bytes: data = data.encode() otp = os.urandom(len(data)).replace(b'\x00', b'\xff') return xor(crypto.encrypt(data), otp) def decrypt(data): ctr = Counter.new(128, initial_value=int(iv.hex(), 16)) crypto = AES.new(key, AES.MODE_CTR, counter=ctr) return crypto.decrypt(data.encode()) # BUG: .encode() on bytes crashes
Broken decrypt function: The decrypt() function calls .encode() on a bytes object, which crashes in Python 3. This is the "broken" part mentioned in the challenge title.
Biased OTP generation: The OTP is generated as os.urandom(len(data)).replace(b'\x00', b'\xff'). This means OTP bytes range over 1-255, never 0x00.
Fixed AES-CTR keystream: The key and IV are fixed for the session, so the AES-CTR keystream is identical for every encryption call.
Since encrypt(data) = data ⊕ keystream ⊕ OTP and OTP byte is never 0x00:
data[j] ⊕ KS[j] ⊕ OTP[j]OTP[j] takes all values in {1..255} but never 0, the value data[j] ⊕ KS[j] ⊕ 0 = data[j] ⊕ KS[j] never appears in the outputAttack Steps:
flag[j] ⊕ KS[j]0x00 ⊕ KS[j] = KS[j](flag[j] ⊕ KS[j]) ⊕ KS[j] = flag[j]#!/usr/bin/env python3 from pwn import * HOST = "TARGET_IP" PORT = TARGET_PORT context.log_level = 'warn' def xor(a, b): return bytes([_a ^ _b for _a, _b in zip(a, b)]) r = remote(HOST, PORT) # Get first flag to know length r.recvuntil(b'Your option: ') r.sendline(b'1') first = r.recvline().strip().decode() flag_len = len(first) // 2 zeros_hex = '00' * flag_len flag_seen = [set() for _ in range(flag_len)] ks_seen = [set() for _ in range(flag_len)] enc = bytes.fromhex(first) for j in range(flag_len): flag_seen[j].add(enc[j]) for i in range(4000): # Collect encrypted flag samples flag_done = min(len(s) for s in flag_seen) >= 255 ks_done = min(len(s) for s in ks_seen) >= 255 if flag_done and ks_done: break if not flag_done: r.recvuntil(b'Your option: ') r.sendline(b'1') enc = bytes.fromhex(r.recvline().strip().decode()) for j in range(flag_len): flag_seen[j].add(enc[j]) if not ks_done: r.recvuntil(b'Your option: ') r.sendline(b'2') r.recvuntil(b'Enter plaintext: ') r.sendline(zeros_hex.encode()) enc = bytes.fromhex(r.recvline().strip().decode()) for j in range(flag_len): ks_seen[j].add(enc[j]) r.close() # Missing value at each position = data XOR keystream flag_xor_ks = bytearray(flag_len) for j in range(flag_len): flag_xor_ks[j] = (set(range(256)) - flag_seen[j]).pop() keystream = bytearray(flag_len) for j in range(flag_len): keystream[j] = (set(range(256)) - ks_seen[j]).pop() flag = xor(flag_xor_ks, keystream) print(f"FLAG: {flag.decode()}")
Use this technique when:
.replace(b'\x00', b'...') — creates biased random bytes$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar