ot ics security

Detecting DNP3 Protocol Anomalies

Detect anomalies in DNP3 (Distributed Network Protocol 3) communications used in SCADA systems by monitoring for unauthorized control commands, firmware update attempts, protocol violations, and deviations from baseline traffic patterns using deep packet inspection and machine learning approaches.

anomaly-detectiondnp3energy-sectoricsidsot-securityprotocol-analysisscada
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

  • When monitoring SCADA systems in the energy sector where DNP3 is the primary protocol
  • When building detection rules for DNP3-based attacks against RTUs and substations
  • When investigating suspected unauthorized control commands sent via DNP3
  • When deploying IDS with DNP3 deep packet inspection at utility substations
  • When responding to alerts from OT monitoring platforms about DNP3 traffic anomalies

Do not use for non-DNP3 protocol monitoring (see detecting-modbus-command-injection-attacks for Modbus), for DNP3 Secure Authentication configuration (separate implementation), or for protocol-agnostic network anomaly detection.

Prerequisites

  • Network TAP/SPAN on DNP3 communication segments (TCP port 20000 or serial)
  • Baseline of normal DNP3 traffic patterns (masters, outstations, poll intervals, function codes)
  • Suricata or Zeek with DNP3 protocol parser enabled
  • Understanding of DNP3 function codes and object groups used in the environment
  • DNP3 communication topology map (master-to-outstation relationships)

Workflow

Step 1: Analyze DNP3 Traffic for Anomalies

#!/usr/bin/env python3
"""DNP3 Protocol Anomaly Detector.
 
Monitors DNP3 communications for unauthorized control commands,
protocol violations, and deviations from established baselines.
Supports both TCP and serial DNP3 deployments.
"""
 
import struct
import sys
import json
from collections import defaultdict
from datetime import datetime
from typing import Dict, List, Optional, Set
 
try:
    from scapy.all import rdpcap, IP, TCP
except ImportError:
    print("Install scapy: pip install scapy")
    sys.exit(1)
 
 
# DNP3 Function Codes
DNP3_FUNCTIONS = {
    0x00: "Confirm", 0x01: "Read", 0x02: "Write",
    0x03: "Select", 0x04: "Operate", 0x05: "Direct Operate",
    0x06: "Direct Operate No Ack", 0x07: "Immediate Freeze",
    0x08: "Immediate Freeze No Ack", 0x09: "Freeze and Clear",
    0x0A: "Freeze and Clear No Ack", 0x0B: "Freeze at Time",
    0x0C: "Freeze at Time No Ack", 0x0D: "Cold Restart",
    0x0E: "Warm Restart", 0x0F: "Initialize Data",
    0x10: "Initialize Application", 0x11: "Start Application",
    0x12: "Stop Application", 0x13: "Save Configuration",
    0x14: "Enable Unsolicited", 0x15: "Disable Unsolicited",
    0x16: "Assign Class", 0x17: "Delay Measurement",
    0x18: "Record Current Time", 0x19: "Open File",
    0x1A: "Close File", 0x1B: "Delete File",
    0x1C: "Get File Info", 0x1D: "Authenticate File",
    0x1E: "Abort File", 0x81: "Response", 0x82: "Unsolicited Response",
}
 
