0) TL;DR
Valid padding
vs Invalid padding
access_code
(the flag)watctf{quantum_helix_padding_oracle}
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
- Let
N = 16
. - Pad value is the count of added bytes (like PKCS#7), each pad byte equals
pad_len
. - Important: if mlen % N == 0, it still appends a whole block of
0x10
.
Oracle responses
- Valid padding — the decrypted last block passes padding checks.
- Invalid padding — padding check fails.
- Special case: if your submitted ciphertext equals the original (IV||CT), you also get Valid padding.
- If padding is valid and the plaintext exactly matches the original
m
, the server prints the flag.
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.
- Maintain a working buffer
work_prev
forC'
. - Recover
I = Dec(Ci)
from right to left:- For target index
idx = 16 − pad
, we enforce a valid padding of lengthpad
by fixing the tail bytes:for all j > idx: work_prev[j] = I[j] ^ pad // ← key invariant # this ensures P'[j] = I[j] ^ work_prev[j] = pad
- We brute-force one byte
g = work_prev[idx]
. If oracle says Valid, then:I[idx] = g ^ pad
- For target index
- After all 16 bytes, compute plaintext block:
P = I ⊕ Ci−1
(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.
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
- CHK for
pad = 1
(should still be Valid):
Rationale: with# after a Valid at pad=1: t2 = work_prev t2[idx-1] ^= 0x80 # flip a previous, non-padded byte ask(t2 || C_i) → should remain Valid
pad=1
, only the last byte matters. Flipping a non-padded byte must preserve validity. - BREAK for
pad ≥ 2
(should be Invalid):
Rationale: if padding length ≥ 2, corrupting any of those pad bytes must break validity.# after a Valid at pad≥2: t3 = work_prev t3[15] ^= 0x01 # corrupt one of the padded bytes ask(t3 || C_i) → should become Invalid
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
- Punctuation common in JSON:
space
,:
,,
,"
,{}
,[]
,_
,.
- ASCII letters (a–z, A–Z) and digits (0–9)
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
- Worst-case naive brute force: 256 guesses × 16 bytes ≈ 4096 oracle calls / block.
- With JSON prioritization: typically 20–60 guesses / byte → ~500–1000 calls / block.
- With CHK/BREAK filters: retries can stay at
1
, keeping the run fast and stable. - For a 7-block message: ~4–7k total calls is realistic on a stable connection.
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
- Don’t mix in the original previous block when fixing the tail. Always use
C'[j] = I[j] ^ pad
. - Use both CHK (pad=1) and BREAK (pad≥2) to reject false positives.
- Keep retries minimal (
0–1
) and only increase--delay
slightly if needed. - Each connection is fresh (new key and IV). If the socket resets, you must start over.
- Progress logging helps (dump recovered bytes as you go), but don’t spam the wire with extra probes.
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}
🎉