ot ics security

Detecting Anomalies in Industrial Control Systems

This skill covers deploying anomaly detection systems for industrial control environments using machine learning models trained on OT network baselines, physics-based process models, and behavioral analysis of industrial protocol communications. It addresses building normal behavior profiles for SCADA polling patterns, detecting deviations in Modbus/DNP3/OPC UA traffic, identifying rogue devices, and correlating network anomalies with physical process data from historians.

anomaly-detectionicsiec62443industrial-controlmachine-learningot-securityscada
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

  • When deploying continuous monitoring for OT environments that lack intrusion detection
  • When building behavior-based detection to complement signature-based IDS in OT networks
  • When establishing baselines for deterministic SCADA communications to detect deviations
  • When integrating machine learning anomaly detection with OT security monitoring platforms
  • When investigating alerts from Nozomi Guardian or Dragos Platform that require deeper analysis

Do not use for signature-based detection of known exploits (see detecting-attacks-on-scada-systems), for IT network anomaly detection without OT protocols, or as a replacement for process safety systems (SIS).

Prerequisites

  • Passive network monitoring sensors on OT network SPAN/TAP ports
  • Minimum 2-4 weeks of baseline traffic capture during normal operations
  • Python 3.9+ with scikit-learn, numpy, pandas for ML model training
  • Process historian access for physical process correlation data
  • Understanding of normal operational patterns including shift changes, batch processes, and maintenance windows

Workflow

Step 1: Build Multi-Dimensional Baseline Model

Capture and model the deterministic behavior of ICS communications across multiple dimensions: timing, protocol behavior, and network topology.

#!/usr/bin/env python3
"""ICS Anomaly Detection System.
 
Builds multi-dimensional baselines from OT network traffic and
detects anomalies using statistical and machine learning methods.
Designed for deterministic SCADA communication patterns.
"""
 
import json
import sys
import time
import warnings
from collections import defaultdict
from datetime import datetime, timedelta
from dataclasses import dataclass, field
 
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
 
warnings.filterwarnings("ignore")
 
 
@dataclass
class CommunicationProfile:
    """Profile for a single master-slave communication pair."""
    src_ip: str
    dst_ip: str
    protocol: str
    port: int
    avg_interval_ms: float = 0.0
    std_interval_ms: float = 0.0
    avg_payload_size: float = 0.0
    function_codes: dict = field(default_factory=dict)
    packets_per_minute: float = 0.0
    first_seen: str = ""
    last_seen: str = ""
 
 