# High-risk function codes that should trigger alerts
DNP3_CRITICAL_FUNCTIONS = {
    0x02,  # Write
    0x03, 0x04, 0x05, 0x06,  # Select/Operate/Direct Operate
    0x0D,  # Cold Restart
    0x0E,  # Warm Restart
    0x0F,  # Initialize Data
    0x10,  # Initialize Application
    0x12,  # Stop Application
    0x19, 0x1A, 0x1B,  # File operations (firmware update)
}
 
 
class DNP3AnomalyDetector:
    """Detects anomalies in DNP3 protocol communications."""
 
    def __init__(self, baseline_file: Optional[str] = None):
        self.alerts = []
        self.sessions = defaultdict(lambda: {
            "packet_count": 0,
            "function_codes": defaultdict(int),
            "control_commands": 0,
            "file_operations": 0,
            "restarts": 0,
        })
        self.packet_count = 0
        self.dnp3_count = 0
 
        self.authorized_masters: Set[str] = set()
        self.authorized_pairs: Dict[str, Set[str]] = defaultdict(set)
        self.baseline_functions: Dict[str, Set[int]] = defaultdict(set)
 
        if baseline_file:
            self.load_baseline(baseline_file)
 
    def load_baseline(self, filepath: str):
        """Load DNP3 communication baseline."""
        with open(filepath, "r") as f:
            baseline = json.load(f)
 
        for entry in baseline.get("authorized_communications", []):
            master = entry["master_ip"]
            outstation = entry["outstation_ip"]
            self.authorized_masters.add(master)
            self.authorized_pairs[master].add(outstation)
            self.baseline_functions[f"{master}->{outstation}"] = set(
                entry.get("expected_function_codes", [0x00, 0x01])
            )
 
    def parse_dnp3_header(self, payload: bytes) -> Optional[dict]:
        """Parse DNP3 data link layer and transport/application headers."""
        if len(payload) < 10:
            return None
 
        # DNP3 Data Link Layer: start(2) + length(1) + control(1) + dest(2) + source(2) + crc(2)
        start_bytes = struct.unpack(">H", payload[0:2])[0]
        if start_bytes != 0x0564:
            return None
 
        length = payload[2]
        control = payload[3]
        dest_addr = struct.unpack("<H", payload[4:6])[0]
        source_addr = struct.unpack("<H", payload[6:8])[0]
 
        direction = "Master->Outstation" if (control & 0x80) else "Outstation->Master"
 
        result = {
            "length": length,
            "control": control,
            "direction": direction,
            "dest_addr": dest_addr,
            "source_addr": source_addr,
            "is_master": bool(control & 0x80),
        }
 
        # Parse transport and application layer (after CRC bytes)
        if len(payload) >= 12:
            transport_header = payload[10]
            if len(payload) >= 13:
                app_control = payload[11]
                func_code = payload[12]
                result["function_code"] = func_code
                result["function_name"] = DNP3_FUNCTIONS.get(
                    func_code, f"Unknown (0x{func_code:02x})"
                )
 
        return result
 
    def analyze_packet(self, pkt):
        """Analyze a packet for DNP3 anomalies."""
        self.packet_count += 1
 
        if not pkt.haslayer(IP) or not pkt.haslayer(TCP):
            return
 
        tcp = pkt[TCP]
        if tcp.dport != 20000 and tcp.sport != 20000:
            return
 
        payload = bytes(tcp.payload)
        if not payload:
            return
 
        dnp3 = self.parse_dnp3_header(payload)
        if not dnp3:
            return
 
        self.dnp3_count += 1
        src_ip = pkt[IP].src
        dst_ip = pkt[IP].dst
        session_key = f"{src_ip}->{dst_ip}"
        session = self.sessions[session_key]
        session["packet_count"] += 1
 
        func_code = dnp3.get("function_code")
        if func_code is not None:
            session["function_codes"][func_code] += 1
 
            # Detection 1: Unauthorized DNP3 master
            if dnp3.get("is_master") and self.authorized_masters:
                if src_ip not in self.authorized_masters:
                    self.alerts.append({
                        "severity": "CRITICAL",
                        "type": "UNAUTHORIZED_DNP3_MASTER",
                        "src": src_ip, "dst": dst_ip,
                        "function": dnp3.get("function_name"),
                        "description": f"Unauthorized DNP3 master {src_ip} communicating with outstation {dst_ip}",
                        "mitre": "T0869 - Standard Application Layer Protocol",
                    })
 
            # Detection 2: Cold/Warm restart command
            if func_code in (0x0D, 0x0E):
                session["restarts"] += 1
                restart_type = "Cold" if func_code == 0x0D else "Warm"
                self.alerts.append({
                    "severity": "CRITICAL",
                    "type": "DNP3_RESTART_COMMAND",
                    "src": src_ip, "dst": dst_ip,
                    "function": f"{restart_type} Restart",
                    "description": f"{restart_type} restart command sent to outstation {dst_ip} (addr {dnp3['dest_addr']})",
                    "mitre": "T0816 - Device Restart/Shutdown",
                })
 
            # Detection 3: File operations (potential firmware update)
            if func_code in (0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E):
                session["file_operations"] += 1
                self.alerts.append({
                    "severity": "CRITICAL",
                    "type": "DNP3_FILE_OPERATION",
                    "src": src_ip, "dst": dst_ip,
                    "function": dnp3.get("function_name"),
                    "description": f"File operation on outstation {dst_ip} - potential firmware update or PIPEDREAM indicator",
                    "mitre": "T0839 - Module Firmware",
                })
 
            # Detection 4: Control commands (Select/Operate)
            if func_code in (0x03, 0x04, 0x05, 0x06):
                session["control_commands"] += 1
                if session_key in self.baseline_functions:
                    if func_code not in self.baseline_functions[session_key]:
                        self.alerts.append({
                            "severity": "HIGH",
                            "type": "UNEXPECTED_CONTROL_COMMAND",
                            "src": src_ip, "dst": dst_ip,
                            "function": dnp3.get("function_name"),
                            "description": f"Control command {dnp3.get('function_name')} not in baseline for {session_key}",
                            "mitre": "T0855 - Unauthorized Command Message",
                        })
 
            # Detection 5: Anomalous function code for this pair
            if session_key in self.baseline_functions:
                if func_code not in self.baseline_functions[session_key]:
                    if func_code not in (0x00, 0x81, 0x82):  # Exclude common response codes
                        self.alerts.append({
                            "severity": "MEDIUM",
                            "type": "ANOMALOUS_FUNCTION_CODE",
                            "src": src_ip, "dst": dst_ip,
                            "function": dnp3.get("function_name"),
                            "description": f"Function code 0x{func_code:02x} not in baseline",
                            "mitre": "T0855 - Unauthorized Command Message",
                        })
 
    def generate_report(self):
        """Generate DNP3 anomaly detection report."""
        print(f"\n{'='*70}")
        print("DNP3 PROTOCOL ANOMALY DETECTION REPORT")
        print(f"{'='*70}")
        print(f"Analysis Time: {datetime.now().isoformat()}")
        print(f"Total Packets: {self.packet_count}")
        print(f"DNP3 Packets: {self.dnp3_count}")
        print(f"Alerts: {len(self.alerts)}")
 
        print(f"\n--- DNP3 SESSION SUMMARY ---")
        for key, session in self.sessions.items():
            print(f"\n  {key}")
            print(f"    Packets: {session['packet_count']}")
            funcs = [DNP3_FUNCTIONS.get(f, f"0x{f:02x}") for f in session["function_codes"]]
            print(f"    Functions: {', '.join(funcs)}")
            print(f"    Control Commands: {session['control_commands']}")
            print(f"    File Operations: {session['file_operations']}")
            print(f"    Restart Commands: {session['restarts']}")
 
        if self.alerts:
            print(f"\n--- ALERTS ---")
            for alert in self.alerts:
                print(f"\n  [{alert['severity']}] {alert['type']}")
                print(f"    {alert['src']} -> {alert['dst']}")
                print(f"    Function: {alert['function']}")
                print(f"    Detail: {alert['description']}")
                print(f"    MITRE ICS: {alert.get('mitre', 'N/A')}")
 
 
