Cryptography / Sea of Secrets CTF 2026

The Cursed Encryption Oracle

01 Nov 2026 · 2 min read

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:

  1. Send n = (15 - k) mod 16 bytes of A. The target byte secret[k] is now the last byte of block b = (n + k) // 16. Save that ciphertext block.
  2. For each printable c, send "A"*n + known + c. Block b of the reply equals the saved block exactly when c == secret[k].
python
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 secret

encrypt() 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:

text
flag{d3ad_m3ns_t3ll_n0_ta1l5_but_ECB_d03s}