class ICSAnomalyDetector:
    """Multi-dimensional anomaly detection for ICS environments."""
 
    def __init__(self):
        self.profiles = {}
        self.topology_baseline = set()
        self.timing_model = None
        self.isolation_forest = None
        self.scaler = StandardScaler()
        self.anomalies = []
        self.training_data = []
 
    def build_baseline_from_pcap(self, pcap_data):
        """Build baselines from parsed pcap data (list of flow records)."""
        print("[*] Building ICS communication baselines...")
 
        for flow in pcap_data:
            key = f"{flow['src']}->{flow['dst']}:{flow['port']}"
 
            if key not in self.profiles:
                self.profiles[key] = CommunicationProfile(
                    src_ip=flow["src"],
                    dst_ip=flow["dst"],
                    protocol=flow.get("protocol", "TCP"),
                    port=flow["port"],
                    first_seen=flow.get("timestamp", ""),
                )
 
            profile = self.profiles[key]
            profile.last_seen = flow.get("timestamp", "")
 
            # Track function codes for industrial protocols
            fc = flow.get("function_code")
            if fc is not None:
                profile.function_codes[fc] = profile.function_codes.get(fc, 0) + 1
 
            # Add to topology baseline
            self.topology_baseline.add((flow["src"], flow["dst"], flow["port"]))
 
        # Calculate interval statistics
        self._calculate_timing_stats(pcap_data)
 
        print(f"  Communication pairs: {len(self.profiles)}")
        print(f"  Topology entries: {len(self.topology_baseline)}")
 
    def _calculate_timing_stats(self, flows):
        """Calculate packet timing statistics per communication pair."""
        timestamps = defaultdict(list)
        for flow in flows:
            key = f"{flow['src']}->{flow['dst']}:{flow['port']}"
            ts = flow.get("timestamp_epoch")
            if ts:
                timestamps[key].append(ts)
 
        for key, ts_list in timestamps.items():
            if key in self.profiles and len(ts_list) > 1:
                ts_sorted = sorted(ts_list)
                intervals = [
                    (ts_sorted[i+1] - ts_sorted[i]) * 1000
                    for i in range(len(ts_sorted) - 1)
                ]
                self.profiles[key].avg_interval_ms = np.mean(intervals)
                self.profiles[key].std_interval_ms = np.std(intervals)
                duration_min = (ts_sorted[-1] - ts_sorted[0]) / 60
                if duration_min > 0:
                    self.profiles[key].packets_per_minute = len(ts_list) / duration_min
 
    def train_isolation_forest(self, features_df):
        """Train Isolation Forest model on feature vectors from baseline traffic."""
        print("[*] Training Isolation Forest model...")
 
        feature_cols = [
            "interval_ms", "payload_size", "packets_per_window",
            "unique_func_codes", "new_connection_flag",
        ]
 
        available_cols = [c for c in feature_cols if c in features_df.columns]
        X = features_df[available_cols].fillna(0).values
 
        X_scaled = self.scaler.fit_transform(X)
 
        self.isolation_forest = IsolationForest(
            n_estimators=200,
            contamination=0.01,  # Expect 1% anomaly rate in baseline
            random_state=42,
            n_jobs=-1,
        )
        self.isolation_forest.fit(X_scaled)
 
        scores = self.isolation_forest.decision_function(X_scaled)
        print(f"  Model trained on {len(X)} samples")
        print(f"  Anomaly score range: [{scores.min():.4f}, {scores.max():.4f}]")
        print(f"  Threshold: {np.percentile(scores, 1):.4f}")
 
    def detect_topology_anomaly(self, src_ip, dst_ip, port):
        """Detect new/unauthorized communication pairs."""
        if (src_ip, dst_ip, port) not in self.topology_baseline:
            return {
                "type": "NEW_COMMUNICATION_PAIR",
                "severity": "high",
                "detail": f"New connection: {src_ip} -> {dst_ip}:{port} not in baseline",
                "recommendation": "Verify if this is an authorized new device or configuration change",
            }
        return None
 
    def detect_timing_anomaly(self, src_ip, dst_ip, port, interval_ms):
        """Detect polling interval deviations."""
        key = f"{src_ip}->{dst_ip}:{port}"
        profile = self.profiles.get(key)
 
        if profile and profile.std_interval_ms > 0:
            z_score = abs(interval_ms - profile.avg_interval_ms) / profile.std_interval_ms
            if z_score > 4.0:
                return {
                    "type": "TIMING_ANOMALY",
                    "severity": "medium",
                    "detail": (
                        f"Interval {interval_ms:.1f}ms deviates from baseline "
                        f"{profile.avg_interval_ms:.1f}ms (z-score: {z_score:.1f})"
                    ),
                    "recommendation": "Check for network congestion, device malfunction, or MITM attack",
                }
        return None
 
    def detect_function_code_anomaly(self, src_ip, dst_ip, port, func_code):
        """Detect unauthorized Modbus/DNP3 function codes."""
        key = f"{src_ip}->{dst_ip}:{port}"
        profile = self.profiles.get(key)
 
        if profile and func_code not in profile.function_codes:
            severity = "critical" if func_code in {5, 6, 15, 16, 8} else "high"
            return {
                "type": "UNAUTHORIZED_FUNCTION_CODE",
                "severity": severity,
                "detail": (
                    f"Function code {func_code} from {src_ip} to {dst_ip}:{port} "
                    f"not in baseline. Allowed: {list(profile.function_codes.keys())}"
                ),
                "recommendation": "Investigate source - possible command injection attack",
            }
        return None
 
    def analyze_flow(self, flow):
        """Analyze a single network flow against all detection models."""
        results = []
 
        # Topology check
        topo = self.detect_topology_anomaly(flow["src"], flow["dst"], flow["port"])
        if topo:
            results.append(topo)
 
        # Timing check
        if "interval_ms" in flow:
            timing = self.detect_timing_anomaly(
                flow["src"], flow["dst"], flow["port"], flow["interval_ms"])
            if timing:
                results.append(timing)
 
        # Function code check
        if "function_code" in flow:
            fc = self.detect_function_code_anomaly(
                flow["src"], flow["dst"], flow["port"], flow["function_code"])
            if fc:
                results.append(fc)
 
        self.anomalies.extend(results)
        return results
 
    def generate_report(self):
        """Generate anomaly detection report."""
        print(f"\n{'='*60}")
        print(f"ICS ANOMALY DETECTION REPORT")
        print(f"{'='*60}")
        print(f"Baseline Profiles: {len(self.profiles)}")
        print(f"Anomalies Detected: {len(self.anomalies)}")
 
        severity_counts = defaultdict(int)
        for a in self.anomalies:
            severity_counts[a["severity"]] += 1
 
        for sev in ["critical", "high", "medium", "low"]:
            if severity_counts[sev]:
                print(f"  {sev.upper()}: {severity_counts[sev]}")
 
        for a in self.anomalies[:20]:
            print(f"\n  [{a['severity'].upper()}] {a['type']}")
            print(f"    {a['detail']}")
 
 