if __name__ == "__main__":
    detector = DNP3AnomalyDetector(
        baseline_file=sys.argv[2] if len(sys.argv) > 2 else None
    )
 
    if len(sys.argv) >= 2:
        print(f"[*] Analyzing: {sys.argv[1]}")
        packets = rdpcap(sys.argv[1])
        for pkt in packets:
            detector.analyze_packet(pkt)
        detector.generate_report()
    else:
        print("Usage: python dnp3_detector.py <capture.pcap> [baseline.json]")

Key Concepts

Term Definition
DNP3 Distributed Network Protocol version 3, the predominant SCADA protocol in the energy sector for communication between masters and outstations
Outstation DNP3 slave device (typically an RTU or IED) that responds to master station polls and commands
Select-Before-Operate DNP3 safety mechanism requiring a Select command before an Operate, preventing accidental control actions
Cold Restart (FC 0x0D) DNP3 command that fully restarts an outstation, resetting all configuration -- a high-risk denial-of-service operation
DNP3 Secure Authentication Optional DNP3 extension (SA v5) adding HMAC-based authentication to prevent command spoofing
PIPEDREAM ICS attack framework with DNP3 capabilities for manipulating outstations and performing firmware updates

Output Format

DNP3 ANOMALY DETECTION REPORT
================================
Analysis Period: [start] to [end]
Monitoring Point: [substation/segment]
 
