WatCTF — “Java Oracle / Helixlite” CBC Padding-Oracle Write-up

Recovering the flag from a noisy padding oracle, with speed optimizations

0) TL;DR

Category
Crypto (CBC / padding-oracle)
Block cipher
AES-128-CBC
Block size
16 bytes
Padding
“helixlite” (PKCS#7-like; always add a full block for exact multiples)
Oracle signal
Valid padding vs Invalid padding
Goal
Decrypt JSON and read access_code (the flag)
Flag
watctf{quantum_helix_padding_oracle}
We exploit the classic CBC property Pi = Dec(Ci) ⊕ Ci−1. By crafting a fake previous block and observing the server’s padding response, we recover the intermediate state I = Dec(Ci) byte-by-byte. Noise is handled with two anti-false-positive checks and a prioritized guess order tailored to JSON.

1) Challenge Recap

The server encrypts a JSON message under a fresh random key and IV per connection, prints the ciphertext (as hex), and then acts as a padding oracle for any user-submitted ciphertexts:

iv = os.urandom(16)
cipher = AES.new(k, AES.MODE_CBC, iv)
enc = cipher.encrypt(helixlite_padding(m))
print((iv + enc).hex())

# loop:
#   read hex-encoded ciphertext from user
#   decrypt with the SAME key
#   print either "Valid padding" or "Invalid padding"
#   if valid and plaintext == original m: print flag

Helixlite padding

Oracle responses

2) CBC Refresher

For AES-CBC decryption of block Ci with previous block Ci−1:

Pi = Deck(Ci) ⊕ Ci−1  =  I  ⊕  Ci−1

If we control the bytes of the previous block (call it C'i−1), we control the output P'i. Padding validity of P'i reveals information about the last bytes of I.

3) Attack Plan

(A) 3-block oracle (preferred)

Submit a 3-block payload: IV || C'i−1 || Ci. The real previous block is replaced by our crafted block C', which we can change arbitrarily without touching the original ciphertext.

(B) 2-block fallback

If a noisy segment makes the 3-block approach fail transiently, we can fall back to a 2-block shape: C'i−1 || Ci (our crafted block is used as IV). The logic is identical: fix tail as C'[j] = I[j] ^ pad, brute-force one byte at a time.

Important pitfall: The tail must be set as C'[j] = I[j] ^ pad (with your recovered I[j]), not using the original Ci−1. Mixing in the original previous block here is a common mistake and breaks the invariant.

4) Handling Noise & False Positives

Real-world oracles are often noisy: occasional Valid can show up spuriously. We use two cheap filters per guess that dramatically reduce false positives without large retry counts:

Checks

Retries

With the CHK/BREAK filters, we can keep tries = 1 (or at most 2) per candidate guess, maintaining speed while staying robust. Only increase --delay a little if the channel is very jittery.

5) Optimizing the Search Order

Brute-forcing 256 values for every byte is slow. But we know the plaintext is JSON: {"access_code": "watctf{...}", "facility": "...", "clearance": "alpha"}. So we should try likely JSON characters first.

Prioritized plaintext set

Mapping plaintext guess → byte we send

At target index idx, if we want plaintext to be P*, the guess value for work_prev[idx] is:

g = P* ^ pad ^ Ci−1[idx]

Because a Valid tells us I[idx] = g ^ pad, and then the true plaintext is P[idx] = I[idx] ^ Ci−1[idx] = (g ^ pad) ^ Ci−1[idx] = P*.

Effect in practice

This heuristic often finds the right byte in the first few dozen guesses instead of scanning all 256 values, cutting total requests by 5–10× across the entire message.

6) Implementation Notes

Payload shapes

# Preferred (3-block):
payload = IV || work_prev || C_i

# Fallback (2-block):
payload = work_prev || C_i

Tail invariant

for j in range(15, idx, -1):
    work_prev[j] = I[j] ^ pad

Progress dump (optional)

[RECOVER b0 pad=16] P[0]=0x7b '{'  tail=7b 22 61 63 63 65 73 73 5f 63 6f 64 65 22 3a 20  ascii='{"access_code": '

Why the final “forge” prints only Valid padding

Computing new_iv = I1 ⊕ P1 yields exactly the original IV because P1 = I1 ⊕ IV. So the “forged” ciphertext equals the original — the server prints only Valid padding, not Access granted! (the latter triggers only when the decrypted message equals the internally stored JSON).

7) Core Pseudocode

for i = last_block down to first_block:
    I = [0]*16
    work_prev = copy(C[i-1])     # arbitrary base; we overwrite tail each step

    for pad in 1..16:
        idx = 16 - pad
        # fix tail to enforce P'[j] = pad for j>idx
        for j in (15..idx+1):
            work_prev[j] = I[j] ^ pad

        # prioritized guesses
        for P_star in JSON_PRIORITIZED_ASCII:
            g = P_star ^ pad ^ C[i-1][idx]
            work_prev[idx] = g
            if ask(IV || work_prev || C[i]) == Valid:
                if pad == 1:
                    # CHK: flip a non-padded byte, still Valid
                    if not ask(IV || flip(idx-1, work_prev) || C[i]) == Valid: continue
                else:
                    # BREAK: corrupt padded byte, must be Invalid
                    if ask(IV || break_last(work_prev) || C[i]) == Valid: continue

                I[idx] = g ^ pad
                break

    P[i] = I ⊕ C[i-1]

8) Complexity & Practical Numbers

9) Running the Solver

# Fast, noise-tolerant defaults
python3 fast_solve.py --host challs.watctf.org --port 2013 \
  --delay 0.00 --timeout 8 --retries 0 --debug

# If the oracle is jittery
python3 fast_solve.py --host ... --port ... --delay 0.01 --retries 1

Sample successful tail

[ASK b0 pad=16 g=38] > Valid padding
[ASK b0 pad=16 g=38 break] > Invalid padding
[RECOVER b0 pad=16] P[0]=0x7b '{'  tail=7b 22 61 63 63 65 73 73 5f 63 6f 64 65 22 3a 20  ascii='{"access_code": '
[+] recovered plaintext (JSON):
{"access_code": "watctf{quantum_helix_padding_oracle}",
 "facility": "quantum_reactor_z9", "clearance": "alpha"}

10) Pitfalls & Tips

11) Conclusion

This challenge is a textbook CBC padding-oracle with two twists: a PKCS#7-like “helixlite” padding (always add a full block on exact multiples), and a slightly noisy oracle. By enforcing the correct tail invariant C'[j] = I[j] ^ pad, validating candidates with CHK/BREAK tests, and prioritizing JSON-friendly bytes, we obtain a fast and reliable decryptor.

Recovered flag: watctf{quantum_helix_padding_oracle} 🎉