if __name__ == "__main__":
    print("ICS Anomaly Detection System")
    print("Load baseline data and call analyze_flow() for real-time detection")

Key Concepts

Term Definition
Deterministic Traffic ICS networks exhibit highly predictable communication patterns where the same master polls the same slaves at fixed intervals with identical function codes
Isolation Forest Unsupervised machine learning algorithm that isolates anomalies by randomly partitioning feature space, effective for OT traffic with low anomaly rates
Polling Interval Time between consecutive SCADA master requests to a slave device, typically fixed and configurable (100ms to 10s)
Function Code Allowlist Set of permitted industrial protocol operations for each communication pair, enforced by anomaly detection rules
Topology Baseline Complete map of all authorized device-to-device communication paths in the OT network
Physics-Based Detection Using physical process models (thermodynamics, fluid dynamics) to detect attacks that manipulate the process while spoofing sensor data

Tools & Systems

  • Nozomi Networks Guardian: OT anomaly detection with AI-powered baseline learning and industrial protocol analysis
  • Dragos Platform: Threat detection using behavioral analytics and threat intelligence specific to ICS environments
  • Scikit-learn: Python ML library with Isolation Forest, One-Class SVM, and Local Outlier Factor for anomaly detection
  • Zeek with OT plugins: Network security monitor with Modbus, DNP3, and BACnet protocol analyzers for baseline building

Output Format

ICS Anomaly Detection Report
==============================
Detection Period: YYYY-MM-DD to YYYY-MM-DD
Baseline Size: [N] communication profiles
 
ANOMALIES DETECTED: [N]
  Critical: [N]  High: [N]  Medium: [N]  Low: [N]
 
[SEVERITY] ANOMALY_TYPE
  Source: [IP] -> Target: [IP]:[Port]
  Detail: [Description of deviation from baseline]
  Baseline: [Expected behavior]
  Observed: [Actual behavior]
Source materials

References and resources

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

References 1

api-reference.md1.7 KB

ICS Anomaly Detection — API Reference

Libraries

Library Install Purpose
pymodbus pip install pymodbus Modbus TCP/RTU client
requests pip install requests Historian and SIEM API access

Modbus TCP Protocol

Function Code Name Risk
1 Read Coils Low
3 Read Holding Registers Low
5 Write Single Coil Medium
6 Write Single Register Medium
15 Write Multiple Coils High
16 Write Multiple Registers High
43 Read Device Identification Recon

Common ICS Ports

Port Protocol Description
502 Modbus TCP PLC communication
102 S7comm Siemens S7 PLCs
44818 EtherNet/IP Allen-Bradley / Rockwell
20000 DNP3 Distributed Network Protocol
4840 OPC-UA OPC Unified Architecture
47808 BACnet Building automation

pymodbus Client Usage

from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("192.168.1.10", port=502)
client.connect()
result = client.read_holding_registers(0, count=10, slave=1)
print(result.registers)
client.close()

Anomaly Detection Thresholds

Metric Threshold Severity
Unusual function codes FC 8, 17, 43, 90+ HIGH
Write frequency > 100/min Burst writes CRITICAL
Exception responses Any exception code MEDIUM
New source IP to PLC Unauthorized access CRITICAL

External References

Scripts 1