TRAFFIC SUMMARY:
  DNP3 Packets: [count]
  Unique Master-Outstation Pairs: [count]
  Control Commands: [count]
  File Operations: [count]
 
ALERTS:
  [CRITICAL] Unauthorized DNP3 master [IP]
  [CRITICAL] Cold restart command to outstation [addr]
  [HIGH] Unexpected control command from [IP]
 
RECOMMENDATIONS:
  1. Deploy DNP3 Secure Authentication (SA v5)
  2. Block unauthorized sources at firewall
  3. Enable DNP3 DPI on industrial firewall
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 DNP3 Protocol Anomalies

DNP3 Function Codes

Code Name Risk Level
0x01 READ Normal
0x02 WRITE Caution
0x03 SELECT Caution
0x04 OPERATE Critical
0x05 DIRECT_OPERATE Critical
0x0D COLD_RESTART Critical
0x0E WARM_RESTART Critical
0x10 INITIALIZE_APPLICATION Critical
0x12 STOP_APPLICATION Critical

Zeek DNP3 Log Fields

#fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p fc_request fc_reply

Zeek DNP3 Protocol Analyzer

# Enable DNP3 analyzer in Zeek
zeek -C -r capture.pcap protocols/dnp3
 
# Output: dnp3.log with function codes, objects, IIN bits

Suricata DNP3 Rules

alert dnp3 any any -> any 20000 (msg:"DNP3 Cold Restart"; \
  dnp3_func:cold_restart; sid:1000001; rev:1;)
 
alert dnp3 any any -> any 20000 (msg:"DNP3 Direct Operate"; \
  dnp3_func:direct_operate; sid:1000002; rev:1;)

Scapy DNP3 Parsing

from scapy.all import rdpcap
from scapy.contrib.dnp3 import DNP3
 
packets = rdpcap("dnp3_capture.pcap")
for pkt in packets:
    if pkt.haslayer(DNP3):
        print(pkt[DNP3].func_code)

ICS-CERT Detection Indicators

Anomaly Detection Method
Unauthorized master Source IP not in allowed list
Burst traffic >10 events/sec from single source
Off-hours commands Control operations outside maintenance windows
Unknown function codes Function codes not in normal baseline

CLI Usage

python agent.py --zeek-log dnp3.log
python agent.py --zeek-log dnp3.log --authorized-masters masters.txt

Scripts 1

agent.py5.6 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""DNP3 protocol anomaly detection agent for ICS/SCADA environments.

