HTB Crypto – AES-CTR ⊕ “No-Null OTP” Missing-Value Leak (Oracle Attack)
0. Summary
What breaks it: OTP bytes are sampled from os.urandom() but then forced to contain no 0x00 bytes:
otp = os.urandom(len(data)).replace(b'\x00', b'\xff')
Because OTP can never be 0x00, each ciphertext byte position has exactly one forbidden ciphertext value.
With enough samples, the forbidden value reveals an internal byte:
\[
X_i = (P_i \oplus KS_i)
\]
We first recover the CTR keystream \(KS\) by encrypting all-zero plaintext, then recover \(FLAG \oplus KS\) by calling Get-flag repeatedly,
and finally XOR them to obtain the flag.
1. Recon: Service Interface
The service is a simple line-based menu:
1) Get flag
2) Encrypt Message
3) Decrypt Message
Your option:
When selecting option 1 multiple times, the output changes each time:
Your option: 1
8cf09217fd8c2e285fe6aada92ece1
Your option: 1
60249833c709dc70e63ae75ed740cc
Your option: 1
a0c50160863c5b468f7be3a4ccec32
That tells us the plaintext is fixed (flag is constant), but some per-request randomness is present. Option 2 gives us an encryption oracle we can query with chosen plaintext.
2. Source Review: What It’s Actually Doing
Key parts of the server (simplified):
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)
otp = os.urandom(len(data)).replace(b'\x00', b'\xff')
return xor(crypto.encrypt(data), otp)
def get_flag():
flag = open('flag.txt', 'r').read().strip()
return encrypt(flag)
- key/iv are generated once at server start and reused for all connections.
- CTR counter is always initialized from the same
iv, so the CTR keystream is reused for same-length messages. - OTP is “random” but forbids the byte value
0x00.
Also, Decrypt is effectively broken in the provided code (.encode() on bytes),
but we don’t need it; the attack uses only options 1 and 2.
3. Crypto Model (CTR as Stream Cipher)
CTR mode behaves like a stream cipher:
\[ \text{CTR}(P) = P \oplus KS \]where \(KS\) is the keystream derived from AES(key, counter(iv)). In this challenge, \(key\) and \(iv\) are fixed globally, and the counter is reset each encryption call, so for a fixed message length \(L\), the keystream bytes \(KS_0,\dots,KS_{L-1}\) are the same every time.
The implementation then XORs an OTP:
\[ C = \text{CTR}(P) \oplus OTP = (P \oplus KS) \oplus OTP \]Define an intermediate value:
\[ X := P \oplus KS \]Then each ciphertext byte is:
\[ C_i = X_i \oplus OTP_i \]4. Core Vulnerability: “No-Null OTP” ⇒ Missing Ciphertext Value
OTP is generated as:
otp = os.urandom(len(data)).replace(b'\x00', b'\xff')
That means for every position \(i\), the random variable \(OTP_i\) cannot be 0:
\[ OTP_i \in \{1,2,\dots,255\} \]Now look at the equation:
\[ C_i = X_i \oplus OTP_i \]If \(OTP_i\) were allowed to be 0, then \(C_i\) could equal \(X_i\) (when \(OTP_i=0\)). But \(OTP_i=0\) is impossible here, therefore:
\[ \Pr[C_i = X_i] = 0 \]In words: the value \(X_i\) is a forbidden ciphertext byte at position \(i\). Over many samples, \(C_i\) will take (at most) 255 distinct values, missing exactly one value:
\[ \{0,\dots,255\} \setminus \{C_i \text{ observed}\} = \{X_i\} \]| What we can query | What we observe | What “missing value” reveals |
|---|---|---|
| Encrypt chosen plaintext \(P\) | Many ciphertexts \(C\) | \(X_i = P_i \oplus KS_i\) |
| Encrypt all-zero \(P=0\) | Many ciphertexts \(C\) | \(KS_i\) (since \(X_i = KS_i\)) |
| Get-flag (fixed \(P=\text{FLAG}\)) | Many ciphertexts \(C\) | \((FLAG_i \oplus KS_i)\) |
5. Attack Plan (CTF Structure)
5.1. Step A — Recover flag length
Call Get flag once. The returned string is hex, so:
\[ L = \frac{\text{len(hex)}}{2} \]5.2. Step B — Recover CTR keystream \(KS\) using Encrypt oracle
Query option 2 with plaintext \(P = 00\cdots 00\) of length \(L\). Then:
\[ X = P \oplus KS = KS \]For each position \(i\), collect a set of observed ciphertext bytes: \[ S_i = \{ C_i^{(1)}, C_i^{(2)}, \dots \} \] The missing byte is: \[ KS_i = \text{the unique value in } \{0,\dots,255\}\setminus S_i \] Repeat until each \(S_i\) has size 255 (so the complement has size 1).
5.3. Step C — Recover \(FLAG \oplus KS\) using Get-flag oracle
Call Get-flag many times. For each byte position \(i\), the missing value is:
\[ (FLAG_i \oplus KS_i) \]5.4. Step D — Recover the flag
\[ FLAG_i = (FLAG_i \oplus KS_i)\ \oplus\ KS_i \]6. Exploit Implementation (pwntools)
The key implementation detail: for each byte position \(i\), you must keep sampling until you have seen 255 distinct ciphertext byte values. If you stop at 254, you may have two “missing” values (one is the real forbidden \(X_i\), one is just not yet observed), which produces garbage output.
#!/usr/bin/env python3
from pwn import remote
import binascii
HOST = "83.136.255.170"
PORT = 53295
def recv_menu(io):
io.recvuntil(b"Your option: ")
def get_flag_ct(io) -> bytes:
recv_menu(io)
io.sendline(b"1")
return bytes.fromhex(io.recvline().strip().decode())
def enc_msg(io, pt: bytes) -> bytes:
recv_menu(io)
io.sendline(b"2")
io.recvuntil(b"Enter plaintext: ")
io.sendline(binascii.hexlify(pt))
return bytes.fromhex(io.recvline().strip().decode())
def missing_from_seen(seen_set):
missing = [v for v in range(256) if v not in seen_set]
if len(missing) != 1:
raise RuntimeError(f"Not unique missing: {len(missing)} missing")
return missing[0]
def recover_missing_value_vector(sample_func, L: int, max_samples=40000):
seen = [set() for _ in range(L)]
done = [False] * L
for n in range(1, max_samples + 1):
ct = sample_func()
if len(ct) != L:
raise RuntimeError(f"length mismatch: got {len(ct)} expected {L}")
for i, b in enumerate(ct):
if not done[i]:
seen[i].add(b)
if len(seen[i]) == 255: # MUST be 255 distinct values
done[i] = True
if n % 500 == 0:
print(f"[*] samples={n} done={sum(done)}/{L}")
if all(done):
break
if not all(done):
raise RuntimeError("Not finished: increase max_samples or keep sampling")
return bytes(missing_from_seen(seen[i]) for i in range(L))
def main():
io = remote(HOST, PORT)
# Step A: get length
c0 = get_flag_ct(io)
L = len(c0)
print(f"[+] flag ciphertext length = {L} bytes")
# Step B: recover KS using encrypt(0^L)
KS = recover_missing_value_vector(lambda: enc_msg(io, b"\x00" * L), L)
print(f"[+] KS = {KS.hex()}")
# Step C: recover FLAG^KS using get_flag()
XFLAG = recover_missing_value_vector(lambda: get_flag_ct(io), L)
print(f"[+] FLAG^KS = {XFLAG.hex()}")
# Step D: flag = (FLAG^KS) ^ KS
FLAG = bytes(a ^ b for a, b in zip(XFLAG, KS))
print("[+] FLAG (raw bytes):", FLAG)
try:
print("[+] FLAG (utf-8):", FLAG.decode())
except:
print("[!] FLAG not utf-8; latin-1:", FLAG.decode("latin-1", errors="replace"))
io.close()
if __name__ == "__main__":
main()
7. Pitfalls & Debugging Notes
7.1. Stopping at 254 distinct bytes (common failure)
If you stop sampling when \(|S_i|=254\), there are two values not yet observed. Picking “the first missing” is basically random, and the final plaintext becomes non-printable bytes.
Correct condition per position:
\[ |S_i| = 255 \quad \Rightarrow \quad |\{0,\dots,255\}\setminus S_i| = 1 \]7.2. Sample count can be large (coupon-collector effect)
Each byte position behaves like collecting 255 coupons. You may need thousands of oracle calls.
If your script fails with “Not finished”, increase max_samples and keep going.
7.3. “Decrypt Message” is unreliable / not needed
In the provided code, decrypt has a bytes/encode mismatch and often terminates the session. The attack is fully achievable with options 1 and 2 only.