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

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

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

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.

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:

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

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

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:

  • 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:

  • "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:

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:

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:

joined -> the string: 36313634366436393665

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

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

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

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

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

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