Analyzes DNP3 network traffic captures (via Zeek or pcap) to detect
unauthorized control commands, protocol violations, and traffic anomalies.
"""

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

DNP3_FUNCTION_CODES = {
    0x01: ("READ", "normal"), 0x02: ("WRITE", "caution"),
    0x03: ("SELECT", "caution"), 0x04: ("OPERATE", "critical"),
    0x05: ("DIRECT_OPERATE", "critical"), 0x06: ("DIRECT_OPERATE_NR", "critical"),
    0x0D: ("COLD_RESTART", "critical"), 0x0E: ("WARM_RESTART", "critical"),
    0x0F: ("INITIALIZE_DATA", "critical"), 0x10: ("INITIALIZE_APPLICATION", "critical"),
    0x11: ("START_APPLICATION", "critical"), 0x12: ("STOP_APPLICATION", "critical"),
    0x14: ("ENABLE_UNSOLICITED", "caution"), 0x15: ("DISABLE_UNSOLICITED", "caution"),
    0x18: ("RECORD_CURRENT_TIME", "normal"),
    0x81: ("RESPONSE", "normal"), 0x82: ("UNSOLICITED_RESPONSE", "normal"),
}

AUTHORIZED_MASTERS = set()
AUTHORIZED_OUTSTATIONS = set()


def load_authorized_hosts(filepath):
    hosts = set()
    if filepath:
        with open(filepath, "r") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#"):
                    hosts.add(line)
    return hosts


def parse_zeek_dnp3_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):
                evt = dict(zip(headers, fields))
                events.append(evt)
    return events


def analyze_dnp3_traffic(events, authorized_masters, authorized_outstations):
    findings = []
    fc_counter = Counter()
    src_dst_pairs = defaultdict(int)
    critical_ops = []

    for evt in events:
        src = evt.get("id.orig_h", evt.get("src_ip", ""))
        dst = evt.get("id.resp_h", evt.get("dst_ip", ""))
        fc_str = evt.get("fc_request", evt.get("function_code", ""))

        try:
            fc = int(fc_str, 16) if fc_str.startswith("0x") else int(fc_str)
        except (ValueError, TypeError):
            fc = -1

        fc_info = DNP3_FUNCTION_CODES.get(fc, ("UNKNOWN", "caution"))
        fc_counter[fc_info[0]] += 1
        src_dst_pairs[f"{src}->{dst}"] += 1

        if authorized_masters and src not in authorized_masters:
            findings.append({
                "type": "unauthorized_master",
                "source": src, "destination": dst,
                "function_code": fc_info[0],
                "severity": "CRITICAL",
                "description": f"DNP3 command from unauthorized master {src}",
            })

        if fc_info[1] == "critical":
            critical_ops.append({
                "timestamp": evt.get("ts", ""),
                "source": src, "destination": dst,
                "function_code": fc_info[0],
                "severity": "HIGH",
            })
            findings.append({
                "type": "critical_control_command",
                "source": src, "destination": dst,
                "function_code": fc_info[0],
                "severity": "HIGH",
                "description": f"Critical DNP3 operation: {fc_info[0]}",
            })

    return {
        "total_events": len(events),
        "function_code_distribution": dict(fc_counter),
        "communication_pairs": dict(src_dst_pairs),
        "critical_operations": critical_ops,
        "findings": findings,
    }


def detect_protocol_anomalies(events):
    anomalies = []
    burst_window = defaultdict(list)

    for evt in events:
        ts = evt.get("ts", "0")
        src = evt.get("id.orig_h", "")
        try:
            timestamp = float(ts)
        except ValueError:
            continue
        burst_window[src].append(timestamp)

    for src, timestamps in burst_window.items():
        timestamps.sort()
        for i in range(len(timestamps) - 10):
            window = timestamps[i + 10] - timestamps[i]
            if window < 1.0:
                anomalies.append({
                    "type": "burst_traffic",
                    "source": src,
                    "events_per_second": round(10 / max(window, 0.001), 1),
                    "severity": "HIGH",
                    "description": f"DNP3 traffic burst from {src}: >10 events/sec",
                })
                break
    return anomalies


def main():
    parser = argparse.ArgumentParser(description="DNP3 Protocol Anomaly Detector")
    parser.add_argument("--zeek-log", required=True, help="Zeek DNP3 log file")
    parser.add_argument("--authorized-masters", help="File with authorized master IPs")
    parser.add_argument("--authorized-outstations", help="File with authorized outstation IPs")
    args = parser.parse_args()

    masters = load_authorized_hosts(args.authorized_masters)
    outstations = load_authorized_hosts(args.authorized_outstations)
    events = parse_zeek_dnp3_log(args.zeek_log)
    traffic_analysis = analyze_dnp3_traffic(events, masters, outstations)
    anomalies = detect_protocol_anomalies(events)

    results = {
        "timestamp": datetime.utcnow().isoformat() + "Z",
        **traffic_analysis,
        "protocol_anomalies": anomalies,
        "total_findings": len(traffic_analysis["findings"]) + len(anomalies),
    }
    print(json.dumps(results, indent=2))


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