agent.py6.6 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""ICS/SCADA anomaly detection agent for industrial control systems."""

import json
import sys
import argparse
import socket
from datetime import datetime

try:
    from pymodbus.client import ModbusTcpClient
except ImportError:
    ModbusTcpClient = None

try:
    import requests
except ImportError:
    print("Install: pip install requests")
    sys.exit(1)


MODBUS_FUNCTION_CODES = {
    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",
    43: "Read Device Identification",
}

ANOMALOUS_FUNCTION_CODES = {8, 17, 43, 90, 100}


def scan_modbus_device(host, port=502, unit_id=1):
    """Read Modbus device identification and holding registers."""
    if ModbusTcpClient is None:
        return {"error": "Install pymodbus: pip install pymodbus"}
    client = ModbusTcpClient(host, port=port, timeout=10)
    result = {"host": host, "port": port, "unit_id": unit_id}
    try:
        if not client.connect():
            result["error"] = "Connection failed"
            return result
        rr = client.read_holding_registers(0, count=10, slave=unit_id)
        if not rr.isError():
            result["holding_registers_0_9"] = rr.registers
        rr2 = client.read_device_information(slave=unit_id)
        if hasattr(rr2, "information") and not rr2.isError():
            result["device_info"] = {k: v.decode() if isinstance(v, bytes) else v
                                     for k, v in rr2.information.items()}
    except Exception as e:
        result["error"] = str(e)
    finally:
        client.close()
    return result


def analyze_modbus_traffic(pcap_summary):
    """Analyze Modbus traffic patterns for anomalies from parsed PCAP data."""
    findings = []
    for entry in pcap_summary:
        fc = entry.get("function_code", 0)
        if fc in ANOMALOUS_FUNCTION_CODES:
            findings.append({
                "src": entry.get("src_ip", ""),
                "dst": entry.get("dst_ip", ""),
                "function_code": fc,
                "issue": f"Unusual Modbus function code {fc} — potential reconnaissance",
                "severity": "HIGH",
            })
        if entry.get("write_count", 0) > 100:
            findings.append({
                "src": entry.get("src_ip", ""),
                "issue": f"High write frequency ({entry['write_count']} writes) — possible attack",
                "severity": "CRITICAL",
            })
        if entry.get("exception_code"):
            findings.append({
                "src": entry.get("src_ip", ""),
                "issue": f"Modbus exception code {entry['exception_code']} — device error",
                "severity": "MEDIUM",
            })
    return findings


def check_ics_network_segmentation(host, ics_ports=None):
    """Verify ICS network segmentation by testing connectivity to OT ports."""
    if ics_ports is None:
        ics_ports = [502, 102, 44818, 20000, 4840]
    port_names = {502: "Modbus", 102: "S7comm", 44818: "EtherNet/IP",
                  20000: "DNP3", 4840: "OPC-UA"}
    results = []
    for port in ics_ports:
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(3)
            open_status = sock.connect_ex((host, port)) == 0
            sock.close()
            result = {
                "host": host, "port": port,
                "protocol": port_names.get(port, "unknown"),
                "accessible": open_status,
            }
            if open_status:
                result["finding"] = f"{port_names.get(port, '')} port accessible from IT network"
                result["severity"] = "CRITICAL"
            results.append(result)
        except socket.error:
            pass
    return results


def query_historian_anomalies(historian_url, api_key, tag_name, hours=24):
    """Query process historian for anomalous sensor readings."""
    headers = {"Authorization": f"Bearer {api_key}"}
    try:
        resp = requests.get(f"{historian_url}/api/v1/tags/{tag_name}/values",
                            params={"hours": hours}, headers=headers, timeout=15)
        resp.raise_for_status()
        data = resp.json().get("values", [])
        values = [v["value"] for v in data if "value" in v]
        if not values:
            return {"tag": tag_name, "anomalies": []}
        avg = sum(values) / len(values)
        std = (sum((v - avg) ** 2 for v in values) / len(values)) ** 0.5
        anomalies = [v for v in data if abs(v.get("value", avg) - avg) > 3 * std]
        return {"tag": tag_name, "mean": avg, "std_dev": std,
                "total_readings": len(values), "anomalies": len(anomalies)}
    except Exception as e:
        return {"tag": tag_name, "error": str(e)}


def run_audit(args):
    """Execute ICS anomaly detection audit."""
    print(f"\n{'='*60}")
    print(f"  ICS ANOMALY DETECTION AUDIT")
    print(f"  Generated: {datetime.utcnow().isoformat()} UTC")
    print(f"{'='*60}\n")

    report = {}

    if args.modbus_host:
        device = scan_modbus_device(args.modbus_host, args.modbus_port or 502)
        report["modbus_device"] = device
        print(f"--- MODBUS DEVICE SCAN ---")
        print(f"  Host: {device['host']}:{device['port']}")
        if device.get("holding_registers_0_9"):
            print(f"  Registers 0-9: {device['holding_registers_0_9']}")
        if device.get("error"):
            print(f"  Error: {device['error']}")

    if args.scan_host:
        seg_results = check_ics_network_segmentation(args.scan_host)
        report["segmentation_check"] = seg_results
        print(f"\n--- NETWORK SEGMENTATION CHECK ---")
        for r in seg_results:
            status = "ACCESSIBLE" if r["accessible"] else "BLOCKED"
            print(f"  {r['protocol']} (:{r['port']}): {status}")

    return report


def main():
    parser = argparse.ArgumentParser(description="ICS Anomaly Detection Agent")
    parser.add_argument("--modbus-host", help="Modbus device IP to scan")
    parser.add_argument("--modbus-port", type=int, default=502, help="Modbus port")
    parser.add_argument("--scan-host", help="Host to test ICS segmentation")
    parser.add_argument("--historian-url", help="Process historian API URL")
    parser.add_argument("--historian-key", help="Historian API key")
    parser.add_argument("--output", help="Save report to JSON file")
    args = parser.parse_args()

    report = run_audit(args)
    if args.output:
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2, default=str)
        print(f"\n[+] Report saved to {args.output}")


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