mcpField
D
detecting-modbus-protocol-anomalies

detecting-modbus-protocol-anomalies

github.com/mukul975安全35.2k40 次安装8 周前更新
代码仓库

关于 detecting-modbus-protocol-anomalies

用于在 OT 环境中检测 Modbus/TCP 流量异常的安全技能。它指导防御人员进行被动流量分析和基线构建,并检查未授权客户端、功能码及寄存器访问。

主要功能

  1. 01分析 Modbus/TCP 流量
  2. 02根据正常轮询模式建立基线
  3. 03检测未授权客户端和功能码
  4. 04检查异常寄存器访问与时序
  5. 05支持功能码允许列表

适用场景

  1. 01调查可疑 Modbus 流量
  2. 02检测未授权写入命令
  3. 03构建 Modbus 专用 OT 监控

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mukul975/anthropic-cybersecurity-skills --skill detecting-modbus-protocol-anomalies

安装后在 Claude Code 中输入/detecting-modbus-protocol-anomalies确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

LICENSE
references/
api-reference.md
scripts/
agent.py

详细介绍

检测 Modbus 协议异常

适用场景

  • 在 OT 环境中部署 Modbus 专用入侵检测时
  • 为确定性 Modbus 轮询模式构建基线模型时
  • 调查 OT 监控工具标记的可疑 Modbus 流量时
  • 在工业防火墙上实施功能码允许列表时
  • 检测可能操纵过程设定值的未授权 Modbus 写命令时

请勿使用于 Modbus 通信的端到端安全防护(Modbus 没有原生安全机制;基于防火墙的控制请参阅 implementing-network-segmentation-for-ot),非 Modbus 协议监控(多协议场景请参阅 detecting-anomalies-in-industrial-control-systems),或对 Modbus 实现进行主动模糊测试(请参阅 performing-plc-firmware-security-analysis)。

前提条件

  • 具备网络 SPAN/TAP 访问权限,以监控 Modbus/TCP 流量(端口 502)
  • 带 Modbus 协议分析器的 Zeek(原 Bro),或带 OT 规则集的 Suricata
  • Python 3.9+ 及 scapy 和 pymodbus,用于自定义分析
  • 正常 Modbus 流量的基线抓包(至少 1-2 周)
  • 已授权的 Modbus 客户端、功能码和寄存器映射的文档

工作流

步骤 1:捕获并解析 Modbus 流量

部署被动监控以捕获所有 Modbus/TCP 流量,并将其解析为结构化记录以供分析。

#!/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]")

关键概念

术语定义
Modbus/TCP运行在 TCP 端口 502 上的工业协议,由 MBAP 头和包含功能码与数据的 PDU 组成
功能码Modbus 命令标识符(FC1-4:读取,FC5-6/15-16:写入,FC8:诊断),决定操作类型
MBAP 头Modbus 应用协议头,包含事务 ID、协议 ID(0x0000)、长度和单元 ID
单元 ID标识目标从站设备的 Modbus 地址(0-247);单元 ID 0 为向所有从站广播
寄存器映射厂商特定的 Modbus 寄存器地址到过程变量的映射(例如,寄存器 40001 = 反应器温度)
功能码允许列表安全策略,定义允许从每个源 IP 发送到每个目标设备的 Modbus 功能码

工具与系统

  • Zeek Modbus Analyzer:内置 Modbus/TCP 协议分析和日志记录功能的网络安全监控器
  • Suricata with ET Open ICS rules:具备 Modbus 专用检测规则的 IDS/IPS,用于检测命令注入和异常
  • Wireshark Modbus Dissector:支持完整 Modbus/TCP 和 Modbus RTU 解码的协议分析器
  • PyModbus:用于构建自定义监控和测试工具的 Python Modbus 库

输出格式

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]

评论

支持 Markdown