Post

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.

Portable OT Detections: Writing Sigma Rules for DNP3 and Modbus

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:

Surveying the SigmaHQ ruleset: 3,791 rules total, and grepping for dnp3, modbus, s7comm, or bacnet returns nothing 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:

sigma check reports 0 errors and 0 condition errors across all six rules; the only issues are ICS ATT&CK tags 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:

Converting the control-source rule and the Modbus-scan correlation rule to Splunk SPL 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.pcap from post 3.
  • Rogue-host fixtures — crafted log lines where an unknown host 10.10.20.66 appears 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.

All seven rules report no match against the benign polling baseline 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.

On the real capture only DISABLE_UNSOLICITED fires; on the rogue fixtures the other five fire, including the scan correlation 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

This post is licensed under CC BY-NC-ND 4.0 by the author.