Snort IDS between PLC and HMI (Modbus)
This run-through is about how to use Snort to catch sketchy Modbus/TCP traffic in a small ICS lab.
Main goals:
- Catch non-Modbus junk on port
502 - Catch
Write Single Registerattempts (Function Code6) - Catch illegal values being written to a PLC register (traffic light logic)
Setup (quick)
- Snort config checked in
snort.lua HOME_NETset to my lab subnetEXTERNAL_NETset toany- Rules stored in
local.rules
Rule syntax check:
sudo snort -T -c /etc/snort/snort.lua
Start Snort in passive mode:
sudo snort -c /etc/snort/snort.lua -i eth0 --daq pcap --daq-mode passive -l /var/log/snort/
Passive mode keeps things safe and observable while testing.
Part 1A - Detect non-Modbus traffic on TCP/502
Rule:
alert tcp any any -> any 502 (msg:"Non-Modbus traffic on port 502 - Invalid Protocol ID"; byte_test:2,!=,0,2; sid:1000001; rev:1;)
- In Modbus/TCP, MBAP Protocol ID (bytes 2-3) should be
0x0000 - If it is non-zero, that payload is not valid Modbus
Test packet (crafted bad Protocol ID):
echo -e "\x00\x01\x00\x01\x00\x06\x01\x03\x00\x00\x00\x0A" | nc -w1 10.211.55.6 502
Result:
- Built-in Snort Modbus inspector alert fired
- Custom rule
sid:1000001also fired
That is a solid confirmation the rule is doing its job.
Part 1B - Detect Write Single Register (Function Code 6)
Rule:
alert tcp any any -> any 502 (msg:"Modbus Write Single Register - Function Code 6"; byte_test:1,=,6,7; sid:1000002; rev:1;)
- Byte
7of the Modbus frame is the function code - Value
6meansWrite Single Register
Test packet:
echo -e "\x00\x01\x00\x00\x00\x06\x01\x06\x00\x01\x00\xFF" | nc -w1 10.211.55.6 502
Result:
sid:1000002fired correctly
Part 1C - Same detection, cleaner rule (Modbus inspector)
Instead of raw byte offsets, I rewrote Part 1B using Snort's Modbus inspector keyword:
alert tcp any any -> any 502 (msg:"Modbus Write Single Register - Function Code 6"; modbus_func:6; sid:1000002; rev:1;)
Why this is nicer:
- Reads like intent, not byte math
- Easier to maintain
- Less likely to confuse future-you during tuning
Part 2 - Detect illegal values in a PLC register
Now the practical OT defense piece: detect writes that break process logic.
For a traffic light register with legal values 0-3, alert on anything greater than 3.
Final rule (targeting register 23 / %QW23):
alert tcp any any -> any 502 (msg:"Illegal value written to Modbus register 23 - traffic light"; modbus_func:6; byte_test:2,=,23,8; byte_test:2,>,3,10; sid:1000003; rev:1;)
What this checks:
modbus_func:6-> write single register onlybyte_test:2,=,23,8-> target register address 23byte_test:2,>,3,10-> written value must be greater than 3 to alert
Validation flow:
- Send legal value (for example
2) -> no alert - Send illegal value (for example
99) -> alert fires
Result:
- Snort fired only on illegal write activity
- No alert on legal writes
- Good signal quality for this lab scenario
Attack simulation note
I used a quick pymodbus write to push an illegal value into the PLC register.
That simulates a real ICS attack pattern: attacker injects out-of-range control values to mess with physical behavior.
Snort caught it immediately with sid:1000003.
Final ruleset
alert tcp any any -> any 502 (msg:"Non-Modbus traffic on port 502 - Invalid Protocol ID"; byte_test:2,!=,0,2; sid:1000001; rev:1;)
alert tcp any any -> any 502 (msg:"Modbus Write Single Register - Function Code 6"; modbus_func:6; sid:1000002; rev:1;)
alert tcp any any -> any 502 (msg:"Illegal value written to Modbus register 23 - traffic light"; modbus_func:6; byte_test:2,=,23,8; byte_test:2,>,3,10; sid:1000003; rev:1;)
Takeaways
- Start passive and verify visibility first
- Build rules in small chunks and validate each one
- Prefer protocol-aware keywords when possible (
modbus_func) - Write policy rules tied to process constraints, not just signatures
- Always test both positive and negative cases (alert + no-alert)
That is how you keep detections useful and avoid noisy IDS output in ICS labs.