ot ics security

Detecting Modbus Protocol Anomalies

This skill covers detecting anomalies in Modbus/TCP and Modbus RTU communications in industrial control systems. It addresses function code monitoring, register range validation, timing analysis, unauthorized client detection, and deep packet inspection for malformed Modbus frames. The skill leverages Zeek with Modbus protocol analyzers, Suricata IDS with OT rules, and custom Python-based detection using Markov chain models for normal Modbus transaction sequences.

icsiec62443industrial-controlmodbusot-securityprotocol-anomalyscada
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

  • When deploying Modbus-specific intrusion detection in an OT environment
  • When building baseline models for deterministic Modbus polling patterns
  • When investigating suspicious Modbus traffic flagged by OT monitoring tools
  • When implementing function code allowlisting on industrial firewalls
  • When detecting unauthorized Modbus write commands that could manipulate process setpoints

Do not use for securing Modbus communications end-to-end (Modbus has no native security; see implementing-network-segmentation-for-ot for firewall-based controls), for non-Modbus protocol monitoring (see detecting-anomalies-in-industrial-control-systems for multi-protocol), or for active fuzzing of Modbus implementations (see performing-plc-firmware-security-analysis).

Prerequisites

  • Network SPAN/TAP access to monitor Modbus/TCP traffic (port 502)
  • Zeek (formerly Bro) with Modbus protocol analyzer or Suricata with OT rulesets
  • Python 3.9+ with scapy and pymodbus for custom analysis
  • Baseline capture of normal Modbus traffic (minimum 1-2 weeks)
  • Documentation of authorized Modbus clients, function codes, and register maps

Workflow

Step 1: Capture and Parse Modbus Traffic

Deploy passive monitoring to capture all Modbus/TCP traffic and parse it into structured records for analysis.

#!/usr/bin/env python3
"""Modbus Protocol Anomaly Detection System.
 
Monitors Modbus/TCP traffic for anomalies including unauthorized
function codes, unusual register access, timing deviations,
and rogue client devices.
"""
 
import json
import struct
import sys
import time
from collections import defaultdict, deque
from dataclasses import dataclass, field
from datetime import datetime
from statistics import mean, stdev
 
try:
    from scapy.all import sniff, rdpcap, IP, TCP
except ImportError:
    print("Install scapy: pip install scapy")
    sys.exit(1)
 
 
MODBUS_FUNCTION_CODES = {
    1: ("Read Coils", "read"),
    2: ("Read Discrete Inputs", "read"),
    3: ("Read Holding Registers", "read"),
    4: ("Read Input Registers", "read"),
    5: ("Write Single Coil", "write"),
    6: ("Write Single Register", "write"),
    7: ("Read Exception Status", "diagnostic"),
    8: ("Diagnostics", "diagnostic"),
    11: ("Get Comm Event Counter", "diagnostic"),
    12: ("Get Comm Event Log", "diagnostic"),
    15: ("Write Multiple Coils", "write"),
    16: ("Write Multiple Registers", "write"),
    17: ("Report Slave ID", "diagnostic"),
    22: ("Mask Write Register", "write"),
    23: ("Read/Write Multiple Registers", "read_write"),
    43: ("Encapsulated Interface Transport", "diagnostic"),
}
 
 
@dataclass
class ModbusAnomaly:
    timestamp: str
    anomaly_type: str
    severity: str
    src_ip: str
    dst_ip: str
    unit_id: int
    func_code: int
    detail: str
    mitre_technique: str = ""
 
 
@dataclass
class ModbusSession:
    """Tracks state for a Modbus master-slave session."""
    src_ip: str
    dst_ip: str
    func_codes_seen: dict = field(default_factory=lambda: defaultdict(int))
    register_ranges: set = field(default_factory=set)
    intervals: list = field(default_factory=lambda: deque(maxlen=500))
    last_timestamp: float = 0
    request_count: int = 0
    write_count: int = 0
 
 
