Browse CTFs New CTF Sign in

Extracting Modbus Register Data via ICS/OT Protocol Forensics and Unauthorized Read Detection

network_forensics_pcap Difficulty 1–5 30 min certifiable

Theory

Why This Matters

During the 2021 Oldsmar water treatment facility attack, an operator observed the sodium hydroxide concentration being changed remotely via the plant's SCADA system. Post-incident analysis of network traffic revealed unauthenticated Modbus TCP read and write requests originating from an external IP that had accessed the facility's TeamViewer session. Because Modbus has no authentication mechanism, any host with network access to TCP port 502 can read every sensor value and write to every actuator register in a PLC. In 2022, Sandworm targeted Ukrainian substations using a tool that specifically enumerated Modbus registers to map power distribution equipment before attempting disruption. OT/ICS network forensics is now a critical analyst capability.

Core Concept

Modbus TCP is the most widely deployed industrial protocol, carried over TCP port 502. It follows a master/slave (client/server) model: an HMI or SCADA system (master) sends function code requests to PLCs or RTUs (slaves); the slave responds with data or a confirmation.

Key function codes relevant to data exposure: - 0x01 — Read Coils (discrete output bit-addressable values: on/off states) - 0x02 — Read Discrete Inputs (sensor binary values) - 0x03 — Read Holding Registers (16-bit read/write registers: temperature, pressure, setpoints) - 0x04 — Read Input Registers (16-bit read-only sensor values) - 0x06 — Write Single Register (potentially unsafe command in unauthorised hands) - 0x10 — Write Multiple Registers (bulk register write — catastrophic if weaponised)

Modbus TCP framing: each Modbus TCP ADU begins with a 7-byte MBAP header: Transaction Identifier (2 bytes), Protocol Identifier (2 bytes, always 0x0000), Length (2 bytes), Unit Identifier (1 byte — Modbus slave address). The PDU follows: 1-byte function code + data.

Security posture: Modbus has zero authentication, zero encryption, and no access control. Every field in every register is readable by any host that can reach port 502. This makes PCAP analysis straightforward — there is no decryption required.

Technical Deep-Dive

# Show all Modbus TCP transactions with function codes and register addresses
tshark -r capture.pcap -Y "modbus" 
  -T fields 
  -e frame.number 
  -e frame.time_relative 
  -e ip.src 
  -e ip.dst 
  -e modbus.func_code 
  -e modbus.reference_num 
  -e modbus.word_cnt 
  -e modbus.data 
  -E header=y -E separator=","

# Filter for Read Holding Registers requests (function code 3)
tshark -r capture.pcap 
  -Y "modbus.func_code == 3 && modbus.request_frame" 
  -T fields 
  -e ip.src 
  -e ip.dst 
  -e modbus.reference_num 
  -e modbus.word_cnt

# Filter for Write operations — these are most dangerous if unauthorised
tshark -r capture.pcap 
  -Y "modbus.func_code == 6 || modbus.func_code == 16" 
  -T fields 
  -e frame.time_relative 
  -e ip.src 
  -e ip.dst 
  -e modbus.func_code 
  -e modbus.reference_num 
  -e modbus.data

# Identify unique Modbus slave addresses (unit IDs) and function codes used
tshark -r capture.pcap -Y "modbus" 
  -T fields -e mbtcp.unit_id -e modbus.func_code 
  | sort -u
import struct

def parse_modbus_tcp(raw_bytes: bytes) -> list[dict]:
    """Parse Modbus TCP packets from raw payload bytes."""
    results = []
    offset  = 0

    while offset + 8 <= len(raw_bytes):
        # MBAP Header (7 bytes)
        trans_id, proto_id, length, unit_id = struct.unpack_from(">HHHB", raw_bytes, offset)
        offset += 7

        if proto_id != 0:     # Not Modbus TCP
            break
        if length < 1:
            break

        func_code = raw_bytes[offset]
        pdu_data  = raw_bytes[offset + 1 : offset + length]
        offset   += length

        entry = {
            "transaction_id": trans_id,
            "unit_id":        unit_id,
            "func_code":      func_code,
            "func_name":      {
                0x01: "Read Coils",
                0x02: "Read Discrete Inputs",
                0x03: "Read Holding Registers",
                0x04: "Read Input Registers",
                0x06: "Write Single Register",
                0x10: "Write Multiple Registers",
            }.get(func_code, f"Unknown(0x{func_code:02X})"),
            "data": pdu_data,
        }

        # Decode register address + count for read requests (FC 01-04)
        if func_code in (0x01, 0x02, 0x03, 0x04) and len(pdu_data) == 4:
            start_addr, qty = struct.unpack(">HH", pdu_data)
            entry["start_register"] = start_addr
            entry["register_count"] = qty

        # Decode response register values (16-bit big-endian words)
        if func_code in (0x01, 0x03, 0x04) and pdu_data and pdu_data[0] == len(pdu_data) - 1:
            byte_count = pdu_data[0]
            reg_values = struct.unpack_from(f">{byte_count // 2}H", pdu_data, 1)
            entry["register_values"] = list(reg_values)

        results.append(entry)

    return results

