← Back to portfolioOpen in the interactive reader
Challenge card for AES Is Secure, Right? as shown on the CTF platform: 107 points, the statement in Russian and English, and the attachment.

KubSTU CTF 2026 Autumn · · Task author @ST47IC4 · 45 min read

AES Is Secure, Right?

Flags are blurred: hover or focus one to reveal it.

Event: KubSTU CTF 2026 Autumn · Category: Crypto · Points: 107 Author of the task: @ST47IC4 Flag: KubSTU{c3rt1f1c4t3_r3v0k3d}


0. TL;DR

AES-128 is never attacked. The "intern" derives the AES key from a 4-digit PIN (sha256(pin)[:16]), and output.txt even tells us the first digit. That leaves 1000 possible keys. For each candidate we undo the home-made layers (AES-ECB → block reversal → block rotation → custom padding) and look for readable plaintext. PIN 2785 gives the flag.

Strong algorithm + tiny key space = weak system.


1. The challenge

A KubSTU capybara has locked down the computer lab and keeps waving an infosec certificate: everything's encrypted with a proper algorithm, nothing to see here. The algorithm really is fine. Everything built around it — less so. [...] Prove to the capybara that the certificate was handed out too early.

The statement already gives the idea: "the algorithm is fine, everything around it is not". So we look at how the key is made and how the data is handled, not at AES itself.

The zip contains two files:

File Content
intern_cipher.py the encryption code
output.txt the ciphertext ct (hex, 144 bytes) and the hint # Hint: PIN starts with 2

2. Step 1 — Read the source code

python
from Crypto.Cipher import AES
from hashlib import sha256

def prepare(data: bytes, n: int = 16) -> bytes:
    r = n - len(data) % n
    if r == 0:
        r = n
    k = (len(data) + r) // n
    return data + bytes([k]) * r

def transform(data: bytes, pin: str) -> bytes:
    key = sha256(pin.encode()).digest()[:16]
    cipher = AES.new(key, AES.MODE_ECB)

    data = prepare(data)
    blocks = [data[i:i + 16] for i in range(0, len(data), 16)]

    shift = sum(int(x) for x in pin) % len(blocks)

    blocks = blocks[shift:] + blocks[:shift]
    blocks = blocks[::-1]

    return b''.join(cipher.encrypt(block) for block in blocks)

The program reads secret.txt (the plaintext) and pin.txt (the PIN), and prints the ciphertext in hex. We have neither file, only the output.

2.1 What each part does

(a) Key derivation: key = sha256(pin.encode()).digest()[:16] The PIN (a string such as "2785") is hashed with SHA-256 and the first 16 bytes become the AES-128 key. SHA-256 is fine, but it is deterministic and fast, and the input has only 10 000 possible values. There is no salt and no slow KDF (PBKDF2, scrypt, Argon2), so nothing slows down a brute force.

(b) Mode: AES.MODE_ECB ECB encrypts every 16-byte block independently with the same key. There is no IV and no chaining. This matters in step 4: because blocks do not depend on each other, moving blocks around does not protect anything.

(c) Custom padding prepare(): AES works on 16-byte blocks, so the data is padded.

  • r = 16 - len % 16 is the number of padding bytes (1 to 16, so the r == 0 branch never runs).
  • k = (len + r) // 16 is the total number of blocks.
  • The pad is r copies of the byte k.

This is not PKCS#7. In PKCS#7 the pad byte equals the pad length. Here the pad byte equals the number of blocks. Example for this challenge: the plaintext is 143 bytes, so r = 1 and k = 9; the pad is a single byte 0x09. A standard unpad() would read the last byte (9) and try to strip 9 bytes, which is wrong. So we need our own inverse.

(d) Block shuffling:

python
shift  = sum(int(x) for x in pin) % len(blocks)   # digit sum of the PIN, modulo number of blocks
blocks = blocks[shift:] + blocks[:shift]          # rotate LEFT by `shift`
blocks = blocks[::-1]                             # reverse the list

It looks like extra secrecy, but shift is computed from the PIN itself. It adds no new secret, it just depends on the same 4 digits.

2.2 The full encryption pipeline

text
plaintext
   │  prepare()            custom padding (pad byte = number of blocks)
   ▼
