Chapter 2 — Networks and protocols
Lesson map
Lesson 02 published. Map, example, EX005.
flowchart TD
N0["Learning outcomes and connection"]
N1["2.1 Industrial protocols and trust"]
N2["2.2 The Modbus data model"]
N3["2.3 The MBAP header"]
N4["2.5 Practical activity"]
N5["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5
N5 --> EX["Worked example"]
EX --> Q["Exercise EX005"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["2.1 Industrial protocols and trust"]
N2["2.2 The Modbus data model"]
N3["2.3 The MBAP header"]
N4["2.5 Practical activity"]
N5["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> Q["EX005"]
[Learning outcomes and connection]
|
v
[2.1 Industrial protocols and trust]
|
v
[2.2 The Modbus data model]
|
v
[2.3 The MBAP header]
|
v
[2.5 Practical activity]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX005]
Learning outcomes and connection
After this chapter you should be able to:
- explain why most industrial protocols carry no authentication, and what that means for whoever can reach a controller's port;
- decode a Modbus/TCP frame by hand and in code: MBAP header, unit identifier, function code, address and quantity;
- tell read functions from write functions and recognize diagnostic and identification functions that deserve review;
- turn a decoder into a first detection: writes from any host other than the authorized engineering source.
Chapter 1 drew the paths. This chapter opens the packets on the control and monitoring paths so that later chapters can filter, verify and detect them.
2.1 Industrial protocols and trust
Protocols such as Modbus and DNP3 were designed for closed serial networks where anything on the wire was assumed to belong there. Their TCP/IP versions kept that assumption: a classic Modbus/TCP device accepts a valid write from any host that can open a TCP connection to port 502. Secure variants exist — the Modbus Organization publishes a Modbus/TCP Security specification using TLS (Modbus specifications), and DNP3 has Secure Authentication (DNP Users Group overview) — but installed devices often do not support them. Until they do, reachability is authorization, and the network controls of Chapters 10 and 11 are the main defence.
2.2 The Modbus data model
A Modbus device exposes four tables: coils (single-bit read/write), discrete inputs (single-bit read-only), input registers (16-bit read-only) and holding registers (16-bit read/write). What a register means — level in centimetres, a pump setpoint, a mode flag — is defined by the device's configuration and documented in a register map. The protocol itself carries numbers, not meanings, so a decoder can tell you "write to holding register 7" but only the register map tells you that register 7 is the pump-2 setpoint.
Common public function codes are 1 (read coils), 2 (read discrete inputs), 3 (read holding registers), 4 (read input registers), 5 (write single coil), 6 (write single register), 15 (write multiple coils), 16 (write multiple registers), 8 (diagnostics) and 43/14 (read device identification). Function codes are defined in the Modbus Application Protocol specification published by the Modbus Organization (Modbus specifications).
2.3 The MBAP header
On TCP each Modbus request starts with a 7-byte MBAP header followed by the protocol data unit:
| Bytes | Field | Notes |
|---|---|---|
| 0–1 | Transaction identifier | Pairs a response with its request. |
| 2–3 | Protocol identifier | Always 0 for Modbus. |
| 4–5 | Length | Number of bytes that follow this field (unit id + PDU). |
| 6 | Unit identifier | Addresses a device behind a gateway; often 1 or 255. |
| 7 | Function code | What to do. |
| 8– | Data | For codes 1–6: a 2-byte address and a 2-byte quantity or value. |
All multi-byte fields are big-endian. Python's struct module decodes them with the format >HHHBB (Python struct). The frame 00 03 00 00 00 06 01 06 00 07 01 2c therefore reads: transaction 3, protocol 0, six bytes follow, unit 1, function 6 (write single register), register 7, value 0x012C = 300.
Two consistency checks catch most malformed or non-Modbus traffic on port 502: the protocol identifier must be 0, and the length field must equal the number of bytes after it. A detection tool that skips these checks will misread garbage as commands.
2.4 Worked example: a capture from KW-04 (synthetic)
A technician exported eight requests sent to the KW-04 RTU (10.10.4.2) during a maintenance morning. KVROC policy says only the engineering workstation eng-ws-15 (10.20.0.15) may write to station controllers; scada-01 (10.20.0.10) only reads.
Lab 2 (save as lab02.py, run with python3 lab02.py):
# Lab 2 - decode Modbus/TCP frames and flag writes (offline, standard library only)
# Synthetic capture from Kestrel Water station KW-04; addresses are private (RFC 1918).
import struct
ENGINEERING = {"10.20.0.15"} # only the engineering workstation may write
WRITE_FC = {5: "write single coil", 6: "write single register",
15: "write multiple coils", 16: "write multiple registers"}
READ_FC = {1: "read coils", 2: "read discrete inputs", 3: "read holding registers", 4: "read input registers"}
capture = [ # (source, destination, frame as hex)
("10.20.0.10", "10.10.4.2", "000100000006010300000004"),
("10.20.0.10", "10.10.4.2", "000200000006010400100002"),
("10.20.0.15", "10.10.4.2", "00030000000601060007012c"),
("10.30.3.7", "10.10.4.2", "000400000006010500a0ff00"),
("10.30.3.7", "10.10.4.2", "00050000000b011000070002040064000a"),
("10.20.0.10", "10.10.4.2", "0006000000060103"),
("10.20.0.10", "10.10.4.2", "000700010006010300000004"),
("10.20.0.10", "10.10.4.2", "000800000006012b0e010000"),
]
def decode(hexframe):
b = bytes.fromhex(hexframe)
if len(b) < 8: return None, "short frame (< 8 bytes)"
tid, pid, length, unit, fc = struct.unpack(">HHHBB", b[:8])
if pid != 0: return None, f"protocol id {pid} is not 0 (not Modbus)"
if length != len(b) - 6: return None, f"length field {length} but {len(b) - 6} bytes follow"
addr, qty = struct.unpack(">HH", b[8:12]) if len(b) >= 12 else (None, None)
return (tid, unit, fc, addr, qty), None
for src, dst, h in capture:
d, err = decode(h)
if err: print(f"{src:<11} -> {dst} MALFORMED {err}"); continue
tid, unit, fc, addr, qty = d
if fc in WRITE_FC:
verdict = "ok (engineering)" if src in ENGINEERING else "ALERT unauthorized write"
print(f"{src:<11} -> {dst} tid={tid} unit={unit} fc={fc:<2} {WRITE_FC[fc]:<24} addr={addr} value/qty={qty} {verdict}")
elif fc in READ_FC:
print(f"{src:<11} -> {dst} tid={tid} unit={unit} fc={fc:<2} {READ_FC[fc]:<24} addr={addr} qty={qty} ok")
else:
print(f"{src:<11} -> {dst} tid={tid} unit={unit} fc={fc:<2} {'other function':<24} REVIEW (not on read/write list)")
Expected output (Python 3.13.5):
10.20.0.10 -> 10.10.4.2 tid=1 unit=1 fc=3 read holding registers addr=0 qty=4 ok
10.20.0.10 -> 10.10.4.2 tid=2 unit=1 fc=4 read input registers addr=16 qty=2 ok
10.20.0.15 -> 10.10.4.2 tid=3 unit=1 fc=6 write single register addr=7 value/qty=300 ok (engineering)
10.30.3.7 -> 10.10.4.2 tid=4 unit=1 fc=5 write single coil addr=160 value/qty=65280 ALERT unauthorized write
10.30.3.7 -> 10.10.4.2 tid=5 unit=1 fc=16 write multiple registers addr=7 value/qty=2 ALERT unauthorized write
10.20.0.10 -> 10.10.4.2 MALFORMED length field 6 but 2 bytes follow
10.20.0.10 -> 10.10.4.2 MALFORMED protocol id 1 is not 0 (not Modbus)
10.20.0.10 -> 10.10.4.2 tid=8 unit=1 fc=43 other function REVIEW (not on read/write list)
Reading the output line by line: the SCADA reads are normal; the engineering workstation wrote the value 300 to register 7 — expected if there is a change ticket; a corporate desk (10.30.3.7) wrote coil 160 to ON (0xFF00 = 65280) and then wrote two registers starting at 7. Those two lines are the finding of the morning: a host in the corporate zone can reach a station controller and change it. Two frames are malformed — one truncated, one with a non-zero protocol identifier — and function 43 (device identification) is neither a read of process data nor a write, so it is marked for review: an identification query from a known SCADA server may be a vendor feature; the same query from an unknown host is classic reconnaissance (MITRE lists remote system information discovery among ICS techniques: ATT&CK for ICS).
2.5 Practical activity
Decode the three frames below by hand, then add them to Lab 2 and confirm: 000900000006010100000010, 000a0000000601050003 0000 (remove the space), 000b00000006020300640001. Write down the function, address and quantity or value of each, and whether the unit identifier is what you expect for KW-04.
The second frame carries function 05, a single-coil write, so record whether the SCADA server is ever expected to send one to KW-04. If a frame has a space in the hex, remove it before pasting; spaces are not part of the capture.
2.6 Common errors and limits
- Treating port 502 traffic as Modbus without checking the protocol identifier and length.
- Reading register numbers as meanings without the device's register map.
- Alerting on every function-43 query, or on none; context (source, frequency) decides.
- Forgetting that responses matter too: an exception response (function code + 0x80) tells you a request was refused (Chapter 12).
-
A decoder that sees only what the capture point sees: traffic between two devices on the same station switch never reaches a control-centre sensor.
-
Alerting on every write without checking whether the source is the engineering workstation named in policy.
- Treating a malformed frame as an attack when it may be a truncated export or a non-Modbus probe; report it as malformed first.
Exercises — chapter 2
EX005 — Hand decode. Objective: read MBAP and PDU fields without code. Inputs: 0010000000060110000a0002 is truncated; 001100000006010f00000008. Steps: decode each field you can and state whether each frame is valid. Expected evidence: field tables with reasoning. Rubric: 3 = recognizes the first as function 16 missing its byte count and values (length 6 is consistent with what is present but a valid fc 16 needs more), the second as function 15 at address 0 for 8 coils, also incomplete without byte count and data; 2 = fields right, validity missed; 1 = partial; 0 = none.
EX006 — Extend the decoder. Objective: improve a detection tool. Inputs: Lab 2. Steps: (1) for function 16, also decode the byte count and the register values; (2) print the values for the 10.30.3.7 frame. Expected evidence: code change and new output line. Rubric: 3 = byte count 4 and values 100 and 10 printed, with a length check that the byte count equals 2 × quantity; 2 = values without check; 1 = attempted; 0 = none.
EX007 — Write policy as data. Objective: express an allowlist precisely. Inputs: KVROC policy. Steps: write an allowlist of (source, destination range, function codes) entries for normal operation and for an approved maintenance window, and say who switches between them. Expected evidence: two short tables and an owner. Rubric: 3 = SCADA read-only codes to all stations; engineering writes only to the station named in the ticket and only during the window; switch owned by KVROC on-call with Kestrel Water approval; 2 = no window; 1 = vague; 0 = none.
EX008 — Reconnaissance or feature? Objective: reason about ambiguous traffic. Inputs: function 43 queries from scada-01 every 24 h to all stations, and the same query from 10.20.0.31 to four stations within three seconds. Steps: decide which needs action and what you would check first. Expected evidence: a short note. Rubric: 3 = the daily SCADA query treated as a probable inventory feature to confirm with the vendor; the burst from an unknown host treated as a priority finding — identify 10.20.0.31 (Chapter 17 revisits it) without probing it; 2 = right priority, weak checks; 1 = both ignored or both alarmed; 0 = none.
Chapter synthesis
Classic industrial protocols trust whoever can reach them, so network position is authority. Modbus/TCP frames have a fixed, checkable structure; decoding them correctly — including the length and protocol checks — is the basis for every later filter and detection. Writes from the wrong place are the first rule; identification and diagnostic functions need context; and a decoder only sees what its capture point sees.
Continue studying — chapter 02 of 18
← Chapter 01 · Course chapter index · Chapter 03 →
Continue to the next authored lesson.