Browse CTFs New CTF Sign in

Interpreting Modbus/TCP Function Codes and Extracting PLC Register Values from PCAP

osint_collection Difficulty 1–5 30 min certifiable

Theory

Why This Matters

The 2016 Ukraine power grid attack (Industroyer/Crashoverride) used Modbus as one of its ICS protocol payloads to send commands to substation RTUs. Modbus/TCP is the most widely deployed industrial control system protocol, found in power utilities, water treatment plants, manufacturing lines, and building automation systems. It carries no authentication and no encryption — every read and write operation appears in cleartext on the wire. Analysts investigating ICS incidents or CTF challenges with OT/ICS scenarios must be able to parse Modbus MBAP headers, identify function codes, and extract register addresses and values to understand what was read from or written to field devices.

Core Concept

Modbus/TCP wraps the Modbus application layer in a 6-byte MBAP (Modbus Application Protocol) header and transmits over TCP port 502:

Field Bytes Description
Transaction ID 2 Echo'd in response; pairs request/response
Protocol ID 2 Always 0x0000 for Modbus
Length 2 Byte count of following fields
Unit ID 1 Identifies the target device (RTU address)

Following the MBAP header is the PDU (Protocol Data Unit): a 1-byte function code and the function-specific data.

Key function codes:

Code Hex Operation
01 0x01 Read Coils (discrete outputs)
02 0x02 Read Discrete Inputs
03 0x03 Read Holding Registers
04 0x04 Read Input Registers
06 0x06 Write Single Register
16 0x10 Write Multiple Registers
15 0x0F Write Multiple Coils

Holding registers (FC 0x03) store 16-bit values. A read response contains: byte count, then pairs of high/low bytes for each register value. A write (FC 0x06) carries: register address (2 bytes) + register value (2 bytes).

Wireshark's Modbus dissector automatically parses all these fields when port 502 is present.

Technical Deep-Dive

# Display all Modbus traffic from a PCAP
tshark -r capture.pcap -Y "modbus" 
  -T fields 
  -e frame.number -e frame.time_relative 
  -e ip.src -e ip.dst 
  -e mbtcp.trans_id 
  -e mbtcp.unit_id 
  -e modbus.func_code 
  -e modbus.reference_num 
  -e modbus.word_cnt 
  -e modbus.data 
  -E header=y -E separator=","

# Filter read operations (FC 03 and FC 04) — request frames
tshark -r capture.pcap 
  -Y "modbus.func_code == 3 or modbus.func_code == 4" 
  -T fields 
  -e frame.number -e frame.time_relative 
  -e ip.src -e ip.dst 
  -e mbtcp.unit_id -e modbus.func_code 
  -e modbus.reference_num -e modbus.word_cnt

# Filter write operations (FC 06 single, FC 16 multiple)
tshark -r capture.pcap 
  -Y "modbus.func_code == 6 or modbus.func_code == 16" 
  -T fields 
  -e frame.number -e frame.time_relative 
  -e ip.src -e ip.dst 
  -e mbtcp.unit_id -e modbus.func_code 
  -e modbus.reference_num -e modbus.data
#!/usr/bin/env python3
"""
Parse Modbus/TCP PCAP and extract register read/write operations.
Requires: pip install scapy
"""
from scapy.all import rdpcap, TCP, Raw, IP
import struct

def parse_modbus(raw_bytes):
    """Parse Modbus/TCP frame. Returns dict of parsed fields or None."""
    if len(raw_bytes) < 8:
        return None
    trans_id, proto_id, length, unit_id = struct.unpack(">HHHB", raw_bytes[:7])
    if proto_id != 0:
        return None   # not Modbus
    func_code = raw_bytes[7]
    data = raw_bytes[8:]
    return {
        "trans_id": trans_id,
        "unit_id": unit_id,
        "func_code": func_code,
        "data": data,
    }

FC_NAMES = {1: "Read Coils", 2: "Read Discrete Inputs",
            3: "Read Holding Regs", 4: "Read Input Regs",
            6: "Write Single Reg", 15: "Write Multiple Coils",
            16: "Write Multiple Regs"}

packets = rdpcap("capture.pcap")

for pkt in packets:
    if not (pkt.haslayer(TCP) and pkt.haslayer(Raw)):
        continue
    if pkt[TCP].dport != 502 and pkt[TCP].sport != 502:
        continue
    direction = "REQUEST" if pkt[TCP].dport == 502 else "RESPONSE"
    parsed = parse_modbus(pkt[Raw].load)
    if not parsed:
        continue

    fc = parsed["func_code"]
    data = parsed["data"]
    fc_name = FC_NAMES.get(fc, f"FC-{fc:#04x}")

    if direction == "REQUEST" and fc in (3, 4) and len(data) >= 4:
        reg_addr, reg_count = struct.unpack(">HH", data[:4])
        print(f"[{direction}] {fc_name}: unit={parsed['unit_id']} "
              f"start_reg={reg_addr} count={reg_count}")

    elif direction == "REQUEST" and fc == 6 and len(data) >= 4:
        reg_addr, reg_val = struct.unpack(">HH", data[:4])
        print(f"[{direction}] {fc_name}: unit={parsed['unit_id']} "
              f"reg={reg_addr} value={reg_val} (0x{reg_val:04x})")

    elif direction == "RESPONSE" and fc in (3, 4) and len(data) >= 1:
        byte_count = data[0]
        values = []
        for i in range(1, byte_count, 2):
            if i+1 < len(data):
                val = struct.unpack(">H", data[i:i+2])[0]
                values.append(val)
        print(f"[{direction}] {fc_name}: unit={parsed['unit_id']} "
              f"values={values}")