16-byte blocks  [B0 B1 ... B8]
   │  rotate left by shift   shift = digit_sum(PIN) % 9
   ▼
   │  reverse the list
   ▼
   │  AES-128-ECB each block, key = sha256(PIN)[:16]
   ▼
ciphertext (144 bytes = 9 blocks)
fig_01

The encryption pipeline, block by block

Interactive 3D scene. Nine blocks of the plaintext are padded, rotated left by 4 positions, reversed, then each encrypted on its own with AES-128-ECB. Buttons play, pause, step, reset or decrypt the sequence, and a data table lists which block sits in each slot at every stage.
Nine 16-byte blocks go through padding, rotation, reversal and AES-ECB. Press Decrypt to run it backwards.

3. Step 2 — Find the weakness

Ask: what does the attacker not know? Only the PIN. Everything else (code, algorithm, padding rule, shuffle rule) is public.

  • The PIN has 4 digits, so there are 10 000 possibilities.
  • output.txt says "PIN starts with 2", so only 2000–2999 remain: 1000 possibilities.

AES-128 would need about 2¹²⁸ tries. Here we need 1000. The whole effective security of the system is log2(1000) ≈ 10 bits. We do not break AES, we guess the key.


4. Step 3 — Plan the attack

For each candidate PIN:

  1. derive the key,
  2. run the encryption steps backwards,
  3. check if the result looks like a valid message.

