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
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 % 16is the number of padding bytes (1 to 16, so ther == 0branch never runs).k = (len + r) // 16is the total number of blocks.- The pad is
rcopies of the bytek.
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:
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 listIt 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
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)The encryption pipeline, block by block
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.txtsays "PIN starts with 2", so only2000–2999remain: 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:
- derive the key,
- run the encryption steps backwards,
- 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:
sis computed from the candidate PIN, and the number of blocks islen(ct) // 16 = 9. Since144 / 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):
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())
breakRun: 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:
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'Brute-forcing PINs 2000 to 2999
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
0x09with probability 1/256, so1000 / 256 ≈ 3.9false 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, and22 % 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:
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
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)
- Read the code; list what is secret and what is public.
- Measure the key space (how many candidates?). If it is small, brute force.
- Write the pipeline as a chain of steps; invert it in reverse order.
- Choose a cheap oracle to recognise a correct decryption (padding, flag format, printable text).
- Verify the result makes sense (here: the flag is split across two adjacent blocks, and the text tells a coherent story).
