A deliberately vulnerable multi-protocol OT/ICS security training lab.
Simulates a 115 kV Electrical Substation over Modbus TCP + DNP3 with a live SCADA HMI, Raspberry Pi GPIO LED indicators, and purpose-built pentesting toolkits.
This lab is built for authorized educational use only.
All components are intentionally vulnerable. Never deploy this on a production network or use these techniques against systems you don't own.
Unauthorized access to ICS/OT systems is a criminal offense.
pi-ot-lab/
├── plc_simulator.py ← Modbus TCP PLC simulator (port 5020)
├── dnp3_simulator.py ← DNP3 outstation + Modbus bridge (port 20000)
├── hmi_server.py ← Flask REST API + HMI backend (port 8080)
├── index.html ← Industrial SCADA web dashboard (Modbus + DNP3 tabs)
├── modisy.py ← Modbus TCP pentesting toolkit (15 commands)
├── dnp3sy.py ← DNP3 pentesting toolkit (14 commands)
├── lab_launcher.py ← One-command launcher for all services
├── TESTING_COMMANDS.md ← Complete command reference (all attacks + recovery)
├── OT_Lab_Manual.tex ← Comprehensive LaTeX lab manual (source)
└── graphify-out/ ← Knowledge graph output (HTML + JSON + report)
| Component | Description | Port |
|---|---|---|
| Modbus PLC | Full Modbus TCP server with live process simulation | 5020 |
| DNP3 Outstation | DNP3 simulator with Modbus bridge — bidirectional cross-protocol sync | 20000 |
| HMI Dashboard | Industrial SCADA web interface with dual-protocol view | 8080 |
| modisy | Modbus pentest toolkit — 15 attack modules, zero dependencies | CLI |
| dnp3sy | DNP3 pentest toolkit — 14 attack modules, zero dependencies | CLI |
You are assessing a 115 kV Electrical Substation SCADA system.
The substation runs on a Raspberry Pi connected to physical LEDs representing circuit breaker states. It serves two industrial protocols simultaneously — Modbus TCP and DNP3 — controlling circuit breakers, transformer taps, capacitor banks, and protection relays across a dual-bus topology.
You attack from your laptop/desktop over the network. Both protocols are unauthenticated and bidirectionally synced — a change made via Modbus appears in DNP3 within 1 second, and vice versa. Every attack reflects live on the HMI dashboard and on the physical GPIO LEDs on the Pi.
┌─────────────────────────┐ ┌──────────────────────────────────┐
│ ATTACKER (Your PC) │ │ TARGET (Raspberry Pi) │
│ │ WiFi/ │ │
│ modisy.py ───────────────────► │ plc_simulator.py :5020 │
│ dnp3sy.py ───────────────────► │ dnp3_simulator.py :20000 │
│ Browser ───────────────────► │ hmi_server.py :8080 │
│ curl / nmap / Wireshark│ LAN │ │
│ │ │ GPIO LEDs: 🟢🟢🟡🟡🔴🔵 │
└─────────────────────────┘ └──────────────────────────────────┘
- Python 3.8+
- Flask
pip install flaskNo external protocol libraries needed. All simulators and toolkits use raw sockets.
SSH into the Pi and clone the repo:
ssh pi@<PI_IP>
git clone https://github.com/positromen/pi-ot-lab.git
cd pi-ot-lab
pip install flaskLaunch the lab on the Pi:
python3 lab_launcher.pyThis starts all three services on the Pi:
- Modbus PLC on port 5020 (with GPIO LED control)
- DNP3 Outstation on port 20000 (auto-syncs with Modbus PLC)
- HMI Dashboard on http://<PI_IP>:8080
On your laptop/desktop, clone the same repo (you only need the attack scripts):
git clone https://github.com/positromen/pi-ot-lab.git
cd pi-ot-labOpen the HMI dashboard in your browser:
http://<PI_IP>:8080
Run attacks targeting the Pi:
python modisy.py -H <PI_IP> -p 5020 dump # Dump all registers
python modisy.py -H <PI_IP> -p 5020 blackout # Open all breakers — LEDs go OFF
python dnp3sy.py -H <PI_IP> -p 20000 trip # Emergency trip — red alarm LED ONReplace
<PI_IP>with your Pi's IP address (e.g.192.168.1.76)
Quick reference: See
TESTING_COMMANDS.mdfor every attack and recovery command with localhost and Pi variants, all in one place.
Full documentation: See
OT_Lab_Manual.texfor complete register maps, attack walkthroughs, process simulation details, and defensive recommendations.
The DNP3 outstation includes a ModbusBridge that creates bidirectional data flow:
| Direction | Mechanism | Latency |
|---|---|---|
| Modbus → DNP3 | DNP3 polls Modbus PLC every 1s, mirrors coils/DI/IR/HR | ~1s |
| DNP3 → Modbus | CROB/analog writes forwarded to Modbus PLC in real time | Immediate |
| Fallback | If Modbus PLC unreachable, DNP3 runs local simulation | Automatic |
This means: modisy.py -H <PI_IP> -p 5020 blackout opens all breakers visible in DNP3 and turns off all GPIO LEDs, and dnp3sy.py -H <PI_IP> -p 20000 trip triggers ESTOP visible in Modbus and lights the red alarm LED on the Pi.
| Address | Name | Default | Impact if Written | GPIO |
|---|---|---|---|---|
0x00 |
CB_BUS1 |
ON | Bus 1 de-energized | GPIO17 (Green) |
0x01 |
CB_BUS2 |
ON | Bus 2 de-energized | — |
0x02 |
CB_FEEDER1 |
ON | Feeder 1 load dropped | GPIO27 (Green) |
0x03 |
CB_FEEDER2 |
ON | Feeder 2 load dropped | GPIO22 (Yellow) |
0x04 |
CB_FEEDER3 |
ON | Feeder 3 load dropped | GPIO10 (Yellow) |
0x05 |
CB_TIE |
OFF | Bus tie breaker | — |
0x06 |
ALARM_ACK |
OFF | Acknowledge alarm | — |
0x07 |
EMERGENCY_TRIP |
OFF | All breakers trip instantly | GPIO9 (Red) |
0x08 |
CAP_BANK |
OFF | Capacitor bank switching | GPIO11 (Blue) |
0x09 |
XFMR_TAP_UP |
OFF | Transformer tap raise | — |
0x0A |
XFMR_TAP_DN |
OFF | Transformer tap lower | — |
0x0B |
AUTO_RECLOSE |
ON | Automatic recloser | — |
| Address | Name | Unit | Description |
|---|---|---|---|
3x00 |
BUS1_VOLTAGE |
kV (×10) | Bus 1 voltage |
3x01 |
BUS2_VOLTAGE |
kV (×10) | Bus 2 voltage |
3x02 |
FREQUENCY |
Hz (×100) | System frequency |
3x03 |
FEEDER1_CURRENT |
A (×10) | Feeder 1 load |
3x04 |
FEEDER2_CURRENT |
A (×10) | Feeder 2 load |
3x05 |
FEEDER3_CURRENT |
A (×10) | Feeder 3 load |
3x06 |
ACTIVE_POWER |
MW (×10) | Total active power |
3x07 |
REACTIVE_POWER |
MVAr (×100) | Reactive power |
3x08 |
POWER_FACTOR |
×1000 | Power factor |
3x09 |
XFMR_TEMP |
°C (×10) | Transformer temperature |
3x10 |
ENERGY_MWH |
MWh | Accumulated energy |
3x11 |
LOAD_PERCENT |
% | Total load percentage |
3x12 |
TAP_POSITION |
pos | Transformer tap position |
3x13 |
FAULT_CURRENT |
A (×10) | Fault current magnitude |
3x14 |
THD_PERCENT |
% (×10) | Total harmonic distortion |
| Address | Name | Default | Notes |
|---|---|---|---|
4x00 |
V_SETPOINT |
1150 | Voltage setpoint (V×10) |
4x01 |
FREQ_NOMINAL |
6000 | Nominal frequency (Hz×100) |
4x02 |
OVER_V_TRIP |
1265 | Over-voltage trip (V×10) |
4x03 |
UNDER_V_TRIP |
1035 | Under-voltage trip (V×10) |
4x04 |
OVER_FREQ_TRIP |
6180 | Over-frequency trip (Hz×100) |
4x05 |
UNDER_FREQ_TRIP |
5820 | Under-frequency trip (Hz×100) |
4x06 |
OVERCURRENT_TRIP |
3000 | Overcurrent trip (A×10) |
4x07 |
LOAD_SHED_THRESH |
90 | Load shedding threshold (%) |
4x08 |
XFMR_TEMP_ALARM |
850 | Transformer temp alarm (°C×10) |
4x09 |
SCADA_AUTH_CODE |
0xBEEF |
Plaintext credential in register |
4x10 |
SYSTEM_MODE |
0 | 0=Normal 1=Maintenance 2=Emergency |
4x11 |
PROTECTION_MODE |
1 | 0=Disabled 1=Enabled |
4x12 |
RECLOSE_DELAY |
5 | Auto-reclose delay (seconds) |
4x13 |
UNIT_ID |
1 | Modbus unit identifier |
| Index | Name | Description |
|---|---|---|
| 0 | BUS1_ENERGIZED |
Bus 1 energized status |
| 1 | BUS2_ENERGIZED |
Bus 2 energized status |
| 2 | XFMR_OK |
Transformer health |
| 3 | PROTECTION_OK |
Protection system status |
| 4 | PANEL_CLOSED |
Panel door status |
| 5–10 | CB_xxx_STATUS |
Individual breaker feedback |
| 11 | ALARM_ACTIVE |
Active alarm indication |
| Index | Name | Default |
|---|---|---|
| 0 | CB_BUS1 |
ON (Closed) |
| 1 | CB_BUS2 |
ON (Closed) |
| 2 | CB_FEEDER1 |
ON (Closed) |
| 3 | CB_FEEDER2 |
ON (Closed) |
| 4 | CB_FEEDER3 |
ON (Closed) |
| 5 | CB_TIE |
OFF (Open) |
| 6 | ALARM_ACK |
OFF |
| 7 | EMERGENCY_TRIP |
OFF |
| 8 | CAP_BANK |
OFF |
| 9 | XFMR_TAP_UP |
OFF |
| 10 | XFMR_TAP_DN |
OFF |
| 11 | AUTO_RECLOSE |
ON |
Mirrors Modbus input registers (15 points): Bus voltages, frequency, feeder currents, power, XFMR temp, etc.
Mirrors Modbus holding registers (14 points):
| Index | Name | Default |
|---|---|---|
| 0 | V_SETPOINT |
1150 |
| 1 | FREQ_NOMINAL |
6000 |
| 9 | SCADA_AUTH_CODE |
0xBEEF |
| 10 | SYSTEM_MODE |
0 |
DNP3 Outstation Address: 10 · Port: 20000
Run from your attacker machine, targeting the Pi
python modisy.py -H <PI_IP> -p 5020 scan # Discover devices
python modisy.py -H <PI_IP> -p 5020 dump # Full memory dump (coils + IR + HR)
python modisy.py -H <PI_IP> -p 5020 enum # Device fingerprint (FC11/FC43)
python modisy.py -H <PI_IP> -p 5020 read-secret # Extract HR[9] SCADA auth code
python modisy.py -H <PI_IP> -p 5020 coil-write 0 0 # Open CB Bus 1 → GPIO17 LED OFF
python modisy.py -H <PI_IP> -p 5020 reg-write 9 0xBEEF # Write auth code
python modisy.py -H <PI_IP> -p 5020 bulk-write 0 9999,5000,1265,1035,5100 # Mass override
python modisy.py -H <PI_IP> -p 5020 estop # Emergency trip (coil 7) → Red LED ON
python modisy.py -H <PI_IP> -p 5020 blackout # Open ALL 6 breakers → All LEDs OFF
python modisy.py -H <PI_IP> -p 5020 trip # E-stop + open all → Red LED ON
python modisy.py -H <PI_IP> -p 5020 flip 2 # Toggle CB Feeder 1 → GPIO27 LED toggles
python modisy.py -H <PI_IP> -p 5020 fuzz -v # Fuzz all function codes
python modisy.py -H <PI_IP> -p 5020 flood --count 2000 # DoS test
python modisy.py -H <PI_IP> -p 5020 replay 0105000700ff00 # Raw PDU replayRun from your attacker machine, targeting the Pi
python dnp3sy.py -H <PI_IP> -p 20000 scan # Discover outstations
python dnp3sy.py -H <PI_IP> -p 20000 dump # Read all data groups
python dnp3sy.py -H <PI_IP> -p 20000 enum # Enumerate properties
python dnp3sy.py -H <PI_IP> -p 20000 read-secret # Extract AO[9] auth code
python dnp3sy.py -H <PI_IP> -p 20000 direct-operate 0 trip # Open CB Bus 1 (no SBO!)
python dnp3sy.py -H <PI_IP> -p 20000 direct-operate 7 close # Engage E-stop via CROB
python dnp3sy.py -H <PI_IP> -p 20000 write-analog 0 0 # Zero voltage setpoint
python dnp3sy.py -H <PI_IP> -p 20000 blackout # Open ALL breakers via CROB
python dnp3sy.py -H <PI_IP> -p 20000 trip # E-stop + open all breakers
python dnp3sy.py -H <PI_IP> -p 20000 flip 2 # Toggle CB Feeder 1
python dnp3sy.py -H <PI_IP> -p 20000 cold-restart # Restart outstation
python dnp3sy.py -H <PI_IP> -p 20000 fuzz -v # Fuzz function codes
python dnp3sy.py -H <PI_IP> -p 20000 flood --count 500 # DoS test| ID | Description |
|---|---|
| V-001 | No authentication on Modbus TCP |
| V-002 | No input validation on writes |
| V-003 | All function codes enabled — no whitelist |
| V-004 | FC11 / FC43 disclose firmware and vendor info |
| V-005 | No rate limiting |
| V-006 | FC16 bulk write allows mass process override |
| V-007 | Exception responses reveal address structure |
| V-008 | No write audit logging at PLC level |
| ID | Description |
|---|---|
| V-DNP-001 | No Secure Authentication |
| V-DNP-002 | Direct Operate accepted without Select |
| V-DNP-003 | Cold/Warm restart commands accepted |
| V-DNP-004 | No input validation on analog outputs |
| V-DNP-005 | Device attributes readable (info disclosure) |
| V-DNP-006 | No rate limiting |
| V-DNP-007 | Unsolicited responses enabled |
| ID | Description |
|---|---|
| V-HMI-001 | No authentication on web interface |
| V-HMI-002 | Unauthenticated write API |
| V-HMI-003 | Debug mode enabled — stack traces exposed |
| V-HMI-004 | CORS wildcard — any origin can send writes |
| V-HMI-005 | Audit log accessible without credentials |
| V-HMI-006 | Maintenance code readable via /api/status |
All endpoints are unauthenticated by design. Access from your attacker machine:
# Modbus PLC status
curl http://<PI_IP>:8080/api/status
# DNP3 outstation status
curl http://<PI_IP>:8080/api/dnp3/status
# Write a Modbus coil (open CB Bus 1) → GPIO17 LED turns OFF
curl -X POST http://<PI_IP>:8080/api/coil/0 \
-H "Content-Type: application/json" \
-d '{"value": false}'
# Engage emergency trip → GPIO9 Red LED turns ON
curl -X POST http://<PI_IP>:8080/api/coil/7 \
-H "Content-Type: application/json" \
-d '{"value": true}'
# Write a protection setpoint
curl -X POST http://<PI_IP>:8080/api/register/0 \
-H "Content-Type: application/json" \
-d '{"value": 1200}'
# Audit log
curl http://<PI_IP>:8080/api/auditWhen running on a Raspberry Pi, the PLC simulator drives physical LEDs via GPIO pins to represent circuit breaker states. LEDs turn ON when the breaker is closed and OFF when open.
| Coil | Name | GPIO (BCM) | LED Color | Function |
|---|---|---|---|---|
| 0 | CB_BUS1 | 17 | Green | Bus 1 breaker |
| 2 | CB_FEEDER1 | 27 | Green | Feeder 1 breaker |
| 3 | CB_FEEDER2 | 22 | Yellow | Feeder 2 breaker |
| 4 | CB_FEEDER3 | 10 | Yellow | Feeder 3 breaker |
| — | ALARM | 9 | Red | Alarm / fault indicator |
| 8 | CAP_BANK | 11 | Blue | Capacitor bank |
Connect each LED from the GPIO pin through a 220Ω resistor to GND.
GPIO Pin ──► 220Ω ──► LED(+) ──► GND
- Normal operation: Bus and feeder LEDs ON (green/yellow lit), alarm OFF
modisy.py -H <PI_IP> -p 5020 blackout: All breaker LEDs OFF, red alarm LED ONmodisy.py -H <PI_IP> -p 5020 trip: All LEDs OFF, red alarm LED ONmodisy.py -H <PI_IP> -p 5020 flip 4: CB_FEEDER3 LED (GPIO10) togglesdnp3sy.py -H <PI_IP> -p 20000 blackout: Same effect via DNP3 CROBdnp3sy.py -H <PI_IP> -p 20000 flip 0: CB_BUS1 LED (GPIO17) toggles- Recovery: Close breakers to turn LEDs back on, alarm auto-clears
- GPIO disabled: On non-Pi systems, GPIO is silently skipped — simulator works normally
| Tool | Usage |
|---|---|
| nmap | nmap -p 5020,20000,8080 <PI_IP> |
| Metasploit | use auxiliary/scanner/scada/modbus_findunitid |
| Wireshark | Filter: `ip.addr == <PI_IP> && (tcp.port == 5020 |
| Scapy | Craft and replay raw Modbus/DNP3 packets |
MIT License — see LICENSE for details.