Step 3 needs a test that tells "right PIN" from "wrong PIN". With a wrong key, AES-decryption gives random bytes. Two good tests:

  • the padding must be valid (cheap, structural),
  • the plaintext should be readable, or contain a known marker like KubSTU{ (flag format).

5. Step 4 — Invert every operation (in reverse order)

Encryption was: prepare → rotate left → reverse → AES-encrypt. Decryption must be: AES-decrypt → un-reverse → rotate right → un-prepare. Last operation done is first to undo.

Encryption step Inverse Why it works
AES-ECB encrypt each block AES-ECB decrypt each 16-byte block same key (we guess the PIN)
blocks[::-1] blocks[::-1] again reversing twice gives the original
blocks[s:] + blocks[:s] (rotate left by s) blocks[-s:] + blocks[:-s] (rotate right by s) opposite rotation
append k copies of byte k... strip trailing bytes equal to k k = number of blocks, which we can compute from the ciphertext length

Small details that matter:

  • s is computed from the candidate PIN, and the number of blocks is len(ct) // 16 = 9. Since 144 / 16 = 9, s = digit_sum % 9, which takes different values for different PINs, so we must really invert the rotation and not just guess.
  • Order matters: if we rotated before reversing, we would get blocks in the wrong order and garbage text, even with the right key.
  • Un-padding: the pad byte is k = total_blocks & 0xff (= 9 here). We count how many trailing bytes equal 9. A valid pad has 1 to 16 bytes, otherwise we reject the candidate. (Real plaintext could in theory also end with the byte 9, but that is a printable-text problem, not a real risk here.)

6. Step 5 — The solver

Full script (it reads output.txt directly):

python
import re, sys
from hashlib import sha256
from Crypto.Cipher import AES      # pip install pycryptodome

src = open(sys.argv[1]).read()
ct = bytes.fromhex(re.search(r"ct\s*=\s*([0-9a-f]+)", src).group(1))

def unprepare(d, bs=16):
    k = len(d) // bs            # number of blocks = value of the pad byte
    pb = k & 0xff
    pl = 0
    for b in reversed(d):       # count trailing pad bytes
        if b != pb:
            break
        pl += 1
    return None if (pl == 0 or pl > bs) else d[:-pl]

def decrypt(ct, pin):
    key = sha256(pin.encode()).digest()[:16]
    c = AES.new(key, AES.MODE_ECB)
    bl = [c.decrypt(ct[i:i+16]) for i in range(0, len(ct), 16)]   # 1) AES decrypt
    bl.reverse()                                                  # 2) undo reversal
    sh = sum(int(x) for x in pin) % len(bl)
    if sh:
        bl = bl[-sh:] + bl[:-sh]                                  # 3) undo rotation
    return unprepare(b"".join(bl))                                # 4) undo padding

for rest in range(1000):               # first digit "2" known from the hint
    pin = f"2{rest:03d}"
    pt = decrypt(ct, pin)
    if pt and b"KubSTU{" in pt:
        print("PIN :", pin)
        print(pt.decode())
        break

Run: python3 solve.py output.txt


7. Step 6 — What the run shows

I also ran the solver with only the padding check (no KubSTU{ marker) to see how much each filter does:

text
ct bytes: 144 blocks: 9
pins passing padding check: 4
2572  shift 7  -> random bytes
2604  shift 3  -> random bytes
2672  shift 8  -> random bytes
2785  shift 4  -> b'CERTIFICATE OF COMPLETION\nCourse: Information Security Basics\n
                    Issued to: KubSTU Intern Lab\nStatus: REVOKED\n
                    Reason: KubSTU{c3rt1f1c4t3_r3v0k3d}\n'
fig_02

Brute-forcing PINs 2000 to 2999

Interactive 3D grid of 1000 cubes, one per candidate PIN from 2000 to 2999. A scan tries each PIN. Four pass the padding check (2572, 2604, 2672 and 2785) and only 2785 decrypts to readable text, with shift 4. A data table lists the four PINs and the totals.
Every cube is a real attempt computed in your browser. Four PINs pass the padding check; only 2785 gives readable text.

Observations:

  • Out of 1000 PINs, 4 pass the padding check. This is expected: with a wrong key, the last byte is random, and it equals 0x09 with probability 1/256, so 1000 / 256 ≈ 3.9 false positives. The padding check alone is a good filter, not a proof.
  • Only one candidate gives readable text. A second filter (printable ASCII or the KubSTU{ marker) removes the false positives. Random bytes essentially never look like English text, so this is conclusive.
  • The correct PIN is 2785. Its digit sum is 2+7+8+5 = 22, and 22 % 9 = 4, so the blocks were rotated by 4.

The decrypted plaintext, block by block

After padding (143 bytes + 1 byte 0x09 = 144 bytes = 9 blocks), the original block order is:

text
0  CERTIFICATE OF C
1  OMPLETION\nCourse
2  : Information Se
3  curity Basics\nIs
4  sued to: KubSTU 
5  Intern Lab\nStatu
6  s: REVOKED\nReaso
7  n: KubSTU{██████
8  █████████████}\n\x09

(The flag characters are redacted in this listing. They are split across blocks 7 and 8, as the next paragraph explains.)

The flag is split across blocks 7 and 8. That is a nice check: if the rotation or reversal were wrong, the two halves of the flag would not be adjacent. The text is a certificate of completion marked REVOKED, with the flag as the "reason". It matches the story: the capybara's certificate "was handed out too early".


8. Final answer

text
PIN  = 2785
Flag = KubSTU{c3rt1f1c4t3_r3v0k3d}

9. Why each part is (not) secure — lessons

Design choice Problem Better practice
Key = sha256(4-digit PIN) Only 10⁴ keys (here 10³ with the hint). Fast hash, no salt. Random 128-bit key, or a slow KDF (Argon2id / scrypt / PBKDF2) on a long passphrase, with a salt.
ECB mode Blocks are independent: identical plaintext blocks give identical ciphertext blocks, and block order can be rearranged freely. Use an authenticated mode (AES-GCM, ChaCha20-Poly1305) with a unique nonce.
Block rotation + reversal "Security through obscurity". It is a fixed, public permutation, and its parameter comes from the PIN. It adds zero entropy. Do not invent cipher layers. Rely on reviewed constructions.
Custom padding Non-standard, so standard tools fail, but a researcher just writes the inverse. It also gives an oracle (padding check) that filters wrong keys fast. Use PKCS#7 (or no padding with a stream/AEAD mode).
Leaking a hint about the PIN Cuts the key space by 10×. Never leak information about the key.

Main idea: "Don't roll your own crypto" and "a cipher is only as strong as its key". Kerckhoffs's principle says the system must stay secure even if everything except the key is public. Here, everything but the key was public, and the key was guessable in 1000 tries.

10. Method summary (reusable checklist for similar crypto CTFs)

  1. Read the code; list what is secret and what is public.
  2. Measure the key space (how many candidates?). If it is small, brute force.
  3. Write the pipeline as a chain of steps; invert it in reverse order.
  4. Choose a cheap oracle to recognise a correct decryption (padding, flag format, printable text).
  5. Verify the result makes sense (here: the flag is split across two adjacent blocks, and the text tells a coherent story).