alosafuzz
ICS/OT protocol security research — fuzzing, scanning, and coordinated disclosure.

A Researcher's Guide to Modbus/TCP

Vendor: cross · Sector: cross · Transport: modbus-tcp · Project: icsfuzzer

Part of the alosafuzz ICS protocol-guide series — written for fuzzing and vulnerability researchers. Defensive posture: wire format, fuzz surface, and bug classes only; no weaponized exploit or DoS code.


1. What it is & where it runs

Modbus is the oldest and most widely deployed industrial protocol — PLCs, RTUs, drives, meters, I/O blocks across every vertical. Modbus/TCP wraps the original serial PDU in a 7-byte MBAP header and runs over TCP port 502. It is governed by the Modbus Organization (there is no IETF RFC; the authoritative docs are the modbus.org spec V1.1b3 and the TCP/IP implementation guide).

For a researcher Modbus is deceptively simple — a 1-byte function code and a tiny data model — which is exactly why it rewards fuzzing: the protocol has no authentication, no encryption, no integrity (TCP carries no CRC), so every bug is reachable from any client, and the interesting failures are in how implementations handle malformed or boundary requests, gateway broadcast semantics, and length-field lies.

2. Session & transport model

No handshake — connect to 502 and send an ADU. Requests are correlated by a Transaction ID the server echoes, so multiple requests can be pipelined on one connection. Max ADU over TCP is 260 bytes (7 MBAP + 253 PDU).

3. Wire format — MBAP + PDU

MBAP header (7 bytes, big-endian): | Field | Bytes | Note | |—|—|—| | Transaction ID | 2 | echoed by server | | Protocol ID | 2 | always 0x0000 | | Length | 2 | byte count of Unit ID + PDU (≤ 0x00FF) | | Unit ID | 1 | serial slave addr; 0xFF ≈ “no slave” on TCP; 0x00 = broadcast on a TCP→serial gateway |

PDU: FunctionCode(1) | Data(0..252). Exception response: FC|0x80 | ExceptionCode.

Function-code ranges: 0x01–0x40 public (many unassigned), 0x41–0x48 and 0x64–0x6E user-defined, 0x80–0xFF exception space. Per-request limits are spec-defined (FC01/02 ≤ 2000 coils; FC03/04 ≤ 125 registers; FC0F ≤ 1968 coils; FC10 ≤ 123 registers; FC05 value must be exactly 0xFF00 or 0x0000). Exceeding a limit must return Exception 03.

4. The fuzz surface

Classification line: FC|0x80 exception responses are spec-compliant, not crashes. The crash signal is timeout/reset/no-response, a TID/proto/length mismatch in the reply, or a truncated response.

Structured vs. coverage-guided: Modbus is a textbook structured-generator target — the surface is semantic (FC dispatch, count/length validation, gateway broadcast) and shallow. A coverage-guided campaign is better spent on the embedded C stacks (libmodbus, FreeMODBUS, esp-modbus) where the memory-safety bugs live.

5. Known vulnerabilities & bug classes

Modbus’s durable, repeatedly-productive classes (the base protocol has no CVE of its own — the bugs are per-implementation):

6. Building a test harness

Robustness responder (scripts/modbus_responder.py, port 5020): returns Exception 0x02 for assigned FCs, 0x01 for unknown, one writable coil at 0x0001; validates proto-id, length (≤260), truncation; sidecar JSONL.

Harness lessons (these are the ones that bite):

Calibration baseline (software responder, 978 cases): clean — 71 crash indicators, all expected (46 connection_reset: bad proto_id/oversized/truncated; 19 timeout: starvation/truncation; 6 no_response), 0 malformed_response false positives after the multi-ADU TID fix. Live runs against esp-modbus / OpenPLC reproduced known per-stack behavior with no new false signal.

7. Open-source implementations & tooling

8. Spec references & sources

9. Open questions / under-explored surface

Worked example: protocols/modbus/fuzzer.py (9 categories, 978 cases) — the project’s original and largest module; responder scripts/modbus_responder.py. (Prior published prose: blog-post-ics-fuzzing.md.)