Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Monitors Modbus TCP traffic on SCADA and ICS networks to detect anomalous function code usage, unauthorized register writes, and suspicious communication patterns. The analyst uses deep packet inspection with pymodbus, Scapy, and Zeek to baseline normal PLC/RTU communication behavior, then applies statistical and rule-based anomaly detection to identify reconnaissance, parameter manipulation, and denial-of-service attacks targeting Modbus devices on port 502. Activates for requests involving Modbus traffic analysis, SCADA network monitoring, ICS anomaly detection, PLC security monitoring, or OT network threat detection.
.claude/skills/monitoring-scada-modbus-traffic-anomalies/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-20 | ✗→✓ | ▲ Improved | — | — |
| case-14 | ✗→✓ | ▲ Improved | — | — |
| case-19 | ✗→✓ | ▲ Improved | — | — |
Do not use on networks without authorization from the asset owner, for active injection or fuzzing against production SCADA systems, or as a replacement for safety-instrumented systems (SIS) that provide physical process protection.
Establish passive monitoring on the OT network segment and begin capturing Modbus TCP frames:
tcpdump -i eth0 port 502 -c 100 -w modbus_capture.pcap.python from scapy.all import rdpcap, TCP from scapy.contrib.modbus import ModbusADURequest, ModbusADUResponse
packets = rdpcap("modbus_capture.pcap") for pkt in packets: if pkt.haslayer(ModbusADURequest): adu = pktModbusADURequest] print(f"Src: {pkt'IP'].src} -> Dst: {pkt'IP'].dst} " f"Unit: {adu.unitId} FuncCode: {adu.funcCode}")
@load policy/protocols/modbus/known-masters-slaves to generate modbus.log entries containing timestamp, source/destination IPs, function code, and exception responses. This provides continuous passive logging without custom scripting.Build a behavioral profile of legitimate Modbus traffic to distinguish normal operations from anomalies:
Normal baseline example (72-hour period): HMI (10.1.1.10) -> PLC (10.1.1.50): FC 03 (Read Holding Registers): 432,180 packets (97.2%) FC 04 (Read Input Registers): 10,540 packets (2.4%) FC 06 (Write Single Register): 1,780 packets (0.4%) FC 16 (Write Multiple Registers): 0 packets (0.0%) FC 43 (Read Device ID): 0 packets (0.0%)
Apply rule-based and statistical detection to identify suspicious function code usage:
python WRITE_FUNCTION_CODES = {5, 6, 15, 16} AUTHORIZED_WRITERS = {"10.1.1.10", "10.1.1.11"} # HMI and engineering WS
def check_unauthorized_write(src_ip, function_code): if function_code in WRITE_FUNCTION_CODES and src_ip not in AUTHORIZED_WRITERS: return { "alert": "UNAUTHORIZED_MODBUS_WRITE", "severity": "CRITICAL", "src_ip": src_ip, "function_code": function_code, "description": f"Write FC {function_code} from unauthorized source {src_ip}" } return None
Exception response correlation:
Detect attempts to manipulate physical process parameters through register value analysis:
python REGISTER_LIMITS = { 40001: {"name": "Reactor Temperature Setpoint", "min": 50, "max": 200, "unit": "C", "max_rate": 5}, # Max 5 degrees per write cycle 40010: {"name": "Pump Speed", "min": 0, "max": 3600, "unit": "RPM", "max_rate": 200}, # Max 200 RPM change per cycle 40020: {"name": "Valve Position", "min": 0, "max": 100, "unit": "%", "max_rate": 10}, # Max 10% per cycle }
def check_register_value(register_addr, new_value, previous_value): if register_addr not in REGISTER_LIMITS: return None limits = REGISTER_LIMITSregister_addr] alerts = ] if new_value < limits"min"] or new_value > limits"max"]: alerts.append({ "alert": "REGISTER_VALUE_OUT_OF_RANGE", "severity": "CRITICAL", "register": register_addr, "name": limits"name"], "value": new_value, "range": f"{limits'min']}-{limits'max']} {limits'unit']}" }) if previous_value is not None: rate = abs(new_value - previous_value) if rate > limits"max_rate"]: alerts.append({ "alert": "REGISTER_VALUE_EXCESSIVE_RATE", "severity": "HIGH", "register": register_addr, "name": limits"name"], "change": rate, "max_allowed": limits"max_rate"] }) return alerts if alerts else None
Identify anomalies in communication patterns that may indicate man-in-the-middle, replay, or denial-of-service attacks:
python AUTHORIZED_MASTERS = {"10.1.1.10", "10.1.1.11"}
def detect_rogue_master(src_ip, dst_ip, dst_port): if dst_port == 502 and src_ip not in AUTHORIZED_MASTERS: return { "alert": "ROGUE_MODBUS_MASTER", "severity": "CRITICAL", "src_ip": src_ip, "target_slave": dst_ip, "description": "Unauthorized device initiating Modbus connection" } return None
python import numpy as np from collections import defaultdict
class TimingAnomalyDetector: def __init__(self, window_size=1000, threshold_sigma=3.0): self.windows = defaultdict(list) self.window_size = window_size self.threshold_sigma = threshold_sigma
def check(self, src_ip, dst_ip, timestamp): key = (src_ip, dst_ip) window = self.windowskey] if len(window) > 0: interval = timestamp - window-1] if len(window) >= 100: mean = np.mean(np.diff(window-100:])) std = np.std(np.diff(window-100:])) if std > 0 and abs(interval - mean) > self.threshold_sigma std: return { "alert": "TIMING_ANOMALY", "severity": "MEDIUM", "pair": f"{src_ip}->{dst_ip}", "interval": interval, "expected_mean": mean, "deviation_sigma": abs(interval - mean) / std } window.append(timestamp) if len(window) > self.window_size: window.pop(0) return None
| Term | Definition | |------|------------| | Modbus TCP | An application-layer protocol encapsulating Modbus frames in TCP/IP, communicating on port 502. It uses a 7-byte MBAP header (transaction ID, protocol ID, length, unit ID) followed by the Modbus PDU containing the function code and data. | | Function Code | A single-byte identifier in the Modbus PDU specifying the operation: read coils (01), read discrete inputs (02), read holding registers (03), read input registers (04), write single coil (05), write single register (06), write multiple coils (15), write multiple registers (16), diagnostics (08), and device identification (43). | | MBAP Header | Modbus Application Protocol header used in Modbus TCP. Contains Transaction ID for request-response matching, Protocol ID (always 0x0000 for Modbus), Length of remaining bytes, and Unit Identifier for addressing slaves behind gateways. | | Holding Register | A 16-bit read/write register in a Modbus slave addressed at range 40001-49999 (protocol address 0-9998). Used for setpoints, configuration, and control values that can be written by the master. Primary target for process manipulation attacks. | | Coil | A single-bit read/write data element in a Modbus slave addressed at range 00001-09999. Controls discrete outputs (valves, pumps, breakers). Write operations (FC 05/15) to coils can directly affect physical equipment state. | | Deep Packet Inspection | Analysis beyond TCP/IP headers into the Modbus application-layer payload to extract function codes, register addresses, and values. Required because standard firewalls only inspect IP/port, missing protocol-level attacks that use legitimate Modbus framing. | | Rogue Master | An unauthorized device sending Modbus requests to slave devices. In OT environments, only designated HMI servers and engineering workstations should act as Modbus masters. A rogue master can read process data or write dangerous values to PLCs. | | Register Value Baseline | The statistical profile (min, max, mean, standard deviation) of values observed in specific registers during normal operations. Deviations beyond physical process bounds indicate sensor failure or malicious manipulation. |
modbus.func_code == 6), and exporting specific fields for analysis.Context: A water treatment facility uses Modbus TCP to communicate between the SCADA server (10.1.1.10) and six PLCs controlling chemical dosing pumps, filtration valves, and flow meters. The security team deploys passive Modbus traffic monitoring after an industry advisory about attacks targeting water utilities.
Approach:
Pitfalls:
Context: A manufacturing plant's SOC observes unusual network activity from an engineering workstation (10.1.2.20) that is authorized for PLC programming. The OT security team uses Modbus traffic monitoring to determine if the workstation is being used for reconnaissance.
Approach:
Pitfalls:
## Modbus Traffic Anomaly Report
**Monitoring Period**: 2026-03-15 00:00:00 UTC to 2026-03-15 23:59:59 UTC
**Network Segment**: OT VLAN 10 (10.1.1.0/24)
**Packets Analyzed**: 2,847,320
**Anomalies Detected**: 4
---
### Alert 1: Unauthorized Write Operation
**Timestamp**: 2026-03-15 14:23:17 UTC
**Severity**: CRITICAL
**Source**: 10.1.2.20 (Engineering Workstation)
**Destination**: 10.1.1.52 (PLC-3 Chemical Dosing)
**Function Code**: 16 (Write Multiple Registers)
**Registers**: 40050-40055
**Values Written**: [250, 100, 0, 1, 3600, 1]
**Baseline**: FC 16 never observed for this source-destination pair
**Context**: Register 40050 (Chlorine Dosing Rate) changed from 25 to 250
(safe range: 10-40). Register 40054 (Dosing Timer) changed from 1800 to 3600.
Combined effect would double chlorine concentration over extended period.
**Recommended Action**: Immediately verify physical process state. Isolate
source device. Check register values against expected setpoints with
plant operator.
---
### Alert 2: Device Enumeration Detected
**Timestamp**: 2026-03-15 14:20:05 to 14:20:47 UTC
**Severity**: HIGH
**Source**: 10.1.2.20
**Targets**: 10.1.1.50, 10.1.1.51, 10.1.1.52, 10.1.1.53, 10.1.1.54 (+10 more)
**Function Code**: 43 (Read Device Identification)
**Baseline**: FC 43 never observed from this source
**Context**: Sequential scanning of 15 devices in 42 seconds. Device
identification responses reveal PLC vendor, model, and firmware versions
for all scanned devices.
**Recommended Action**: Investigate source workstation for compromise
indicators. Block FC 43 from non-engineering subnets at OT firewall.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.