# Wireshark display filter for write operations only (anomaly hunting):
# modbus.func_code == 6 || modbus.func_code == 16 || modbus.func_code == 15

# Check for Modbus exception responses (error codes — FC | 0x80):
tshark -r capture.pcap 
  -Y "modbus.func_code >= 128" 
  -T fields -e frame.number -e ip.src -e modbus.func_code 
  -e modbus.exception_code

Analytical Methodology

  1. Apply Wireshark filter modbus to isolate all Modbus/TCP traffic. Confirm that traffic uses port 502. Identify the SCADA master IP (the client issuing requests) and RTU/PLC IP(s) (the servers responding).
  2. Extract the unit IDs present in the traffic. Each unit ID identifies a distinct field device (RTU, PLC, sensor). Multiple unit IDs from the same server IP indicate a Modbus gateway relaying to multiple field devices.
  3. Separate read operations (FC 01–04) from write operations (FC 06, 15, 16). In normal ICS operation, reads dominate. An unusually high proportion of write operations — especially from an unexpected source IP — is anomalous and may indicate an attacker sending commands.
  4. For read operations (FC 03/04 holding/input register reads), record: unit ID, starting register address, register count, and the response values. Register addresses map to physical I/O (sensor readings, setpoints, status flags). Document each read-response pair using the matching Transaction ID.
  5. For write operations (FC 06/16), record: unit ID, register address, and the written value. In an attack scenario, the written values represent commands sent to field devices — circuit breaker trips, pump speed changes, valve positions.
  6. Look for Modbus exception responses (FC codes with bit 7 set, e.g., 0x83 = exception to FC 0x03). Exception code 0x01 (Illegal Function) or 0x02 (Illegal Data Address) may indicate an attacker probing registers that do not exist.
  7. Correlate Modbus write timestamps with any physical events noted in the incident report (power outage, pump failure, safety trip). The write operation immediately preceding the physical event is the likely cause.
  8. Document: per-session summary (master IP, device IP, unit IDs accessed, FC distribution), all write operations with register addresses and values, any exception responses, and the timeline of operations.

Common Analytical Errors

  • Ignoring Transaction ID for request-response pairing: Modbus TCP is not strictly synchronous — a master may issue multiple requests before receiving responses. Always pair requests and responses using the Transaction ID, not just sequential order.
  • Treating all reads as benign: Reconnaissance reads (scanning all holding registers from 0 to 65535) are a pre-attack technique. A rapid sequential scan of all register addresses from one source is malicious read activity, not normal polling.
  • Missing non-standard ports: While 502 is the IANA-assigned Modbus/TCP port, some implementations use non-standard ports (503, 510, 5502). If the Wireshark Modbus dissector is not automatically applied, use Analyze → Decode As → Modbus/TCP on the relevant port.
  • Assuming register values are always unsigned 16-bit integers: Holding registers are 16-bit storage, but values may represent signed integers, fixed-point decimals (e.g., temperature × 10), bit fields, or ASCII characters. Without the device's register map, raw values require context for interpretation.

NICE Framework Alignment

Code Knowledge/Skill/Task Statement How This Card Develops It
K0046 Knowledge of intrusion detection systems and methodologies ICS network anomaly detection requires Modbus function code and register access pattern analysis
K0093 Knowledge of network protocols Modbus MBAP header structure, function codes, and register address space for ICS/OT environments
K0221 Knowledge of OSI model and network layers Modbus/TCP operates at layer 7 over TCP layer 4; MBAP sits between TCP and the Modbus PDU
S0046 Skill in performing packet-level analysis Parsing binary Modbus frames from PCAP using tshark field extraction and Python struct parsing
T0023 Collect intrusion artifacts for use in forensic analysis Modbus write operation records are forensic artifacts establishing what commands were sent to field devices

Further Reading

  • Modbus.org: Modbus Application Protocol Specification V1.1b3 — authoritative protocol reference
  • ICS-CERT: "Analysis of the Cyber Attack on the Ukrainian Power Grid" — Industroyer Modbus payload details
  • Wireshark Wiki: Modbus dissector documentation
  • SANS ICS: "Modbus Scanning and Attack Detection" (whitepaper)
  • Claroty/Dragos: ICS protocol anomaly detection white papers — Modbus anomaly classes

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.