A Researcher's Guide to IEC 60870-5-104
Part of the alosafuzz ICS protocol-guide series — written for fuzzing and vulnerability researchers. Defensive posture: wire format, fuzz surface, and bug classes only; no weaponized exploit or DoS code.
1. What it is & where it runs
IEC 60870-5-104 (“IEC 104”) is the TCP/IP adaptation of the IEC 60870-5-101 telecontrol application layer — the European counterpart to DNP3. It is heavy in European electric power (control-centre ⇄ RTU/IED), water, and gas SCADA.
What makes it distinct from Modbus/DNP3 for a researcher is that it is connection-oriented with real session state: no application data flows until a STARTDT handshake completes, and a sliding-window sequence-number scheme (SSN/RSN) governs every I-frame — the device validates these numbers, so they are a genuine stateful fuzz surface rather than an echoed correlation field. On top of that sits the IEC 101 ASDU with a large TypeID space and timestamped control commands.
- Transport: TCP port 2404 (IANA). Spec IEC 60870-5-104:2006 + A1:2016; ASDUs from IEC 60870-5-101:2003.
2. Session & transport model
TCP connect → STARTDT_ACT (U) → STARTDT_CON (U) → [DATA_TRANSFER]
→ I-frame(ASDU) ⇄ I-frame/S-frame → … → STOPDT_ACT → STOPDT_CON
Three frame kinds, selected by the low bits of control-field byte 1: I-frame (information, carries an ASDU, increments SSN), S-frame (supervisory, pure ACK via RSN), U-frame (unnumbered session control: STARTDT/STOPDT/TESTFR act/con). A fuzzer must complete STARTDT before its I-frame cases are in the DATA_TRANSFER state — and “I-frame before STARTDT” is itself a state-machine test (some devices process it anyway).
3. Wire format — APDU & ASDU
APDU: 0x68 | LEN | CF1 CF2 CF3 CF4 | ASDU…. Start byte always 0x68; LEN
counts CF1..CF4 + ASDU (4..253).
Control field:
- I-frame (CF1 bit0=0): CF1–CF2 = SSN (15-bit, LE, bit0 cleared), CF3–CF4 =
RSN. Encode
struct.pack("<H",(n & 0x7FFF)<<1). - S-frame (CF1 & 0x03 == 0x01): CF1=0x01,CF2=0x00, CF3–CF4 = RSN.
- U-frame (CF1 & 0x03 == 0x03): CF1 encodes the function — STARTDT_ACT 0x07 / _CON 0x0B, STOPDT_ACT 0x13 / _CON 0x23, TESTFR_ACT 0x43 / _CON 0x83; CF2–CF4 = 0.
ASDU: TypeID | VSQ | COT_lo COT_hi | CA_lo CA_hi | InformationObjects….
- VSQ: bit7 SQ (sequential IOAs from one base), bits6–0 object count.
- COT (cause of transmission): byte1 = T|P/N|Cause(6), byte2 = originator address. COT 44–47 (unknown TypeID/COT/CA/IOA) are the exception codes — the spec-compliant rejection, not a crash.
- CA (common address, 2B LE; 0xFFFF global). Information objects: a 3-byte IOA + type-specific data.
Key TypeIDs: monitor 1/3/9/11/13/30 (single/double point, measured values, timestamped), control 45/46 (single/double command), 48/50/51 (set-points normalised/scaled/float), 100 (General Interrogation), 101 (counter interrogation), 103 (clock sync, CP56Time2a). TypeIDs 128–255 are vendor; many reserved ranges (64–99 …) should return COT=44.
4. The fuzz surface
- SSN/RSN sliding window (the signature IEC 104 surface): RSN ahead of SSN (ACKing unsent frames), SSN gaps, duplicate SSN, SSN=0x7FFF→0x0000 wraparound. Because the device validates these, mis-handling can confuse state or exhaust buffers — this is where IEC 104 differs most from Modbus-style fuzzing.
- TypeID dispatch — sweep the TypeID byte, especially the reserved 64–99 range; some RTUs hang or restart instead of returning COT=44.
- VSQ object count vs. actual objects present; SQ bit with inconsistent IOAs.
- Control set-point float edges (C_SE_NC_1, TypeID 51) — NaN, ±Inf, ±MAX_FLOAT into an analog-output driver.
- CP56Time2a (clock sync / timestamped commands) — invalid day/month, year=99 (2099), SU (summer-time) toggling mid-session, out-of-range millisecond field.
- General Interrogation timing — issue GI (TypeID 100) before the previous one terminates; GI to CA=0xFFFF (global) when the device expects a specific CA.
- U-frame / state machine — STARTDT replay mid-session (forced re-init), I-frame before STARTDT, STOPDT/TESTFR out of order.
- APDU framing —
LEN< 4 or > 253, truncated body, bad start byte (binary corruption → device should close).
Classification line: COT 44–47 (and P/N negative confirmation) are exceptions, not crashes — the direct analogue of Modbus FC|0x80. The crash signal is timeout/reset/no-response on a well-formed, in-state frame.
Structured vs. coverage-guided: firmly structured — the value is in the stateful SSN/RSN and session-machine bugs and the ASDU semantics, all reached only after a real STARTDT. A coverage-guided campaign would instrument an open-source stack (lib60870, below) for the ASDU/APDU byte parser, complementary to the structured state fuzzing.
5. Known vulnerabilities & bug classes
Durable, repeatedly-productive IEC 104 classes (field-level):
- TypeID flooding (reserved 64–99 at rate) — hang/restart instead of COT=44.
- Sequence-number abuse — RSN ≫ SSN confuses weak validators; SSN gaps exhaust buffers.
- GI timing / global-CA GI — overlapping interrogations, broadcast GI mishandling.
- Set-point NaN/Inf — analog-output driver faults.
- CP56Time2a invalid fields — date/time validation bugs.
- STARTDT replay / I-frame-before-STARTDT — session-state enforcement gaps.
6. Building a test harness
Robustness responder (scripts/iec104_responder.py, port 2405): answers
STARTDT_ACT→CON, TESTFR_ACT→CON, STOPDT_ACT→CON; returns COT=44 for all
I-frames (no process image modelled); closes on bad start byte, LEN<4 / >253,
truncated body, I-frame before STARTDT, invalid CF1; sidecar JSONL.
Harness notes:
- SSN patching — unlike Modbus TID, IEC 104 SSN is a validated sequence
number; patch it to the tracked value for all categories except
sequence(the SSN value is the test) andbinary_corruption(raw bytes are the test). Without this, every I-frame after the first is rejected. - Multi-APDU drain —
frame_interleaving/gi_timingconcatenate APDUs;_count_apdus()then drain N−1 responses (the Modbus multi-ADU lesson).
Calibration baseline (software responder, 537 cases): clean — 69 crash indicators (35 timeout from S-frames/truncated writes, 18 connection_reset from bad start/LEN/CF1, 16 no_response post-close), 0 malformed_response false positives, all explained by 40 expected sidecar closes. On real hardware, a crash indicator with no matching sidecar close is a finding candidate.
7. Open-source implementations & tooling
- lib60870 (MZ Automation, C) — the reference IEC 101/104 stack; compilable → ASan/coverage-guided for the APDU/ASDU parser, and a live robustness target.
- c104 (Python, binds lib60870) — quick client for valid seeds.
- Wireshark
104apci/104asdudissectors.
8. Spec references & sources
- IEC 60870-5-104:2006 + Amendment 1:2016; IEC 60870-5-101:2003 (ASDUs).
- Wireshark IEC 104 dissectors.
9. Open questions / under-explored surface
- A coverage-guided ASan campaign on lib60870’s ASDU parser (information-object decoding for the timestamped/measured TypeIDs) — not yet run.
- Which RTUs actually hang on reserved-TypeID floods vs. returning COT=44 — needs device-level testing.
- Deeper SSN/RSN window-exhaustion behavior at the W-parameter boundary on real stacks.
- IEC 62351 (the security extension for 104) — a separate, newer surface.
Worked example: protocols/iec104/fuzzer.py (11 categories, 537 cases);
responder scripts/iec104_responder.py.