◇ OpenCyber Academy
Technical · Chapter 2 · English

Chapter 2 — Networks and protocols

Open, free learning for anyone who wants to protect people and technology. No account is required to read this text or its worked answers.

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:

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

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.

Worked answers

EX005 — Hand decode. Frame 1: tid 0x0010 = 16, protocol 0, length 6, unit 1, function 16 (write multiple registers), start address 10, quantity 2 — but function 16 must also carry a byte count (4) and four bytes of values, so the PDU is incomplete; a correct parser rejects it even though the MBAP length matches the bytes present. Frame 2: tid 17, length 6, unit 1, function 15 (write multiple coils), address 0, quantity 8 — also missing its byte count (1) and the coil byte. Full credit notes that MBAP checks alone do not validate a PDU; each function has its own structure.

EX006 — Extend the decoder. In the write branch: if fc in (15, 16): bc = b[12]; data = b[13:13 + bc] and for 16, vals = struct.unpack(f">{qty}H", data) after checking bc == 2 * qty and len(data) == bc. For the 10.30.3.7 frame ...011000070002040064000a, the byte count is 4 and the values are 100 and 10 — two registers starting at 7 (setpoint and the next register).

EX007 — Write policy as data. Normal: (10.20.0.10, 10.10.0.0/16, {1, 2, 3, 4}). Maintenance window: add (10.20.0.15, the station named in the ticket, {3, 5, 6, 15, 16}) for the window only. The KVROC on-call switches the policy on and off against a ticket approved by Kestrel Water operations; the switch is logged and the window expires automatically.

EX008 — Reconnaissance or feature? The daily query from scada-01 is consistent with an inventory feature; confirm in the SCADA vendor documentation and add it to the model as an expected flow. The three-second burst from 10.20.0.31 is a priority: an unknown host in Z2 querying identification across stations is reconnaissance-shaped. First checks are read-only: DHCP and switch records for the address, the asset inventory, the jump-host and directory logs for that host. Do not probe it. Chapter 17 shows what it did next.