Cyber League 2025 CTF Writeup: Crypto, Forensics, Cloud & More
Our Cyber League 2025 CTF writeup covers Crypto, Forensics, Cloud, and Misc challenges. Detailed solutions, insights, and lessons learned—this guide will level up your cybersecurity game!
' OR 1=1--
Cyber League 2025 was nothing short of shiok—a thrilling competition packed with brain-teasing challenges. My team and I, representing ' OR 1=1--
, had an unforgettable experience hacking through Crypto, Forensics, Cloud, and Misc categories. The organisers outdid themselves, delivering a well-structured event that kept us on our toes for 24 hours non-stop! Big shoutout to the Cyber League crew for their hard work and to my team members for the fantastic synergy we had during this competition.
For those following our writeup, whether you’re a fellow competitor or someone keen to learn, we’ve put together our solutions in a clear, easy-to-understand format. From cryptography puzzles that tested our algorithms to forensic investigations and cloud exploits, we’ve broken down each challenge and shared the thought process, tools, and scripts that helped us secure our spot on the leaderboard.
Forensics - Uncover Me
Cyberleague warm-up challenge
Really straightforward challenge, john
was able to crack the password protected zip file and retrieve the contents.
CYBERLEAGUE{Y0U_Fo|_|nD_3e!}
This methodology will be applied again at Matryoshka II
Forensics - Baby PCAP
Solver #10
Looks like a Client-Server DNS communication script and is verified by the challenge's description
We extracted this results into a .csv
and retrieved the encrypted string
grep -oP 'result\.\K[^.]+(?=\.chall123)' extracted.csv | sort | uniq
ECwydiAfdiIyJ3YrIhIRLm8lBVNMVCMqA0cdPVgQKkoKZCUZFT9DOAFEUFw=
fB0fXhd8RgEAHEcl
IRofRw==
NRkRVA==
And obviously we used Chatgpt to write a decode script:
python dec.py
CYBERLEAGUE{baby_warmup_stonks_894ejfhsjeeq}
/home/ubuntu
root
flag
Miscellaneous - Log Analysis
Solver #11
Looking at the snippet, we found a leet flag
string to /supersecure/
to search param f1a9=
(last line). Using grep, we extracted the parameter from the log file:
grep -oP '(?<=\?f1a9=)[0-9A-Fa-f]+' log_challenge.log
43
59
42
45
52
4c
45
41
47
55
45
7b
6c
30
67
73
5f
52
5f
62
30
52
69
4e
67
7d
Then using decode.fr, we can attempt to decode this to ASCII:
Miscellaneous - Pwn-Dis-File
Solver #71
We used pdfdetach
to extract files in the pdf
file:
pdfdetach -saveall Pwn-Dis-File.pdf
CYBERLEAGUE{hidden_in_plain_sight}
Miscellaneous - Matryoshka
Solver #80
Time to put your programming skills to the test! Don't do it by hand (unless you like pain of course)!
What we have to achieve would be to extract the compressed file given to us. binwalk -e
returns multiple compressed files and was not fun to work with. We then crafted a script to identify the file and extract the compressed file.
Let the script run for abit and stop when file
returns an ASCII text, with no line terminators
result. Then go into the directory and view the file contents:
cat matryoshka.txt
CYBERLEAGUE{m0r3_c0mpr3ss_m0r3_b3tt3r3r}%
Miscellaneous - Matryoshka II
Solver #61
Time to put your programming skills to the test! Don't do it by hand (unless you like pain of course)!
Similar approach to Matryoshka
, this time we found out that the given file requires a password. And the password that is required is actually the filename in the zip file, shown in the image below.
With this in mind, we could use a small script to unzip the zip files to get the flag. In this script, we used 7za
with the -aoa
flag to always replace the existing file since we found that files such as et
were frequently seen and caused some issues if we were to rename them.
#!/bin/bash
file="matryoshka2.zip"
rm -r tmp 2>/dev/null
mkdir tmp
cp "$file" tmp
cd tmp
while [ 1 ]; do
# Extract the pin from the zip file
pin=$(unzip -l "$file" | awk 'NR>3 {print $4}' | head -n -2)
# Extract the archive using the pin, silently handle name clashes by overwriting
7za x "$file" -p"$pin" -aoa
# Find the newly extracted file (exclude the old file)
new_file=$pin
# Check if a new file was found
if [ -z "$new_file" ]; then
echo "No new file extracted. Exiting..."
break
fi
# Update $file with the new file name
file="$new_file"
echo "Extracted new file: $file"
done
Finally, the script will error out at dolor
since it is an ASCII text file (containing the flag)
file dolor
dolor: ASCII text, with no line terminators
cat dolor
CYBERLEAGUE{m0r3_3ncryp7_m0r3_b3tt3r3r}%
Cloud - Perfect Storage
Solver #40
aws configure
# Note: use ap-southeast-1
And so we begin enumeration!
With the user's information, we can enumerate for the account's policies with aws cli
We could continue to enumerate the policies.
With the S3 resource name, we can perform further enumeration!
We could work out the bucket public link using this format:
http://<bucket_name>.<region>.amazonaws.com
And access the site through a browser
Notice that the bucket contained the flag.txt
. This was the bucket that we needed to work on (hopefully)
We were unable to further enumerate for methods of Privesc, so we performed an unauthenticated attack using curl to attempt to retrieve the flag:
so we elevated our privilege from an employee to a user!
So why does this work? Upon researching about IAM's policy conditions, we saw this:
We could either pass in an empty PrincipleARN or probably just access it unauthenticated. So, either one of these would work:
aws s3 cp s3://perfect-storage-7815696ecbf1c96/flag.txt . --no-sign-request
curl http://perfect-storage-7815696ecbf1c96.s3.amazonaws.com/flag.txt
Cryptography - One Time Pin
Solver #54
To capture the flag in this challenge, we need to provide a hexadecimal input that matches the server's randomly generated hexadecimal value. This value is derived from a random number using Python's random.Random
library. However, the process is not truly random—it’s pseudorandom. The server regenerates a new random number whenever we provide the wrong input. The random number generator (RNG) relies on Python’s random module, which uses a pseudorandom number generator (PRNG) to produce a sequence of numbers that mimic the properties of randomness. A PRNG starts from an initial seed state, and the generated sequence is deterministic, meaning it can be reproduced if the seed is known. This makes the numbers predictable, despite appearing random, and the process is computationally efficient.
To predict the server's randomly generated number, we first need to collect 624 samples of the server’s output. This is achieved by repeatedly sending the input "00000000"
to the server and capturing the difference (delta) between the generated number and our input, extracted from the server’s response. Each delta value, converted from hexadecimal to its numerical representation, corresponds to the server’s pseudorandomly generated number.
After collecting all 624 samples, we use the MT19937Predictor
library to reconstruct the internal state of the Mersenne Twister pseudorandom number generator (PRNG) used by the server. By feeding these samples into the predictor, we enable it to predict the next number in the PRNG sequence. Using this predicted value, we can send the correct PIN to the server and retrieve the flag.
#!/usr/bin/env python3
import socket
import re
import sys
import time
from mt19937predictor import MT19937Predictor
SERVER_IP = "127.0.0.1" # Replace with the actual server IP
SERVER_PORT = 10008 # Replace with the actual server port
TOTAL_SAMPLE = 625 # 624 + 1, catering for loop
# The exact prompt string (as bytes) that we wait for.
PROMPT = b"Please enter your 8-char one time pin to continue [00000000 - ffffffff]:"
SUCCESS_PROMPT = b"flag:"
HEX_RE = re.compile(rb"Error 0x([0-9a-fA-F]+): Incorrect PIN")
def read_until(sock, target, timeout=0.1):
"""
Read data from the socket until the target bytes are seen or until timeout.
Returns all data read.
"""
sock.settimeout(timeout)
data = b""
start = time.time()
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break # timed out; return what we have so far.
if not chunk:
break # connection closed
data += chunk
if target in data:
break
# Just in case we get stuck, check the elapsed time.
if time.time() - start > timeout:
break
return data
def read_all(sock, timeout=10.1):
"""
Read all available data from the socket until there's no more data
or until `timeout` seconds pass without new data.
"""
sock.settimeout(timeout)
data = b""
while True:
try:
chunk = sock.recv(4096)
if not chunk:
# Connection closed
break
data += chunk
except socket.timeout:
# No more data within the timeout window
break
return data
def collect_samples(sock):
"""
Collect 624 delta samples from the server.
"""
samples = []
for i in range(TOTAL_SAMPLE):
# Send "00000000" to the server
sock.sendall(b"00000000\n")
# Read all responses
data = read_all(sock, timeout=0.1)
# Extract delta value
if b"Error 0x" in data:
try:
delta = extract_hex_value(data)
samples.append(delta)
print(f"[{i}] Captured delta: {delta}")
except ValueError as e:
print(f"Failed to parse delta at iteration {i}: {e}")
continue
# Check for the prompt
if PROMPT not in data:
print(f"Unexpected server response at iteration {i}: {data.decode(errors='replace')}")
continue
if len(samples) != TOTAL_SAMPLE:
raise RuntimeError(f"Only collected {len(samples)} samples, but expected {TOTAL_SAMPLE}.")
return samples
def predict_next_number(samples):
"""
Use the captured samples to predict the next random number.
"""
predictor = MT19937Predictor()
for sample in samples:
predictor.setrandbits(sample, 32)
return predictor
def try_predictions(sock, predictor, max_attempts=5):
"""
Attempt multiple predictions to handle potential misalignments.
"""
for attempt in range(max_attempts):
next_num = predictor.getrandbits(32)
pin_to_send = f"{next_num:08x}\n"
print(f"Trying predicted PIN (attempt {attempt + 1}): {pin_to_send.strip()}")
sock.sendall(pin_to_send.encode())
# Read the server's response
response = read_all(sock, timeout=5.0)
print(f"Server response:\n{response.decode(errors='replace')}")
if SUCCESS_PROMPT in response:
print("Successfully retrieved the flag!")
return True
print("All prediction attempts failed. PRNG state might be misaligned.")
return False
def extract_hex_value(data: bytes) -> int:
"""
Given a bytes object containing an error response like:
"Error 0x1fe460f6: Incorrect PIN.Welcome Admin!"
extract the hex value and return it as an integer.
"""
match = HEX_RE.search(data)
if not match:
raise ValueError("Could not locate the error hex in:\n" + data.decode(errors="replace"))
hex_str = match.group(1).decode()
return int(hex_str, 16)
def main():
samples = []
predictor = MT19937Predictor()
samples = []
try:
sock = socket.create_connection((SERVER_IP, SERVER_PORT))
# Step 1: Collect samples
print("Collecting samples...")
samples = collect_samples(sock)
# Step 2: Recover PRNG state
print("Recovering PRNG state...")
predictor = predict_next_number(samples)
# Step 3: Attempt predictions
print("Predicting the next random number and sending PIN...")
success = try_predictions(sock, predictor)
if not success:
print("Failed to retrieve the flag. Check alignment or server logic.")
except Exception as e:
print(f"An error occurred: {e}")
finally:
print("Closing connection.")
sock.close()
if __name__ == "__main__":
main()
Final Scores
Wrapping up, Cyber League 2025 was a blast, and we're already looking forward to next year’s showdown. This CTF not only pushed our technical skills to the next level but also reminded us why we love cybersecurity—every challenge is a puzzle waiting to be cracked, and every solution is a step closer to mastery.
Kudos to the organisers once again for such a well-executed event. To those reading this, feel free to reach out if you have questions about our solutions or want to exchange ideas. Let’s keep the learning going!
Until next time—stay curious, stay sharp, and hack responsibly. See you at the next Cyber League!
Comments ()