Modbus was published in 1979, for serial links, by a company making programmable controllers for General Motors. It has no authentication, no encryption, no session concept and no integrity checking beyond a link-layer checksum. It is also, more than four decades later, one of the most widely deployed industrial protocols in the world.
That combination is the whole reason it matters to anyone doing OT security. The protocol is not badly designed – it is superbly designed for the problem it was given, which was moving register values between a controller and a handful of devices over a wire nobody else could physically touch. Every security property people complain about today is a consequence of that original scope, and of the decision in 1999 to wrap the same message format in TCP and put it on Ethernet without changing anything else.
If you can read Modbus/TCP at the packet level, a great deal follows. You can tell a monitoring system from a control system. You can identify which host is capable of starting a pump. You can spot a device that has begun doing something it never did before. This article covers the protocol in enough depth to do all three.
It uses the environment from How to Build a Safe OT Cybersecurity Lab, and picks up from Passive OT Asset Discovery with Wireshark, which used function codes to classify devices without explaining where they come from. This is that explanation.
Safety classification: Green. Everything here is analysis of captured traffic. Nothing transmits. The one exception is the lab exercise at the end, which writes to a simulated PLC on an isolated segment – never do the equivalent on a live network.
The data model: four tables and nothing else
Modbus has no concept of a variable, a tag, a file or an object. A device exposes exactly four tables, and every operation reads or writes cells in one of them.
Coils are single bits that a master can read and write. Physically they correspond to digital outputs – a relay, a solenoid, a pump start command. Discrete inputs are single bits that a master can only read, corresponding to digital inputs such as a limit switch or a pressure switch. Holding registers are sixteen-bit words that can be read and written, used for setpoints, analogue outputs and general-purpose values. Input registers are sixteen-bit words that can only be read, typically carrying live measurements from sensors.
The read-only tables are read-only by convention within the protocol, not by enforcement. The controller program decides what any given address actually does, and nothing in the protocol prevents a device from mapping a writeable coil to something that should never have been writeable remotely.
Everything about the protocol falls out of this model. There is no way to ask a device for a list of its addresses, no way to discover what address 40001 means, and no way to know whether writing to it opens a valve or updates a display label. That knowledge lives in a spreadsheet somebody made during commissioning, and the security implications of that are considerable.
Frame structure on the wire
A serial Modbus frame carries a slave address, a function code, data, and a CRC. Modbus/TCP keeps the middle two, drops the CRC because TCP already provides integrity, and prefixes a seven-byte header called the MBAP – the Modbus Application Protocol header.
The MBAP header contains a two-byte transaction identifier, echoed back by the slave so a master can match responses to requests when several are outstanding. Then a two-byte protocol identifier, which is always zero for Modbus. Then a two-byte length field counting the remaining bytes. Then a single-byte unit identifier, which is the old serial slave address, retained mostly for gateways that bridge TCP to serial segments. On a native TCP device it is usually 1, or ignored entirely.
After the header comes the PDU: one byte of function code, followed by the data.
A read of two holding registers starting at address zero looks like this on the wire:
00 01 transaction id
00 00 protocol id (always zero for Modbus)
00 06 length: six bytes follow
01 unit id
03 function code 3, read holding registers
00 00 starting address
00 02 quantity of registers
And the response:
00 01 transaction id, echoed
00 00 protocol id
00 07 length
01 unit id
03 function code 3, echoed
04 byte count
01 90 register 0 = 400
00 D7 register 1 = 215
Twelve bytes out, thirteen back. There is nothing else in there – no identity, no credential, no sequence number that means anything, no timestamp. The transaction identifier is the only field resembling state, and it exists purely so the master can correlate its own outstanding requests.
Two consequences follow immediately. Anything that can complete a TCP handshake on port 502 and emit those twelve bytes will get an answer, because the device has no basis on which to refuse. And the entire exchange is trivially readable, forgeable and replayable by anything sitting on the path.
Function codes, and the line that matters
There are dozens of defined function codes, but a handful account for almost all real traffic. What matters for security is not memorising them, but internalising which side of one line each falls on.
Reading is done with function code 1 for coils, 2 for discrete inputs, 3 for holding registers and 4 for input registers. Of these, code 3 dominates most captures by a wide margin, because holding registers are where process values usually live.
Writing is done with function code 5 to set a single coil, 6 to write a single register, 15 to write multiple coils and 16 to write multiple registers. Code 16 is common because it lets a master push a block of setpoints in one exchange.
Function code 43 is the outlier worth knowing: with MEI type 14 it is Read Device Identification, and it returns vendor name, product code and revision as plain strings. It is the closest thing Modbus has to a banner, and where a master already issues it, the response hands you inventory data for free.
The line to internalise runs between 1-4 and 5, 6, 15, 16. Reads observe the process. Writes change it. A master that only ever issues reads is a monitoring system – a historian, a dashboard, a trend collector. A master that issues writes has control authority, which in physical terms means it can start a pump, open a valve or change a setpoint.
That single distinction does more work than any other piece of Modbus knowledge. It determines which firewall rules are actually required, which hosts belong in which zone, and what an unauthorised command looks like when you eventually need to recognise one.
The address confusion that catches everybody
This is the part that wastes an afternoon of your life, once, and then never again.
Traditional Modbus documentation numbers addresses from one and encodes the table into a leading digit. Coils are 00001 upward, discrete inputs 10001 upward, input registers 30001 upward, and holding registers 40001 upward. That last one is why engineers habitually say “40001” when they mean the first holding register.
The protocol on the wire does none of this. It numbers from zero, and the table is identified by the function code rather than the address. So holding register 40001 in the documentation is address 0 in the packet. Holding register 40100 is address 99.
The rule is to subtract the leading digit and then subtract one. Wireshark shows you the raw wire value, so a capture referencing address 0 corresponds to what a site engineer will call 40001, and the drawing on their wall will say 40001 too. Mixing the two conventions in a report is a fast way to lose credibility with the people who run the plant.
Some vendors add their own offset on top of this. When a value does not line up, check the device documentation before assuming the capture is wrong.
Exceptions, and why they are interesting
When a slave cannot service a request it responds with the original function code plus 0x80, followed by a one-byte exception code. Function code 3 becomes 0x83; function code 16 becomes 0x90.
Exception code 1 means illegal function – the device does not support that operation at all. Code 2 means illegal data address, so the register requested does not exist on this device. Code 3 means illegal data value. Code 4 signals a device failure during processing, and codes 5 and 6 indicate the device is busy or still working on something.
For security work, exceptions are more useful than they first appear. A burst of exception code 2 responses is what enumeration looks like from the slave’s side – something is walking through an address range discovering what exists. A sudden rise in exception code 1 suggests a client trying function codes the device does not implement, which is not normal behaviour for a configured HMI that has been polling the same registers for two years. A well-behaved master produces almost no exceptions at all, because its register map was set correctly at commissioning.
Filter for them directly:
modbus.exception_code
Anything appearing there is worth a look, if only to understand why a production system is generating errors nobody has noticed.
Reading a capture in practice
Open a capture from the lab, or any file containing port 502 traffic, and start with the display filter that isolates the protocol:
mbtcp
Wireshark dissects Modbus automatically on port 502. If a site runs it on a non-standard port, use Decode As to point the Modbus dissector at that port, otherwise you will see raw TCP and conclude there is nothing there.
Separate the two directions to establish roles:
mbtcp && tcp.dstport == 502 requests, so the source is a master
mbtcp && tcp.srcport == 502 responses, so the source is a slave
Then break the traffic down by what it actually does:
modbus.func_code == 3 reads of holding registers
modbus.func_code in {5 6 15 16} writes - the process-changing operations
modbus.func_code == 43 device identification
modbus.exception_code errors
To turn a capture into a summary rather than a scroll, extract the relationships and function codes together:
tshark -r capture.pcap -Y "modbus" -T fields \
-e ip.src -e ip.dst -e modbus.func_code \
| sort | uniq -c | sort -rn
That output is the protocol equivalent of a site survey. Each line says which host asked which host to do what, and how often.
Narrow it to writes alone, with timestamps, and you have a list of every moment the process was commanded to change:
tshark -r capture.pcap \
-Y "modbus.func_code in {5 6 15 16}" -T fields \
-e frame.time -e ip.src -e ip.dst \
-e modbus.reference_num -e modbus.func_code
On a healthy network this list is short, comes from one or two known hosts, and correlates with operator activity. Anything else on it is a finding.
Watching it happen in the lab
Reading about the absence of authentication is one thing. Watching a single command change a physical process is more instructive.
In the lab, start a capture on the monitoring interface, then read the tank level from the OpenPLC simulator:
mbpoll -a 1 -t 4 -r 1 -c 2 192.168.30.20
Now issue a write to coil 0, the pump start command:
mbpoll -a 1 -t 0 -r 1 192.168.30.20 1
In Wireshark, filter for modbus.func_code == 5 and look at what the exchange contains. A twelve-byte request. No credential, no challenge, no negotiation. The device complies because compliance is the only behaviour it knows, and in the capture the pump status coil flips and the tank level begins to climb.
That is the argument for OT segmentation compressed into a single packet. The controller is not vulnerable in the sense of having a flaw – it is doing exactly what it was designed to do. The only control that stands between an arbitrary host and that coil is whether the network permits the connection in the first place.
Run the same write from the test workstation on 192.168.10.50 and it should fail, blocked by the firewall, with the denied attempt appearing in the logs. If it succeeds, you have found something rather more interesting than a protocol lesson.
What this means for defending a Modbus network
Because the protocol offers no security properties of its own, everything protective happens around it, and knowing the packet format tells you exactly where to put your effort.
Network segmentation is not one control among several – it is the primary control. If a host cannot reach TCP/502, the absence of authentication is irrelevant. If it can, the absence of authentication is total. There is no middle ground and no compensating control at the protocol layer.
Detection focuses on the write function codes. Since the set of hosts legitimately issuing codes 5, 6, 15 and 16 is small, stable and knowable, an alert on writes from anything outside that set is both high-fidelity and cheap to maintain. The same applies to reads of registers no configured master has ever asked for.
Polling regularity is a defender’s advantage that IT networks rarely offer. Modbus masters poll on fixed cycles, so the traffic is close to deterministic. A change in rate, a new source address, or an unfamiliar function code stands out against that baseline in a way that would be invisible in ordinary enterprise traffic. That is what makes an OT baseline worth building, and it is what the next article covers.
Where the protocol genuinely must cross a boundary, the answer is to terminate it. A data diode, a gateway that translates to a read-only feed, or a broker that exposes process values over an authenticated protocol will all work. Tunnelling Modbus over a VPN protects it in transit but does nothing about the fact that whatever emerges at the far end can still write to a coil.
What comes next
With the protocol readable, three things follow naturally: building a full OT traffic baseline, detecting unauthorised Modbus commands against that baseline, and testing whether your segmentation actually prevents the reachability this protocol assumes away.
The last of those is the one most worth doing on a real network, because every argument in this article reduces to a single question: who can open a TCP connection to port 502?
Who can reach port 502 in your environment?
Modbus assumes the network is trustworthy. Most industrial networks have accumulated exceptions that quietly undermine that assumption – a vendor remote-access appliance, a dual-homed workstation, a firewall rule that outlived the project it was written for.
CyberLabs OT security assessments examine network segmentation, remote-access pathways, industrial device exposure and monitoring controls, while accounting for operational availability and recovery requirements.