class ModbusAnomalyDetector:
    """Detects anomalies in Modbus/TCP traffic."""
 
    def __init__(self):
        self.sessions = {}
        self.baseline_sessions = {}
        self.anomalies = []
        self.authorized_clients = set()
        self.authorized_func_codes = {}  # per-session allowed FCs
        self.packet_count = 0
 
    def set_authorized_clients(self, clients):
        """Set list of authorized Modbus client IPs."""
        self.authorized_clients = set(clients)
 
    def set_authorized_func_codes(self, session_key, func_codes):
        """Set allowed function codes for a specific session."""
        self.authorized_func_codes[session_key] = set(func_codes)
 
    def load_baseline(self, baseline_file):
        """Load baseline profiles from previous capture analysis."""
        with open(baseline_file) as f:
            baseline = json.load(f)
        for key, data in baseline.get("modbus_baselines", {}).items():
            self.baseline_sessions[key] = data
            self.authorized_func_codes[key] = set(data.get("allowed_function_codes", []))
        print(f"[*] Loaded {len(self.baseline_sessions)} Modbus baselines")
 
    def process_packet(self, pkt):
        """Process a single packet for Modbus anomaly detection."""
        if not pkt.haslayer(TCP) or not pkt.haslayer(IP):
            return
 
        # Check for Modbus/TCP (port 502)
        if pkt[TCP].dport != 502 and pkt[TCP].sport != 502:
            return
 
        payload = bytes(pkt[TCP].payload)
        if len(payload) < 8:
            return
 
        self.packet_count += 1
        timestamp = float(pkt.time)
        ts_str = datetime.fromtimestamp(timestamp).isoformat()
 
        # Parse MBAP header
        try:
            trans_id = struct.unpack(">H", payload[0:2])[0]
            proto_id = struct.unpack(">H", payload[2:4])[0]
            length = struct.unpack(">H", payload[4:6])[0]
            unit_id = payload[6]
            func_code = payload[7]
        except (IndexError, struct.error):
            return
 
        # Determine direction
        if pkt[TCP].dport == 502:
            src_ip = pkt[IP].src
            dst_ip = pkt[IP].dst
            is_request = True
        else:
            src_ip = pkt[IP].dst
            dst_ip = pkt[IP].src
            is_request = False
 
        if not is_request:
            return  # Only analyze requests
 
        session_key = f"{src_ip}->{dst_ip}"
 
        # Get or create session
        if session_key not in self.sessions:
            self.sessions[session_key] = ModbusSession(src_ip=src_ip, dst_ip=dst_ip)
 
        session = self.sessions[session_key]
        session.request_count += 1
        session.func_codes_seen[func_code] += 1
 
        # ── Anomaly Detection Rules ──
 
        # Rule 1: Unauthorized Modbus client
        if self.authorized_clients and src_ip not in self.authorized_clients:
            self.anomalies.append(ModbusAnomaly(
                timestamp=ts_str,
                anomaly_type="UNAUTHORIZED_CLIENT",
                severity="critical",
                src_ip=src_ip, dst_ip=dst_ip,
                unit_id=unit_id, func_code=func_code,
                detail=f"Modbus request from unauthorized client {src_ip}",
                mitre_technique="T0886 - Remote Services",
            ))
 
        # Rule 2: Unauthorized function code
        allowed_fcs = self.authorized_func_codes.get(session_key)
        if allowed_fcs and func_code not in allowed_fcs:
            fc_info = MODBUS_FUNCTION_CODES.get(func_code, (f"Unknown FC{func_code}", "unknown"))
            severity = "critical" if fc_info[1] == "write" else "high"
            self.anomalies.append(ModbusAnomaly(
                timestamp=ts_str,
                anomaly_type="UNAUTHORIZED_FUNCTION_CODE",
                severity=severity,
                src_ip=src_ip, dst_ip=dst_ip,
                unit_id=unit_id, func_code=func_code,
                detail=f"FC {func_code} ({fc_info[0]}) not in allowlist {sorted(allowed_fcs)}",
                mitre_technique="T0855 - Unauthorized Command Message",
            ))
 
        # Rule 3: Write operation detection
        if func_code in (5, 6, 15, 16, 22, 23):
            session.write_count += 1
            fc_name = MODBUS_FUNCTION_CODES.get(func_code, ("Unknown", ""))[0]
 
            # Extract register address
            if len(payload) >= 10:
                register_addr = struct.unpack(">H", payload[8:10])[0]
                session.register_ranges.add((func_code, register_addr))
 
                self.anomalies.append(ModbusAnomaly(
                    timestamp=ts_str,
                    anomaly_type="WRITE_OPERATION",
                    severity="high",
                    src_ip=src_ip, dst_ip=dst_ip,
                    unit_id=unit_id, func_code=func_code,
                    detail=f"Write: {fc_name} to register {register_addr} from {src_ip}",
                    mitre_technique="T0836 - Modify Parameter",
                ))
 
        # Rule 4: Timing anomaly
        if session.last_timestamp > 0:
            interval = (timestamp - session.last_timestamp) * 1000  # ms
            session.intervals.append(interval)
 
            baseline = self.baseline_sessions.get(session_key)
            if baseline and len(session.intervals) > 10:
                expected_interval = baseline.get("polling_interval_avg_sec", 0) * 1000
                expected_std = baseline.get("polling_interval_stddev", 0) * 1000
 
                if expected_std > 0:
                    z_score = abs(interval - expected_interval) / expected_std
                    if z_score > 5.0:
                        self.anomalies.append(ModbusAnomaly(
                            timestamp=ts_str,
                            anomaly_type="TIMING_ANOMALY",
                            severity="medium",
                            src_ip=src_ip, dst_ip=dst_ip,
                            unit_id=unit_id, func_code=func_code,
                            detail=(
                                f"Interval {interval:.0f}ms vs baseline "
                                f"{expected_interval:.0f}ms (z={z_score:.1f})"
                            ),
                            mitre_technique="T0831 - Manipulation of Control",
                        ))
 
        # Rule 5: Protocol violation - invalid protocol ID
        if proto_id != 0:
            self.anomalies.append(ModbusAnomaly(
                timestamp=ts_str,
                anomaly_type="PROTOCOL_VIOLATION",
                severity="high",
                src_ip=src_ip, dst_ip=dst_ip,
                unit_id=unit_id, func_code=func_code,
                detail=f"Non-standard protocol ID {proto_id} (expected 0)",
                mitre_technique="T0830 - Man in the Middle",
            ))
 
        # Rule 6: Broadcast write (unit ID 0)
        if unit_id == 0 and func_code in (5, 6, 15, 16):
            self.anomalies.append(ModbusAnomaly(
                timestamp=ts_str,
                anomaly_type="BROADCAST_WRITE",
                severity="critical",
                src_ip=src_ip, dst_ip=dst_ip,
                unit_id=unit_id, func_code=func_code,
                detail="Broadcast write command (unit ID 0) affects ALL slaves",
                mitre_technique="T0855 - Unauthorized Command Message",
            ))
 
        session.last_timestamp = timestamp
 
    def analyze_pcap(self, pcap_file):
        """Analyze pcap file for Modbus anomalies."""
        print(f"[*] Analyzing {pcap_file}...")
        packets = rdpcap(pcap_file)
        for pkt in packets:
            self.process_packet(pkt)
        print(f"[*] Processed {self.packet_count} Modbus packets")
 
    def generate_report(self):
        """Generate anomaly detection report."""
        print(f"\n{'='*70}")
        print("MODBUS PROTOCOL ANOMALY DETECTION REPORT")
        print(f"{'='*70}")
        print(f"Packets analyzed: {self.packet_count}")
        print(f"Sessions tracked: {len(self.sessions)}")
        print(f"Anomalies detected: {len(self.anomalies)}")
 
        severity_counts = defaultdict(int)
        type_counts = defaultdict(int)
        for a in self.anomalies:
            severity_counts[a.severity] += 1
            type_counts[a.anomaly_type] += 1
 
        print(f"\nBy Severity:")
        for sev in ["critical", "high", "medium", "low"]:
            if severity_counts[sev]:
                print(f"  {sev.upper()}: {severity_counts[sev]}")
 
        print(f"\nBy Type:")
        for atype, count in sorted(type_counts.items(), key=lambda x: -x[1]):
            print(f"  {atype}: {count}")
 
        print(f"\nTop Anomalies:")
        for a in self.anomalies[:15]:
            print(f"  [{a.severity.upper()}] {a.anomaly_type}: {a.detail}")
 
 