# Load and parse Modbus payload bytes from a followed TCP stream
with open("modbus_stream.bin", "rb") as fh:
    data = fh.read()

for pkt in parse_modbus_tcp(data):
    print(f"FC=0x{pkt['func_code']:02X} ({pkt['func_name']:30s}) "
          f"Unit={pkt['unit_id']} "
          f"Regs={pkt.get('register_values', pkt.get('start_register', ''))}")

Analytical Methodology

  1. Apply display filter modbus in Wireshark to isolate all Modbus TCP traffic. If the Modbus dissector is not active, apply tcp.port == 502 and use Decode As → Modbus.
  2. Identify all unique (source IP, destination IP, unit ID) combinations. Source IPs are SCADA/HMI clients; destination IPs are PLC/RTU slaves. Unit ID identifies the specific Modbus slave (device) at the destination. Multiple unit IDs at one IP indicates a gateway with multiple downstream devices.
  3. Filter function code 3 (Read Holding Registers) responses — these contain process values: temperatures, pressures, flow rates, setpoints. In Wireshark, expand the Modbus PDU → register data field. Note the starting register address and register values.
  4. Correlate register addresses against known PLC documentation (if available) or industry standard register maps. Registers 0–99 often contain setpoints; 100–199 status values; 200+ measured values (varies by vendor). Flag any register ranges that contain recognisable engineering values.
  5. Identify write operations (function codes 6 and 16). Any write operation from an unexpected source IP is a critical finding. Document: source IP, target slave, register address written, value written, and timestamp.
  6. Check for Modbus exception responses (function code OR'd with 0x80, e.g., 0x83 for a FC 3 exception). Exception code 0x01 (illegal function) and 0x02 (illegal data address) indicate probing of unsupported registers — a reconnaissance pattern.
  7. Build a register access map: for each register address accessed, record the reading client IP, access type (read/write), value observed, and timestamp. This constitutes the OT data exposure evidence.

Common Analytical Errors

  • Missing Modbus on non-standard ports: Some OT deployments run Modbus on ports other than 502 (e.g., 503, 4444). If the modbus display filter returns no results, check all TCP streams for the 0x0000 protocol identifier in the MBAP header.
  • Confusing request and response register values: A FC 3 Request specifies a starting address and quantity (what to read). A FC 3 Response contains the actual register values (what was returned). Extracting data from the request yields only address metadata, not process values.
  • Overlooking Modbus serial encapsulated in TCP: Modbus RTU (serial) can be encapsulated in raw TCP without MBAP headers. These lack the 6-byte MBAP prefix and require different parsing. The frame starts directly with the unit address byte.
  • Ignoring exception responses as reconnaissance evidence: Exception responses reveal which register addresses are invalid on a device — an attacker scanning register space generates a pattern of exception responses that maps the device's register topology. Exception patterns are forensic evidence of active reconnaissance.
  • Treating all Modbus traffic as equally suspicious: Legitimate SCADA systems poll PLCs every 1–30 seconds with predictable read requests. Anomalous traffic is characterised by new source IPs, unusual register ranges, write operations, or polling rates outside the established baseline.

NICE Framework Alignment

Code Knowledge/Skill/Task Statement How This Card Develops It
K0046 Knowledge of intrusion detection systems and methodologies Identifying unauthorised Modbus read/write patterns that OT security monitoring platforms detect: unexpected source IPs, write to critical registers, exception response storms
K0093 Knowledge of network protocols Understanding Modbus TCP MBAP header structure, function code semantics, register address space, and the absence of authentication in the protocol
K0221 Knowledge of OSI model and network layers Modbus TCP operates at layer 7 over TCP; understanding how the application protocol's lack of security controls makes layer-4 network access equivalent to full PLC control
S0046 Skill in performing packet-level analysis Using Wireshark Modbus dissector, tshark field extraction, and Python struct parsing to extract and interpret PLC register values from PCAP
T0023 Collect intrusion artifacts for use in forensic analysis Extracting register access maps, write operation evidence, and process value timelines as structured forensic artifacts supporting ICS incident investigation

Further Reading

  • Modbus Application Protocol Specification V1.1b3 — Modbus.org (2012) — definitive function code reference
  • ICS-CERT Advisory: "Modbus Protocol Security" — CISA technical advisory on OT protocol vulnerabilities
  • Hacking Exposed Industrial Control Systems — Larson, Bodungen et al., Chapter 5: Modbus Attack and Defence (McGraw-Hill)

Challenge Lab

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