Module 18 — IoT and OT Hacking · Guided Lab Walkthrough¶
A step-by-step, do-it-in-order lab against your own environment only. Each step gives the command, what you should observe, a hint, and the defender/PAM takeaway. Outputs shown are representative — yours will differ. See
../../labs/and../../labs/topology.md.⛔ OT SAFETY — read this first. Never scan, connect to, or write to any real ICS/OT device, PLC, or IoT product you do not own on an isolated bench. A stray Modbus write or an aggressive scan can trip a safety system, damage equipment, or hurt people. Everything below targets your own loopback simulators, your own firmware images, and Shodan's index — which you query, never connect to. If you see a Shodan/Censys result, you look at the count and metadata only; you do not open a session to it.
Goal: feel why IoT/OT protocols are insecure by design — pub/sub with no auth, register read/write with no identity — then map each finding to the PAM control that contains it.
Targets: your own workstation only — 127.0.0.1 (loopback). No external host is contacted except Shodan's search API in Part A.
Prereqs: mosquitto + mosquitto-clients installed, Python with pip install pymodbus, binwalk installed, and (optional) a Shodan account/API key (shodan init <KEY>). A firmware image you legally own (e.g. from your own router vendor's download page) saved as firmware.bin.
💡 Prefer one command? Parts B and C are containerised in
../../labs/ot/—docker compose -f labs/ot/docker-compose.yml up -d --buildgives you the MQTT broker and Modbus simulator with the clients already inside the images (no host install). Come back here for Part A (Shodan) and the firmware carving in C3.
Part A — Passive exposure recon (query the INDEX, never connect)¶
A1. Count internet-exposed Modbus and MQTT — from the index only¶
shodan count 'port:502 product:Modbus' # how many exposed Modbus endpoints
shodan count 'port:1883' # how many exposed MQTT brokers
17432
289104
shodan count returns only the index tally; shodan search would add metadata — still index data, still no connection.
Hint if `shodan` isn't set up
Run `shodan initDefender/PAM view: these are devices that should never be internet-reachable. The control is segmentation + IEC 62443 zones/conduits — Modbus/MQTT belong deep in OT, reachable only through a brokered jump host in the IDMZ, never NATed to the edge.
Part B — Local MQTT lab: pub/sub with no identity¶
B1. Start your own broker¶
mosquitto -v # verbose broker on 127.0.0.1:1883 (leave running in terminal 1)
mosquitto version 2.x starting
Opening ipv4 listen socket on port 1883.
B2. Subscribe to every topic with the wildcard¶
# terminal 2
mosquitto_sub -h 127.0.0.1 -t '#' -v
lab/sensor/temp 21.4
lab/sensor/temp spoofed
# is the multi-level wildcard — one unauthenticated subscribe reads all traffic on the broker. This is passive interception with zero credentials.
B3. Publish a spoofed reading (missing-auth pub/sub abuse)¶
# terminal 3 — first a legit-looking value, then the forgery
mosquitto_pub -h 127.0.0.1 -t 'lab/sensor/temp' -m '21.4'
mosquitto_pub -h 127.0.0.1 -t 'lab/sensor/temp' -m 'spoofed'
lab/sensor/temp would accept spoofed as truth — this is OWASP "insecure network services" in miniature: no auth means anyone can both read and write the entire message bus.
Hint if the subscriber sees nothing
Confirm the broker is still running in terminal 1 and that all three commands use `-h 127.0.0.1`. If your distro ships a hardened `mosquitto.conf` with `allow_anonymous false`, that's the *fixed* state — temporarily test with a config that sets `allow_anonymous true` **on loopback only** to observe the insecure behavior, then revert.Defender/PAM view: require broker authentication + TLS (8883), and architecturally put the broker inside an OT zone reachable only via a brokered path. Detection: anonymous subscribes to #, or publishes from an unexpected client ID. PAM lever: vault the broker/service credential and rotate it (CyberArk CPM); no shared anonymous access.
Part C — Local Modbus lab + firmware secrets¶
C1. Start a Modbus simulator on loopback¶
# terminal 1 — CLI varies by pymodbus version
python -m pymodbus.server --host 127.0.0.1 --port 5020
Server(TCP) listening.
C2. Read holding registers (FC3), then write one (FC6 — simulator only)¶
# terminal 2
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)) # function code 3 — read
c.write_register(1, 42) # function code 6 — write (SIM ONLY)
print("FC3 after:", c.read_holding_registers(0, 10)) # confirm the change
c.close()
PY
FC3 read : ReadHoldingRegistersResponse (10 registers)
FC3 after: [0, 42, 0, 0, 0, 0, 0, 0, 0, 0]
write_register (FC6) altered process state. On a real PLC this same call could open a valve or stop a motor, which is why you only ever do this against a simulator.
Hint if the connection is refused
Confirm the server terminal shows "listening" and that client and server use the same port (`5020` here). If `python -m pymodbus.server` isn't available in your version, use the packaged `pymodbus.simulator` or a `StartTcpServer(...)` script — the FC3/FC6 client calls are unchanged.C3. Carve a firmware image you own for hardcoded secrets¶
binwalk firmware.bin # identify embedded filesystems/signatures
binwalk -e firmware.bin # extract the rootfs
grep -rniE 'password|api[_-]?key|BEGIN .*PRIVATE' _firmware.bin.extracted/
Squashfs filesystem, gzip), extract a rootfs, and grep surface any embedded secrets:
DECIMAL HEXADECIMAL DESCRIPTION
0x... ... Squashfs filesystem, little endian
_firmware.bin.extracted/squashfs-root/etc/shadow:root:$1$...
Hint if extraction is empty
You need a firmware image you legally own (download from your own device vendor). Some images are encrypted/packed — `binwalk` will show entropy near 1.0 and no filesystem; that's a finding in itself (the vendor obfuscated it), not a failure.Defender/PAM view: Modbus/firmware findings share one root cause — implicit trust. Controls: keep OT protocols on isolated, monitored segments (protocol-aware firewall denies write function codes from IT); require signed firmware + secure boot; and vault/rotate device credentials so a hardcoded secret in an image isn't a live key. Detection: unexpected FC5/6/15/16 writes, firmware hash/signature mismatch.
What you should conclude¶
Every "win" above exists because the protocol assumes a trusted, isolated network — so the entire defense is the network and the broker, not the protocol.
| You did | Why it worked | The control that stops it |
|---|---|---|
| Counted exposed Modbus/MQTT via Shodan index | Devices NATed to the internet | Segmentation + IEC 62443 zones/conduits; never edge-reachable |
Subscribed to # and injected on MQTT |
Broker allows anonymous read/write | Broker auth + TLS (8883); brokered access; vault the credential |
| FC3 read + FC6 write on Modbus | No auth/authorization/crypto in the protocol | IT/OT segmentation; deny writes from IT; PSM/PSMP jump host into OT |
| Grepped firmware for hardcoded creds | Secrets baked into the image | Signed firmware + secure boot; vault/rotate device creds |
PAM through-line: treat OT as crown-jewel Tier 0. No human or vendor touches L0/L1 directly — every path goes through a jump host in the IDMZ with JIT, MFA, approval, and full session recording. Map these to ../../defender-pam/.
Cleanup¶
# stop the broker (terminal 1) and Modbus server (terminal 1/C1) with Ctrl-C
rm -rf _firmware.bin.extracted/ # remove extracted firmware rootfs
# if you set allow_anonymous true to observe B, revert mosquitto.conf now
Record it¶
Log commands, outputs, and what surprised you in the My lab log table at the bottom of README.md, and note any misses in PROGRESS.md.