// HackTricks · Network Services

502/tcp - Pentesting Modbus Protocol

502/tcp - Pentesting Modbus Protocol

Basic Information

Modicon introduced Modbus in 1979 for communication among industrial devices. Current specifications use client/server terminology, although many tools and deployed serial systems still say master/slave.[3]

Default Modbus/TCP port: 502. The separate Modbus Security profile wraps Modbus in TLS with X.509 authentication and uses port 802; do not assume every Modbus deployment is necessarily plaintext.[3][4]

PORT    STATE SERVICE
502/tcp open  modbus

Modbus TCP carries a seven-byte MBAP header followed by the Modbus PDU:[3]

  • Transaction ID: matches requests and responses
  • Protocol ID: normally 0x0000
  • Length: remaining bytes in the frame
  • Unit ID: important when a Modbus/TCP gateway forwards traffic to serial/RTU devices
  • Function Code + Data: the actual read/write/diagnostic operation

The Unit ID is frequently ignored by native Modbus/TCP devices, but it becomes critical when the target is a TCP-to-RTU gateway. In that case, you usually need to enumerate multiple unit/slave IDs to reach the device behind the bridge.

Legacy Modbus/TCP traffic on port 502 is typically plaintext and unauthenticated, so passive captures often reveal:

  • The in-use unit/slave IDs
  • Which function codes the process actually accepts
  • The register map being polled by the HMI/SCADA server
  • Whether writes are rare enough that replaying one stands out immediately

Enumeration

Automatic

nmap -sV --script modbus-discover -p 502 <IP>
nmap --script modbus-discover --script-args='modbus-discover.aggressive=true' -p 502 <IP>
msf> use auxiliary/scanner/scada/modbusdetect
msf> use auxiliary/scanner/scada/modbus_findunitid

modbus-discover is useful because it tries to enumerate legal slave IDs and extract device identification data such as vendor and firmware strings.[1]

Passive Recon

If you can sniff traffic, extract the parameters you need before sending anything intrusive:

tshark -r modbus.pcap -Y modbus \
  -T fields -e ip.src -e ip.dst -e modbus.unit_id \
  -e modbus.func_code -e modbus.reference_num -e modbus.word_cnt

Useful Wireshark display filters:

modbus
modbus.func_code == 3
modbus.func_code == 16
modbus.exception_code

Manual Enumeration with PyModbus

python3 -m pip install pymodbus
from pymodbus.client import ModbusTcpClient

host = "10.10.10.10"
unit = 1
client = ModbusTcpClient(host, port=502)
client.connect()

print(client.read_coils(address=0, count=16, device_id=unit))
print(client.read_holding_registers(address=0, count=10, device_id=unit))
print(client.read_device_information(device_id=unit))

client.close()

The current PyModbus client uses zero-based protocol addresses and names the unit argument device_id; older releases used slave or unit, so match the example to the installed version.[5]

Notes:

  • Addressing is a common pitfall: many operators document holding register 400001, while libraries expect the zero-based offset (0).
  • When crossing a gateway, keep the same TCP endpoint and iterate the Unit ID.
  • Some servers expose only a subset of function codes and return Modbus exception responses for unsupported ones.

Offensive Tooling

git clone https://github.com/TacticalGator/modbuster
cd modbuster && pipx install .
modbuster getfunctions <IP>
modbuster read -s 1 <IP> 400001 10
modbuster diag -s 1 <IP>

Other useful frameworks/tools:[6]

  • smod: function-code enumeration, UID brute force, fuzzing modules
  • scapy.contrib.modbus: packet crafting for unsupported or custom workflows

Interesting Function Codes

The following standardized function codes and the 0x2B/0x0E device-identification MEI are defined in the Modbus application protocol specification.[3]

Read / Recon

  • 0x01 / 0x02: read coils / discrete inputs
  • 0x03 / 0x04: read holding / input registers
  • 0x11: report server ID
  • 0x2B/0x0E: Read Device Identification (vendor, product code, revision, optional objects)
  • 0x08: diagnostics, especially interesting on serial devices and some gateways

Write / Impact

These are the first function codes to validate carefully during authorized testing because they can directly alter process state:

  • 0x05: write single coil
  • 0x06: write single holding register
  • 0x0F: write multiple coils
  • 0x10: write multiple holding registers
  • 0x16: mask write register
  • 0x17: read/write multiple registers

Less Common but Worth Testing

These are not universally implemented, but when present they are valuable because defenders often monitor them less:

  • 0x14: read file record
  • 0x15: write file record
  • 0x18: read FIFO queue

A practical workflow is:

  1. Find the valid Unit ID.
  2. Verify read-only access with 0x01-0x04.
  3. Pull device identification with 0x2B/0x0E.
  4. Enumerate supported function codes.
  5. Only then validate whether write functions are accepted.

Scapy Packet Crafting

Scapy ships Modbus packet definitions for common read/write primitives and Read Device Identification.[6]

from scapy.contrib.modbus import ModbusADURequest, ModbusPDU2B0EReadDeviceIdentificationRequest
from scapy.all import sr1

pkt = ModbusADURequest(transId=1, unitId=1) / ModbusPDU2B0EReadDeviceIdentificationRequest()
resp = sr1(pkt, timeout=2)
if resp:
    resp.show()

This is useful when you need to:

  • Replay a packet seen in a PCAP with only minimal edits
  • Test uncommon function codes not exposed cleanly by higher-level clients
  • Validate how a target reacts to malformed length/function combinations during lab work

What Usually Leads to Impact

In real assessments, attackers rarely start with memory corruption. The usual path is:

  • Sniff or enumerate the correct Unit ID and register ranges
  • Learn the meaning of the values from traffic patterns or vendor docs
  • Replay or slightly modify legitimate write requests
  • Abuse weak process assumptions such as “this register is only written by the HMI”

Recent Modbus research and datasets keep emphasizing the same attacker behaviors: query flooding, false data injection, brute-force writes, replay, and malformed length/frame handling. Those are usually more realistic test cases than hunting for a single vendor-specific CVE.[2]

References