// 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

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.