Crypto Notes – Missing-Value Leak in AES-CTR ⊕ No-Null OTP

Date: 2026-01-27
Box: HTB — (Crypto Study Note)

HTB Crypto – AES-CTR ⊕ “No-Null OTP” Missing-Value Leak (Oracle Attack)

This challenge exposes a classic “looks-random but isn’t” pitfall. The server uses AES-CTR (fixed key/IV) and then XORs a per-message “OTP”. However, the OTP is generated with replace(b'\x00', b'\xff'), which makes byte 0x00 impossible. That single constraint creates a missing-value leak that lets us recover the keystream and then the flag, using only the Encrypt oracle and Get-flag oracle.
AES-CTR Oracle Attack Biased RNG Missing-Value Leak Keystream Recovery CTF Crypto

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.