Cryptography / Sea of Secrets CTF 2026
The Cursed Encryption Oracle
The challenge is a web oracle at https://cursed-encryption-oracle.vercel.app. /info says it encrypts user_input || secret_flag with AES-128-ECB and PKCS#7 padding, hex-encoded, with at…
Crypto, 200 pts
Flag: flag{d3ad_m3ns_t3ll_n0_ta1l5_but_ECB_d03s}
The challenge is a web oracle at https://cursed-encryption-oracle.vercel.app. /info says it encrypts user_input || secret_flag with AES-128-ECB and PKCS#7 padding, hex-encoded, with at most 256 input bytes. /encrypt takes {"plaintext": "..."} and returns {"ciphertext": "<hex>"}.
Attacker-controlled bytes in front of a secret, with ECB, is the textbook byte-at-a-time setup. Equal plaintext blocks give equal ciphertext blocks, so we can shift the secret across a block boundary and test guesses against it one byte at a time.
First the secret length S. Ciphertext length is 16 * (floor((n + S) / 16) + 1) for n input bytes. The empty input gives 48 bytes and the length stays at 48 for n = 1 through 5, then jumps to 64 at n = 6. So S + 5 < 48 <= S + 6, which gives S = 42.
To recover secret byte k (0-indexed) given the bytes found so far:
- Send
n = (15 - k) mod 16bytes ofA. The target bytesecret[k]is now the last byte of blockb = (n + k) // 16. Save that ciphertext block. - For each printable
c, send"A"*n + known + c. Blockbof the reply equals the saved block exactly whenc == secret[k].
def recover_secret(S):
secret = ""
for k in range(S):
n = (15 - k) % 16
pad = "A" * n
b = (n + k) // 16
target = encrypt(pad)[b*16:(b+1)*16]
for c in map(chr, range(32, 127)):
if encrypt(pad + secret + c)[b*16:(b+1)*16] == target:
secret += c
break
return secretencrypt() is a POST to /encrypt that hex-decodes the ciphertext. I fired the 95 candidates per byte in parallel with a thread pool (60 workers) and retried failed requests, since the Vercel endpoint is slow. The secret comes out as:
flag{d3ad_m3ns_t3ll_n0_ta1l5_but_ECB_d03s}