OTForge · Ironhorse Midstream, simulated natural gas gathering network
Catching a DNP3 Direct Operate with Suricata and Zeek
How a protocol aware Suricata rule detects an unauthorized compressor start command, what changes when one keyword flips it from alerting to blocking, and what that tradeoff costs on a live process network.
The environment and the attack
The network is a simulated natural gas gathering and compression system. A SCADA master at
10.200.20.10 polls three RTUs every five seconds with DNP3 function code 1 (Read).
Ordinary telemetry, nothing that changes state.
The attack came from a Kali host at 10.200.60.10 in the attacker subnet and targeted
10.200.10.11 (RTU-2) over TCP port 20000. RTU-2 controls the main gas compressor at
Compressor Station B, and Binary Output 0 on that RTU is wired to the compressor contactor relay,
so a write to that point can start the compressor or trip it offline.
The command was DNP3 function code 3, Direct Operate. DNP3 sorts function codes by intent: 1 is Read, 2 is Write, 3 is Direct Operate, 4 is Direct Operate No Ack. Function code 3 is the moment an attacker stops reading data and starts running the process.
Why it worked: DNP3 carries no authentication. The outstation cannot tell a forged master from a real one, so any host that can reach port 20000 can send it a command with no credentials. This was not a protocol exploit, it was a valid command from the wrong source.
What the sensors saw
Zeek does not alert; it logs. But the log was enough to reconstruct the entire attack after the fact, without any signature. A single DNP3 entry made the event stand out three ways at once: the source IP was the attacker subnet rather than the master, the function code was 3 instead of 1, and the traffic crossed from the attacker zone into the OT field zone, something normal polling never does.
Two rules, very different fidelity
Two Suricata rules fired on the same packet, and the difference between them is the whole lesson in alert quality.
SID 9000010 (preloaded) matches raw bytes, it looks for the DNP3 start bytes
0x05 0x64 and checks the link layer control byte. The problem: every valid DNP3 data
frame begins with those same start bytes, so it fires on all DNP3 traffic on port 20000,
including the routine five second polls.
SID 9100001 (the lab rule) works at the DNP3 application layer using the
dnp3_func:3 keyword, so Suricata parses the protocol and confirms the function code
really is Direct Operate before firing.
In a real deployment with dozens of RTUs polling every few seconds, the byte match rule would throw thousands of alerts an hour on normal traffic, and that fatigue would bury the one alert that matters. The application layer rule stays silent during normal polling and only speaks up when a real Direct Operate crosses the wire.
dnp3_func:3 application layer keyword to match only
Direct Operate from the attacker subnet.
IDS mode: seen, but not stopped
With only the alert rule active, re-running the attack fired both signatures, including the
custom message LAB03-ALERT Unauthorized DNP3 Direct Operate from attacker subnet.
The attack still succeeded. An IDS watches a copy of the traffic and never touches the real
stream, so the Direct Operate reached RTU-2 before the alert appeared. Detection tells you the
attack happened, but only after it happened.
IPS mode: one keyword changed
The reject rule (SID 9100002) is the identical rule with alert swapped for
reject. On the final run the attacker script never finished, it got a connection
refused and a TCP reset almost immediately, and the Direct Operate never reached RTU-2. Suricata
prioritizes reject over alert when multiple rules match, so the packet was dropped before
delivery. Zeek corroborated it: only a partial session record with no DNP3 application layer
data, because the reset killed the connection before the DNP3 layer came up.
LAB03-BLOCK and the reject action drops the
packet before it reaches RTU-2.
The availability tradeoff
Blocking capability comes with a new risk. An IPS sits inline, so if it fails or is misconfigured it can drop legitimate SCADA commands along with malicious ones, meaning the real master gets cut off from the RTUs, and a compressor or emergency shutdown valve stops answering commands it should obey. On an ICS the entire point is that the process keeps running, and the attacker's goal is usually to disrupt availability rather than steal data. A defensive tool that can cause an outage by itself is a genuine concern, which is why OT operators have historically preferred passive detection.
The ways operators manage that inline risk, and how this rule reflects them:
- Scope the rule so narrowly that it only blocks traffic which could never be legitimate, here, Direct Operate originating outside the master's subnet.
- Test against normal polling before trusting it in blocking mode.
- Keep a resiliency plan so the process can fall back to local control with an operator present if the network control path is lost.
Defense recommendations
A single rule is not a security program. DNP3 cannot defend itself, so defense has to be layered around the protocol.
Network segmentation
The attacker reached RTU-2 because a misconfigured firewall rule allowed traffic from the attacker zone into the OT field zone. Keeping the field network in its own segment and allowing only the SCADA master's subnet to reach port 20000 means the packet never arrives and the IPS rule never has to fire. Segmentation removes the path instead of catching the attack in progress, and it doesn't depend on any single rule being correct.
Protocol whitelisting
Inside the OT zone, allow only the known good pattern, Read polls from the master to the three RTUs, and flag everything else. Since the attack is a valid command from the wrong source rather than a protocol bug, enumerating legitimate sources and function codes is the only reliable way to catch it. A whitelist also scales as the network grows, where a blacklist of known attacks would not.
What I took from it
Detection and prevention are not the same thing, and the gap between them is operationally significant. Both sensors saw the attack clearly in IDS mode and the compressor command still went through. One keyword changed the outcome entirely, and the price of that blocking power is that the same inline tool could just as easily block the real master if it failed.
The attack also needed remarkably little. No exploit, no trick: a normal DNP3 session and a clean Direct Operate. Because the protocol carries no authentication, the outstation had no way to refuse. When a protocol cannot protect its own messages, security has to wrap around it: segmentation to keep untrusted hosts away from field devices, application layer inspection to catch what slips through, and forensic logging to reconstruct what happened when it does.
Lab write up from industrial control systems security coursework at UTSA. All addresses and devices belong to a simulated training environment.