if __name__ == "__main__":
    detector = ModbusAnomalyDetector()
 
    if len(sys.argv) > 1:
        # Load baseline if provided
        if len(sys.argv) > 2:
            detector.load_baseline(sys.argv[2])
        detector.analyze_pcap(sys.argv[1])
        detector.generate_report()
    else:
        print("Usage: python modbus_detector.py <pcap_file> [baseline.json]")

Key Concepts

Term Definition
Modbus/TCP Industrial protocol running on TCP port 502, consisting of an MBAP header and PDU with function code and data
Function Code Modbus command identifier (FC1-4: reads, FC5-6/15-16: writes, FC8: diagnostics) determining the operation type
MBAP Header Modbus Application Protocol header containing transaction ID, protocol ID (0x0000), length, and unit ID
Unit ID Modbus address (0-247) identifying the target slave device; unit ID 0 is broadcast to all slaves
Register Map Vendor-specific mapping of Modbus register addresses to process variables (e.g., register 40001 = reactor temperature)
Function Code Allowlist Security policy defining which Modbus function codes are permitted from each source IP to each target device

Tools & Systems

  • Zeek Modbus Analyzer: Network security monitor with built-in Modbus/TCP protocol analysis and logging
  • Suricata with ET Open ICS rules: IDS/IPS with Modbus-specific detection rules for command injection and anomalies
  • Wireshark Modbus Dissector: Protocol analyzer with full Modbus/TCP and Modbus RTU decoding
  • PyModbus: Python Modbus library for building custom monitoring and testing tools

