Runbook: hex-turbine-plc¶
Entry point¶
This picks up from the engineering workstation. You have a shell there.
PS C:\Users\engineer>
The PLC is at 10.10.3.21. It has no SSH, no web UI, no login shell. Everything is protocol traffic.
Port survey¶
Scan the PLC to see what is listening:
PS C:\Users\engineer> nmap -sV -p 502,2404,4840,20000 10.10.3.21
Starting Nmap 7.93 ( https://nmap.org ) at 2026-06-11 19:15 UTC
Nmap scan report for 10.10.3.21
Host is up (0.00011s latency).
PORT STATE SERVICE VERSION
502/tcp open mbap?
2404/tcp open msdtc Microsoft Distributed Transaction Coordinator
4840/tcp open opcua-tcp?
20000/tcp open dnp?
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows
Nmap done: 1 IP address (1 host up) scanned in 165.63 seconds
Four ports open. nmap’s service guesses are approximate: 502 is Modbus TCP, 4840
is OPC-UA, 20000 is DNP3. Port 2404 is identified as msdtc (Microsoft DTC),
which is a mis-guess: the actual protocol is IEC-104. These are four separate
integration points added at different times, none decommissioned when the next
arrived. SNMP is on UDP, the scan above misses it.
PS C:\Users\engineer> nmap -sU -p 161 --script snmp-sysdescr 10.10.3.21
Starting Nmap 7.93 ( https://nmap.org ) at <timestamp>
Nmap scan report for 10.10.3.21
Host is up (0.000083s latency).
PORT STATE SERVICE
161/udp open snmp
| snmp-sysdescr: HEX-CPU-4000 Turbine PLC, Hex Computing Division, firmware 4.1.2
|_ System uptime: 3m57.13s (23713 timeticks)
Nmap done: 1 IP address (1 host up) scanned in 13.26 seconds
Device identity confirmed. The firmware version and vendor may narrow available
exploit paths. Run snmp-brute to check for default community strings:
PS C:\Users\engineer> nmap -sU -p 161 --script snmp-brute 10.10.3.21
Starting Nmap 7.93 ( https://nmap.org ) at <timestamp>
Nmap scan report for 10.10.3.21
Host is up (0.000087s latency).
PORT STATE SERVICE
161/udp open snmp
| snmp-brute:
| public - Valid credentials
|_ private - Valid credentials
Nmap done: 1 IP address (1 host up) scanned in 14.54 seconds
Both defaults in place. The private community allows read-write access. Before
probing further, check the Documents folder. Ponder saved an SNMP capture there:
PS C:\Users\engineer> cat Documents\snmp_plc_2024-03-15.txt
The file gives sysContact (Ponder Stibbons <ponder@unseen.edu>), sysLocation
(Hex Engine Room, Unseen University, Ankh-Morpork), and confirms the
rwcommunity private note at the bottom. It also lists the MAC address and
interface counters from the time of capture.
Reading the process state¶
Modbus TCP on port 502 requires no credentials. Read the input registers first:
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 input 0 11
[2986, 421, 83, 229, 74, 228, 73, 498, 31, 8, 15]
Eleven values in order: RPM, temperature (°C), pressure (bar), line voltage A (V), line current A (A), line voltage B (V), line current B (A), frequency ×10 (so 498 means 49.8 Hz), power (kW), oil pressure (bar), vibration ×10 (so 15 means 1.5 mm/s). The values shown above are illustrative; yours will differ. Run the command a few times to watch them drift.
Read the coils:
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 coil 0 7
[False, False, False, False, False, True, True]
Seven bits in order: emergency stop, alarm overspeed, alarm overtemp, alarm overpressure, alarm undervoltage, breaker A closed, breaker B closed. In normal operation all alarm flags are False and both breakers are True (closed).
At steady state:
RPM: ~3000
Temperature: 400-440°C
Pressure: 80-90 bar
Voltage: 220-235 V on each feeder
Frequency: 470-500 (47.0-50.0 Hz)
Fuel valve: 60-80%, adjusted by governor
Cooling pump: 100%
IEC-104¶
Port 2404 serves live turbine data over IEC-104 without authentication. Verify the connection and get a raw response:
PS C:\Users\engineer> python -c "import socket,time; s=socket.socket(); s.connect(('10.10.3.21',2404)); s.settimeout(3); s.sendall(bytes([0x68,0x04,0x07,0x00,0x00,0x00])); time.sleep(0.2); r=s.recv(64); print('STARTDT confirmed:',r[:6].hex()=='68040b000000'); s.sendall(bytes([0x68,0x04,0x00,0x00,0x00,0x00])); time.sleep(0.3); data=s.recv(512); print('ASDU type:',hex(data[6])); print('raw:',data.hex()); s.close()"
STARTDT confirmed: True
ASDU type: 0x9
raw: 6819000000000904010001000100004c7200180b00b60800061300
Type 9 is M_ME_NB_1 (normalised measured value). The ASDU carries four values:
RPM, temperature, voltage A, and frequency. Each is encoded as raw × 10 of the
Modbus register value. A proper IEC-104 client (lib60870, python-iec104)
decodes these automatically and maps them to engineering units.
OPC-UA¶
The nmap scan showed port 4840 open (opcua-tcp). Connect endpoint:
opc.tcp://10.10.3.21:4840. Security: None. Authentication: anonymous.
This is not a Modbus-to-OPC-UA bridge; it is a separate sidecar service sharing
the PLC’s IP address. The sidecar runs its own node tree. An OPC-UA client (such
as opcua-client-gui or python-asyncua) can browse the namespace, read node
values, and call methods. The node tree includes pump-like objects with callable
methods. Neither asyncua nor opcua-client is pre-installed on this
workstation; a tool brought in from elsewhere connects without credentials.
The interesting implication is that two independent services sit on 10.10.3.21. Port 502 is the physical controller; port 4840 is the OPC-UA layer. Actions on one are not automatically visible on the other.
Governor behaviour¶
The governor is a proportional controller. Its only input is HR[0] (setpoint) and IR[0] (current RPM); its only output is HR[1] (fuel valve). Raising the setpoint causes the governor to open the valve; RPM climbs to match. Lowering it causes the valve to close; RPM falls.
Try a modest change: raise the setpoint by 200 and read it back:
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 holding 0 3200
Written 3200 to holding[0] on 10.10.3.21:502
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 holding 0 1
[3200]
Watch IR[0] (RPM) over the next thirty seconds via modbus_read.py. RPM climbs;
temperature follows; frequency rises proportionally; power output increases. The
effects appear in all telemetry channels simultaneously.
Restore the original value:
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 holding 0 3000
Written 3000 to holding[0] on 10.10.3.21:502
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 holding 0 1
[3000]
Trips and what triggers them¶
The physics loop monitors three conditions and fires the emergency stop coil automatically if any is exceeded:
RPM > 3300: overspeed trip (coil 1 sets, coil 0 sets automatically)
Temperature > 490°C: overtemp trip (coil 2 sets, coil 0 sets automatically)
Pressure > 95 bar: overpressure trip (coil 3 sets, coil 0 sets automatically)
A fourth condition, undervoltage (voltage < 85% of 230 V), sets coil 4 but does not automatically trip the machine.
To trigger an overspeed trip: write the setpoint to a value above 3300. The governor raises fuel; RPM climbs past the trip threshold; the physics loop sets estop and overspeed alarm simultaneously; the governor sees estop and zeroes the fuel valve.
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 holding 0 3800
Written 3800 to holding[0] on 10.10.3.21:502
Read coils within a few seconds:
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 coil 0 7
[True, False, False, False, True, True, True]
Coil 0 (estop) is set. Coil 1 (overspeed alarm) may already have cleared by the time you read: the physics loop sets it the moment RPM exceeds 3300 but clears it automatically once RPM drops back below 3300, which happens quickly after the fuel valve closes. Coil 4 (undervoltage alarm) sets as voltage drops with RPM. Coil 0 does not clear automatically; it latches until written back.
Recovery: lower the setpoint first, then release the estop. In the wrong order, the setpoint is still above 3300 and the trip fires again immediately.
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 holding 0 3000
Written 3000 to holding[0] on 10.10.3.21:502
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 coil 0 false
Written false to coil[0] on 10.10.3.21:502
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 coil 0 2
[False, False]
A direct emergency stop without a trip: write the coil without changing the setpoint.
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 coil 0 true
Written true to coil[0] on 10.10.3.21:502
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 coil 0 2
[True, False]
The fuel valve closes immediately. RPM decays. The coil stays True until written back to False. This differs from an overspeed trip only in that no alarm flag is set alongside it, and the setpoint stays unchanged.
Transient vs persistent¶
All register writes take effect immediately and stay until the next write or a restart. Nothing is written to persistent storage.
Setting HR[0] (setpoint) to a new value: stays at that value until written again. The governor continues chasing the new target indefinitely.
Writing estop coil True: stays True until explicitly written False.
Alarm coils (1-4): set and cleared automatically by the physics loop based on live sensor values. Writing to them has no lasting effect.
HR[2] (cooling pump speed): the governor does not touch this. A write persists. Reducing cooling causes temperature to climb toward the overtemp trip.
HR[3] (overcurrent threshold): persists in the PLC register. The relay IEDs at 10.10.3.31 and 10.10.3.32 hold their own copies of this threshold and are not affected by a PLC register write.
After a restart, all registers reset to defaults: setpoint 3000, cooling pump 100%, overcurrent threshold 200A, estop 0.
Cooling pump as a slow path¶
Lowering the cooling pump speed (HR[2]) raises temperature gradually over minutes rather than seconds. At cooling=0, the temperature climbs from nominal (420°C) toward the overtemp trip (490°C). The rate depends on current fuel load. At full load the trip fires in under two minutes; at lower RPM it takes longer.
This produces a different observable signature than an overspeed trip: RPM stays normal while temperature climbs. Alarm coil 2 fires; estop fires; the machine trips on overtemperature rather than overspeed. A monitoring system that watches only the estop flag sees the same event either way.
PS C:\Users\engineer> python Tools\modbus_write.py 10.10.3.21 502 holding 2 0
Written 0 to holding[2] on 10.10.3.21:502
PS C:\Users\engineer> python Tools\modbus_read.py 10.10.3.21 502 holding 0 4
[3000, 15, 0, 200]
Cooling pump speed is now 0. Read temperature at 30-second intervals via
modbus_read.py to watch it climb.