Zeek vs Suricata: What Each One Actually Sees On An OT Network
Both sensors read the same mirror port and answer different questions. Plus the industrial parsers Suricata ships disabled, and the Zeek defaults that quietly limit what lands in your logs.
Put Zeek and Suricata on the same mirror port and they see identical packets.
They answer completely different questions.
Suricata asks whether this is known bad. Zeek asks what happened here.
In an OT network that difference decides whether you see an incident or not, and it is worth being precise about why.
Suricata Is A Rule Engine
A packet arrives, a rule matches or it does not, an alert fires.
That is the whole model, and it is a good one. Suricata is strong on known bad — CVE patterns, exploit attempts, scanning, anything somebody has already characterised and written a signature for.
It can also run inline as an IPS and drop traffic. Zeek cannot do that inline — it sits out of path, and the closest equivalent is asking a firewall to block after the fact through the NetControl framework.
The cost is the other half of the sentence. If nobody wrote a rule for it, there is no output at all.
The Config Trap: OT Parsers Are Off By Default
Worth checking on your own sensor before reading further.
In a stock suricata.yaml the industrial app-layer parsers ship disabled.
This is stable across versions. It is the upstream default in 6.0, 7.0, 8.0 and current development, and the Debian, Ubuntu and RHEL packages ship it the same way.
Change only the enabled line. Do not paste the block over your config — you would drop the detection-ports and the modbus stream-depth settings that ship alongside it, and stream depth matters here because Modbus connections stay open for weeks.
Now the part that is widely repeated and no longer true.
On Suricata 7 and later, rules using the modbus or dnp3 keywords do not silently fail — they are rejected at load time. You get an explicit error:
And suricata -T fails outright. The silent-failure version of this story was true on Suricata 6 and older, which is end of life. That is where the folklore comes from.
The practical failure mode today is not a silent miss, it is a sensor whose rules never loaded and an operator who did not read suricata.log.
Byte-offset rules load regardless of the parser setting. For Modbus TCP the function code sits at offset 7, because the MBAP header is transaction id two bytes, protocol id two, length two, unit id one.
Use the real variable names. $EXTERNAL_NET and $HOME_NET exist in a stock config, $OT_NET does not, and a rule referencing an undefined variable fails to load.
This is a fallback, not an equivalent. Modbus TCP pipelines several requests into one segment, and offset 7 only sees the first one — a write that arrives second in the same segment is invisible to it, and depending on where the stream boundary falls the same request can also alert twice. Enable the parser and use the keyword if you can.
Zeek Is A Protocol Analyzer
Zeek parses sessions and writes down what happened. In base mode it produces no verdict at all.
That caveat matters — the shipped local.zeek loads a set of detection scripts that do write notices, so a normal zeekctl deployment is not verdict-free. It is the OT protocol logs that carry no judgement.
For industrial traffic you get these without installing anything:
- conn.log — who talked to who, for how long, with which service
- modbus.log — ts, uid, id, tid, unit, func, pdu_type, exception
- dnp3.log — fc_request, fc_reply, and the IIN field
- weird.log — protocol anomalies nobody wrote a rule for
Two details about modbus.log that catch people. The func field is a name string such as WRITE_SINGLE_COIL, not a numeric code. And the log covers both requests and responses, distinguished by pdu_type.
One more worth knowing before you trust an empty log: Zeek's Modbus analyzer is bound to port 502 and has no dynamic protocol detection signature. Modbus on a non-standard port produces no modbus.log at all. DNP3 does ship a detection signature, so it is found off-port.
Modbus and DNP3 analyzers are part of Zeek base. S7comm is not — the maintained option there is the CISA package, github.com/cisagov/icsnpp-s7comm.
A Zeek log line does not say this is bad. It says this source wrote to that register at that time. Deciding whether that is normal is your job, and that is the honest division of labour.
Passive Asset Inventory, With Two Caveats
The known-hosts and known-services scripts give you every device that spoke and every port that answered, passively.
Two things the usual advice leaves out.
First, these are already loaded in a standard deployment — the shipped local.zeek includes both, and zeekctl loads local.zeek. If you run zeekctl you have them. You do not have them when reading a pcap with bare zeek -r.
Second, and more important in OT: the default only tracks local hosts, meaning addresses inside Site::local_nets, which is empty on a fresh install. To get what most people assume they are getting, load policy/tuning/track-all-assets.
Also know the limits before you treat the output as an asset list. Known-hosts requires a completed TCP handshake in both directions, so UDP-only and layer 2 industrial traffic — EtherNet/IP implicit I/O, GOOSE, PROFINET — never appears. And a host is logged once per day, not per session.
Within those limits it costs nothing and it tends to stay more current than an asset list maintained by hand.
Why OT Breaks The Signature Model
Most damage in an OT network is done with entirely valid commands.
Write Single Coil, function code 0x05, is not an exploit. DNP3 Cold Restart, function code 0x0D, is a documented function in the standard. The protocol is doing exactly what it was built to do.
There is no signature being violated, so Suricata stays quiet — and that is correct behaviour, not a failure.
Zeek does not raise an alarm either. What it does is record that a particular source wrote to a particular register at a particular time. Once you have a few weeks of that, a write arriving from an engineering workstation at 3am stops looking like ordinary traffic.
The verdict comes from context, not from the packet.
What To Actually Run
Neither tool is optional, and neither is sufficient.
- Suricata for known bad — exploits, scans, CVE patterns
- Zeek for baseline, asset picture, and the full session record
- A SIEM to join the two together
- Process state on top, to say whether the command actually moved anything
A signature engine without context misses valid commands. A log pipeline without a verdict gives you a lot of storage nobody reads.
Detection in OT is the point where network evidence and process reality meet. Neither half works alone.
Try It Yourself
Wazuh already runs as a free lab inside Labshock, connected to a live industrial environment. Zeek and Suricata are next in the same detection chain.
- The open-source part of Labshock — github.com/zakharb/labshock
- Start free in Loginward, the starter zone, no card needed
- Full platform and zone list — labshocksecurity.com
- Ask questions in the community — discord.gg/bpmaQFfW76
OT security must be testable. Not documented.
More on education