Output Format

Modbus Protocol Anomaly Detection Report
==========================================
Capture Period: YYYY-MM-DD to YYYY-MM-DD
Packets Analyzed: [N]
Sessions: [N]
 
ANOMALIES: [N]
  UNAUTHORIZED_CLIENT: [N]
  UNAUTHORIZED_FUNCTION_CODE: [N]
  WRITE_OPERATION: [N]
  TIMING_ANOMALY: [N]
  BROADCAST_WRITE: [N]
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 1

api-reference.md1.6 KB

API Reference: Detecting Modbus Protocol Anomalies

Modbus Protocol Limits

Parameter Maximum Value
Coil read quantity 2000
Register read quantity 125
Register write quantity 123
Unit ID range 1-247
PDU size 253 bytes

Anomaly Detection Methods

Anomaly Detection Severity
Timing deviation Polling interval outside tolerance MEDIUM-HIGH
Excessive read Quantity > protocol limits HIGH
Invalid function code Not in standard set HIGH
Modbus scan >5 unique function codes from source HIGH
Register range violation Address outside configured range MEDIUM

Zeek Modbus Log Fields

#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p func exception quantity

Suricata Modbus Rules

alert modbus any any -> any 502 (msg:"Modbus Invalid Function Code"; \
  modbus: function !1,!2,!3,!4,!5,!6,!15,!16; sid:4000001;)
alert modbus any any -> any 502 (msg:"Modbus Excessive Register Read"; \
  modbus: function 3; modbus: quantity > 125; sid:4000002;)

Scapy Modbus Analysis

from scapy.contrib.modbus import ModbusADURequest
from scapy.all import rdpcap
 
pkts = rdpcap("modbus.pcap")
for pkt in pkts:
    if pkt.haslayer(ModbusADURequest):
        print(f"FC={pkt.funcCode} Len={pkt.len}")

Baseline Monitoring

# Expected polling behavior
expected_interval = 1.0  # seconds
tolerance = 0.5
# Alert if interval < 0.5s or > 3.0s

CLI Usage

python agent.py --modbus-log modbus.log
python agent.py --modbus-log modbus.log --expected-interval 2.0

Scripts 1

agent.py5.4 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""Modbus protocol anomaly detection agent for OT/ICS networks.

