Portable OT Detections: Writing Sigma Rules for DNP3 and Modbus
Post 5 left the sensor decoding DNP3 into logs and alerting with a hand-written Suricata signature. This post turns those logs into portable Sigma rules for DNP3 and Modbus — a rule space that, I found out, is completely empty upstream — and tests every one against the lab's real traffic.
Post 5 got the sensor seeing inside the ESP: a SPAN of the
east-west OT traffic, Zeek decoding DNP3 into a dnp3.log, and one Suricata signature firing on the
rogue breaker command. That signature works, but it’s a single hand-written rule in Suricata’s own
syntax, pinned to my lab’s addresses. If I moved to a different SIEM, or wanted to share the
detection, I’d be rewriting it from scratch.
This post fixes that by writing the same ideas as Sigma rules — the portable, vendor-neutral format for detections — and then doing the part I care about most: testing every rule against real traffic to see what actually fires. Along the way I went looking for existing Sigma rules for industrial protocols to borrow from, and found something I didn’t expect: there aren’t any.
Where this fits. A follow-on to post 5 of the NERC CIP series, and a crossover with the detection-engineering work from the Splunk series. Post 5 produced the logs; this post writes portable detections on top of them.
What is Sigma?
Sigma is a generic, YAML-based signature format for log events. You write the detection logic once — which fields to match, which values, how to combine them — and a converter turns it into the query language of whatever you run: Splunk SPL, Microsoft Sentinel KQL, Elastic, and dozens more. It’s the same idea as writing a detection in both SPL and KQL by hand, except you write it once and let the tool do the translating.
The community ruleset lives in SigmaHQ. It’s large and well-maintained, so before writing anything I checked what it already had for OT protocols.
The gap: no ICS protocol rules exist
I cloned the repo and counted. On the day I ran it, SigmaHQ had 3,791 rules. Grouped by what they watch, the shape is lopsided:
| Log source | Rules |
|---|---|
| Windows | ~2,880 |
| Linux | 248 |
| Azure / AWS / GCP / M365 | ~240 |
| Zeek (network) | 24 |
| DNP3, Modbus, S7, BACnet | 0 |
Every one of the 24 Zeek rules watches IT traffic — SMB, DNS, HTTP, Kerberos. There is nothing for any industrial protocol. A quick grep confirmed it:
3,791 rules in the repo, and not one mentions an industrial protocol.
That’s the gap. The protocols that run the grid have no community detections at all, and my lab already produces clean logs for two of them. So the rules below are written against the exact Zeek schema my sensor outputs, with real traffic to test them on.
The Zeek fields to match on
Sigma rules match fields, so I need the field names Zeek actually writes. Running Zeek 8 over the DNP3 capture from post 3:
1
zeek -C -r dnp3-crob.pcap LogAscii::use_json=T
gives a dnp3.log where each line looks like this (the rogue run’s opening packet):
1
2
{"ts":1784741833.96,"id.orig_h":"10.10.20.12","id.resp_h":"10.10.20.11",
"id.resp_p":20000,"fc_request":"DISABLE_UNSOLICITED","fc_reply":"RESPONSE","iin":1}
The field that matters is fc_request — Zeek decodes the DNP3 function code into its name. For
Modbus, Zeek’s modbus.log uses func (the function name), exception (set on error
responses), and unit. Source and destination are the raw Zeek connection fields
id.orig_h, id.resp_h, and id.resp_p. I used those names directly, because that’s how the
existing SigmaHQ Zeek rules name their fields too (they match on things like id.resp_p), so the
rules drop into the same tooling without a custom field mapping.
The rules
Six rules, split into two kinds.
Function-code rules
These match on what was asked for. They don’t care who sent it, so they catch an attacker even if they’re speaking from a legitimate host. The simplest is disabling unsolicited reporting — the outstation’s way of volunteering “something just changed.” Turning it off is a classic way to keep the operator’s screen quiet while you manipulate the field device:
1
2
3
4
5
6
7
8
9
title: DNP3 Disable Unsolicited Responses
logsource:
product: zeek
service: dnp3
detection:
selection:
fc_request: 'DISABLE_UNSOLICITED'
condition: selection
level: medium
The other one restarts or stops the outstation — COLD_RESTART, WARM_RESTART, STOP_APPL,
INITIALIZE_APPL. Outside a planned maintenance window these are rare and disruptive, so a single
match is worth an alert:
1
2
3
4
5
6
7
8
9
detection:
selection:
fc_request:
- 'COLD_RESTART'
- 'WARM_RESTART'
- 'STOP_APPL'
- 'INITIALIZE_APPL'
condition: selection
level: high
Source-based rules
These match on who is talking. A control network is static — the same master talks to the same outstations for years — so the allowlist is short and the rules are templates you edit for your own addresses. Control commands should only come from the master station:
1
2
3
4
5
6
7
8
detection:
control:
fc_request: ['SELECT', 'OPERATE', 'DIRECT_OPERATE', 'DIRECT_OPERATE_NR']
known_masters:
id.orig_h:
- '10.10.20.12' # <- your control-room master(s)
condition: control and not known_masters
level: high
The Modbus equivalent flags any write function (WRITE_SINGLE_COIL, WRITE_MULTIPLE_REGISTERS,
and the rest) from a host that isn’t the known HMI. Modbus has no authentication, so anything that
can reach TCP/502 can move process state; reads are routine, writes shouldn’t come from strangers.
The last two are behavioural. One flags a new talker pair — any DNP3 conversation whose
source/destination hasn’t been seen before, which catches a rogue master that no function-code rule
would. The other flags a burst of Modbus exceptions (ILLEGAL_FUNCTION, ILLEGAL_DATA_ADDRESS)
from one host, the signature of someone enumerating function codes or address ranges. That one is a
Sigma correlation rule — it counts events in a window:
1
2
3
4
5
6
7
8
9
correlation:
type: event_count
rules:
- modbus_exception_response # the base rule, by id
group-by:
- id.orig_h
timespan: 1m
condition:
gte: 6
The full six are in the lab repo under sigma/rules/.
Checking they’re valid
Sigma ships a linter. It parses every rule, checks the schema, and runs a set of quality validators:
Zero errors, zero condition errors. The 13 “issues” are all the same ICS ATT&CK-tag complaint.
The 13 “issues” are all one thing, and it’s worth explaining because it says something about the state of OT detection tooling — see the note at the end.
Converting to SPL proves they compile to a real query, and ties back into the Splunk series:
The correlation rule becomes a proper bin/stats/search pipeline — the same SPL I’d have written by hand, generated from the YAML.
Testing them against real traffic
Compiling isn’t firing. A rule that parses cleanly can still match nothing, or match everything. The only way to know is to run it against logs, the same way I ran Atomic Red Team against the Splunk detections.
So I wrote a small evaluator that runs each rule’s parsed Sigma logic against Zeek log lines — not a re-written copy of the rule, the actual parsed condition tree — and reports what matched. Then I pointed it at three sets of logs:
- A benign baseline — the master polling the outstation, reads only. Nothing should fire.
- The real lab capture — Zeek’s output over the actual
dnp3-crob.pcapfrom post 3. - Rogue-host fixtures — crafted log lines where an unknown host
10.10.20.66appears on the control network, operates a point, restarts the outstation, writes a coil, and scans.
Here’s what fired:
| Log set | What fired |
|---|---|
| Benign baseline | Nothing (0 of 7 rules — no false positives) |
| Real lab capture | DISABLE_UNSOLICITED only |
| Rogue-host fixtures | The other five, including the scan correlation |
The benign run is the one people skip, and it’s the one that matters most: a rule that fires on normal polling is worse than no rule. All seven stayed quiet.
Benign baseline: steady-state polling and reads. Every rule stays quiet — no false positives.
The interesting miss
Look at the real capture again. It contains the rogue DIRECT_OPERATE — the breaker command that is
the whole point of the lab. But the “control command from an unauthorized source” rule didn’t
fire on it.
That’s correct, and it’s the most useful result here. In the post-3 scenario, the rogue command came
from 10.10.20.12 — which is the SCADA master’s own address. Whoever sent it was either on the
master host or spoofing it. A rule that keys on the source address can’t tell that command apart from
a real one, because by that field it is a real one.
This is why I wrote both kinds of rule. The source-based rules are cheap and catch the easy case — a
strange host showing up on the control net. But the attacker who gets onto the master, or who speaks
with its address, walks straight past them. The function-code rules (DISABLE_UNSOLICITED, the
restart rule) and the behavioural new talker pair rule don’t depend on who’s talking, and on the
real capture the DISABLE_UNSOLICITED rule is the one that caught the run. If I only had the
source-based rule, this capture would have gone by clean.
Top: the real lab capture — only DISABLE_UNSOLICITED fires, because the DIRECT_OPERATE came from the master’s own address. Bottom: the rogue-host fixtures, where a strange host trips the other five rules.
Pros and cons
| Pros | Cons |
|---|---|
| Write once, convert to SPL / KQL / Elastic | The ICS log sources and ATT&CK tags aren’t first-class in the tooling yet |
| Portable and shareable, unlike my per-lab Suricata signature | Source-based rules miss an attacker using a legitimate address |
| Function-code rules need no site-specific tuning | Allowlist rules are templates — useless until you fill in your addresses |
| Fills a real gap: zero ICS rules exist upstream | Zeek has to actually decode the protocol first (post 5’s checksum gotcha) |
A note on ATT&CK tags
Those 13 linter “issues” are all the same complaint: an invalid ATT&CK tag. My rules are tagged with
ICS ATT&CK techniques — attack.t0816 (Device Restart/Shutdown), attack.t0855 (Unauthorized
Command Message), and so on — because that’s the matrix that describes attacks on control systems.
But the linter’s default taxonomy only knows Enterprise ATT&CK, so it rejects every ICS technique
ID as unknown.
It isn’t a mistake in the rules. SigmaHQ’s own CI has its hard MITRE-tag test commented out, and the validator that complains is flagged for removal in the next major pySigma release. The tags are correct; the tooling just hasn’t caught up to ICS. Which is the same story as the empty rule directory — OT detection in the open-source world is early, and that’s exactly what makes it worth writing about.
Wrap-up
I set out to replace one hand-written Suricata signature with portable Sigma rules, and ended up writing the first DNP3 and Modbus detections I could find anywhere in the open ruleset. Six rules, all passing the linter, all converting to SPL, all tested against real and crafted traffic — with a clean benign baseline and one honest miss that explains why function-code detections matter more than address-based ones in OT.
Next I want to feed these into a real Splunk instance against a live Zeek feed from the sensor, rather than my offline evaluator, and see whether the correlation rule holds up at traffic volume. That’s the SIEM step post 5 pointed at. And the rules themselves are clean enough that submitting them upstream to SigmaHQ is on the table — there’s clearly room.
References
- Sigma · SigmaHQ rules repo · sigma-cli
- Zeek DNP3 analyzer · Zeek Modbus analyzer
- MITRE ATT&CK for ICS
- Earlier in this series: DNP3 on the wire · CIP-015 INSM with Zeek and Suricata
- Detection-engineering companion posts: Same detection, SPL and KQL · Atomic Red Team vs. my Splunk detections