Detects protocol-level anomalies in Modbus TCP traffic including malformed
packets, timing deviations, register range violations, and replay attacks.
"""

import argparse
import json
from collections import Counter, defaultdict
from datetime import datetime

MODBUS_FUNCTIONS = {
    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",
}

VALID_REGISTER_RANGES = {
    "coils": (0, 65535), "discrete_inputs": (0, 65535),
    "holding_registers": (0, 65535), "input_registers": (0, 65535),
}

MAX_REGISTER_READ = 125
MAX_COIL_READ = 2000


def parse_modbus_log(filepath):
    events = []
    with open(filepath, "r") as f:
        headers = None
        for line in f:
            if line.startswith("#fields"):
                headers = line.strip().split("\t")[1:]
                continue
            if line.startswith("#"):
                continue
            if not headers:
                continue
            fields = line.strip().split("\t")
            if len(fields) >= len(headers):
                events.append(dict(zip(headers, fields)))
    return events


def detect_timing_anomalies(events, expected_interval=1.0, tolerance=0.5):
    findings = []
    pair_timestamps = defaultdict(list)

    for evt in events:
        src = evt.get("id.orig_h", "")
        dst = evt.get("id.resp_h", "")
        try:
            ts = float(evt.get("ts", 0))
        except ValueError:
            continue
        pair_timestamps[f"{src}->{dst}"].append(ts)

    for pair, timestamps in pair_timestamps.items():
        timestamps.sort()
        for i in range(1, len(timestamps)):
            interval = timestamps[i] - timestamps[i-1]
            if interval < expected_interval - tolerance or interval > expected_interval * 3:
                findings.append({
                    "type": "timing_anomaly",
                    "pair": pair,
                    "expected_interval": expected_interval,
                    "actual_interval": round(interval, 3),
                    "severity": "MEDIUM" if interval > expected_interval * 3 else "HIGH",
                })
                break
    return findings


def detect_register_anomalies(events):
    findings = []
    for evt in events:
        fc_str = evt.get("func", "0")
        try:
            fc = int(fc_str)
        except ValueError:
            continue

        quantity = evt.get("quantity", "0")
        try:
            qty = int(quantity)
        except ValueError:
            qty = 0

        if fc in (1, 2) and qty > MAX_COIL_READ:
            findings.append({
                "type": "excessive_coil_read",
                "function_code": fc,
                "quantity": qty,
                "max_allowed": MAX_COIL_READ,
                "severity": "HIGH",
                "source": evt.get("id.orig_h", ""),
            })
        elif fc in (3, 4) and qty > MAX_REGISTER_READ:
            findings.append({
                "type": "excessive_register_read",
                "function_code": fc,
                "quantity": qty,
                "max_allowed": MAX_REGISTER_READ,
                "severity": "HIGH",
                "source": evt.get("id.orig_h", ""),
            })

        if fc not in MODBUS_FUNCTIONS:
            findings.append({
                "type": "invalid_function_code",
                "function_code": fc,
                "severity": "HIGH",
                "source": evt.get("id.orig_h", ""),
            })
    return findings


def detect_scan_patterns(events, threshold=50):
    findings = []
    src_fc_counter = defaultdict(Counter)
    for evt in events:
        src = evt.get("id.orig_h", "")
        fc_str = evt.get("func", "0")
        try:
            fc = int(fc_str)
        except ValueError:
            continue
        src_fc_counter[src][fc] += 1

    for src, fc_counts in src_fc_counter.items():
        unique_fcs = len(fc_counts)
        total = sum(fc_counts.values())
        if unique_fcs > 5 or (fc_counts.get(17, 0) > 0 and fc_counts.get(43, 0) > 0):
            findings.append({
                "type": "modbus_scan",
                "source": src,
                "unique_function_codes": unique_fcs,
                "total_requests": total,
                "severity": "HIGH",
                "description": f"Modbus enumeration from {src}: {unique_fcs} function codes",
            })
    return findings


def main():
    parser = argparse.ArgumentParser(description="Modbus Protocol Anomaly Detector")
    parser.add_argument("--modbus-log", required=True, help="Zeek modbus.log file")
    parser.add_argument("--expected-interval", type=float, default=1.0,
                        help="Expected polling interval in seconds")
    args = parser.parse_args()

    events = parse_modbus_log(args.modbus_log)
    all_findings = []
    all_findings.extend(detect_timing_anomalies(events, args.expected_interval))
    all_findings.extend(detect_register_anomalies(events))
    all_findings.extend(detect_scan_patterns(events))

    results = {
        "timestamp": datetime.utcnow().isoformat() + "Z",
        "total_events": len(events),
        "findings": all_findings,
        "total_findings": len(all_findings),
    }
    print(json.dumps(results, indent=2))


if __name__ == "__main__":
    main()
Keep exploring