You can speak Modbus to a chemical reactor as freely as its own engineer. Breaking it, though, isn’t a yes or a no — it’s a question of patience.
In February 2021, the lye setpoint at a Florida water plant jumped from 100 to 11,100 ppm, no exploit, no malware, just a control command the system was built to trust. Stuxnet and FrostyGoop did the same to Iran’s centrifuges and a Ukrainian city’s heat. Modbus, the protocol running the reactor in front of us has no authentication: reach port 502 and the plant obeys you like its own engineer. Once you can say anything to the process, can you actually break it?
Part 13e of a hands-on OT series. Part 13d mapped the GRFICSv3 PLC’s registers and confirmed HR 1024–1028 (the %MW setpoints) accept and retain external writes. Now we weaponise that, three OT process-attack classes, each the technique behind a famous, documented incident. We don't reproduce any incident's exact kit; the incidents are proof these classes cause real, costly harm.
All scripts used in this post: ot-security-lab-scripts/part13e-attack-scenarios
Setpoint Manipulation: the attack class behind Oldsmar
In February 2021, the Oldsmar, Florida water plant’s HMI was accessed via TeamViewer and the sodium hydroxide (lye) setpoint jumped from 100 ppm to 11,100 ppm, 111×. An operator watching the screen caught it and reverted it. (FBI/CISA later left the cause, intrusion vs. operator error, unresolved; the setpoint-change mechanism it publicised is the point.)
It made the simplest ICS attack class famous: change a setpoint, let the controller do the rest, no actuator override, no false readings. We demonstrate that class on Modbus here, not Oldsmar’s TeamViewer/HMI kit.
The lab equivalent: HR 1026, the composition setpoint, a%MW register that accepts and retains external Modbus writes (one write, sustained effect).
Checking the registers using mbtget
mbtget -r3 -a 1024 -n 5 -p 502 192.168.95.2With mbtget installed, the entire attack is one command:
mbtget -w6 15400 -a 1026 -p 502 192.168.95.2That’s it. No code, no dependencies, no authentication. The setpoint drops from ~55295 to 15400 and stays there until someone fixes it.
For automated monitoring during the attack, oldsmar_setpoint.py writes the value, observes process response for 60 seconds, and restores:
The shape is the point: the write sticks, and the PLC’s own control loop walks the process to the new setpoint. Capture your real numbers here, the exact curve depends on process state (as the FrostyGoop section showed, assumed numbers and measured numbers diverge).
Press enter or click to view image in full size
Parameter Drift: the attack class behind Stuxnet
Stuxnet didn’t slam centrifuge frequency to maximum. It varied the frequency of Iran’s IR-1 centrifuges between 1,410 Hz and 2 Hz in cycles, designed to cause mechanical stress and bearing damage while staying below alarm thresholds and it replayed recorded “healthy” data to the monitoring systems so operators saw normal screens while the speeds cycled. It set Iran’s enrichment programme back an estimated months to years, and made slow drift a recognised ICS attack class.
Stuxnet’s stealth had two halves: sensor spoofing and a gradual attack profile (the centrifuges were never slammed in one move). We demonstrate the second half the drift class on Modbus; not Stuxnet’s S7/Profibus kit, and the spoofing half needs an on-path MITM, out of scope here. What we can show faithfully is a true incremental ramp.
A real ramp, not a single write
This is the distinction from Oldsmar section. Oldsmar was one write. A drift is a loop of small writes over time. stuxnet_drift.py ramps HR 1025 (%MW1, confirmed externally writable in Part 13d, so each increment sticks and accumulates):
--subtle: the full +10% spread across the entire window as one-step-per-second nudges. Over a 60s run that's ~0.17% per write, indistinguishable from sensor noise on any single reading.--aggressive: the same register driven to 5× in a handful of large steps, then held, immediately destabilizing. The contrast case.
Press enter or click to view image in full size
The script reads HR 1025 back every second, so the output proves the drift is accumulating,%MW writes stick and stack.
Every step is the same FC6 packet, same function code, register, size so a network IDS sees a normal write rate and nothing else; only a per-register rate-of-change policy flags the slow climb. The trend is the weapon: no single reading is out of bounds. Honest limit: the values stay real on ScadaLTS, so a trend-watching operator would still catch the climb this evades threshold alarms, not vigilant humans.
Brute-Force Shutdown: the attack class behind FrostyGoop
In January 2024, FrostyGoop attacked district-heating infrastructure in Lviv, Ukraine. Per Dragos, it sent Modbus TCP commands directly to ENCO heating controllers the devices that managed the physical process changing values and making the controllers report false readings. There was no separate PLC layer it tunneled past: in that architecture the ENCO controllers were the process controllers, and Modbus has no authentication, so it just spoke their protocol. ~600 buildings lost heat for ~48 hours in sub-zero weather proof that brute-force shutdown, no subtlety required, is an attack class in itself.
This section demonstrates that class on Modbus in front of you. It is not a reproduction of FrostyGoop’s ENCO-controller environment GRFICSv3 centralises actuator control in the OpenPLC PLC, so the brute-force kill here is the run bit
Get urjasec’s stories in your inbox
Join Medium for free to get updates from this writer.
Before killing the run bit, let’s check what’s the current state. One mbtget command.
mbtget -r1 -a 40 -p 502 192.168.95.2One mbtget command to kill.
mbtget -w5 0 -a 40 -p 502 192.168.95.2Press enter or click to view image in full size
That trips the PLC safe-state: the purge valve opens, pressure bleeds down from ~2700 kPa. Restart with:
mbtget -w5 1 -a 40 -p 502 192.168.95.2That’s it. That single coil write is the entire attack, no setpoint manipulation, no output forcing.
Watching it die
frostygoop_recreation.py wraps the one write with a baseline read, a second-by-second monitor, and an automatic restore. Here's the attack window from a real 60-second run (sampled):
Press enter or click to view image in full size
Read the chain: within two seconds of the kill the PLC safe-state snaps the valve to 100% open, and pressure bleeds down continuously ~12 kPa/s, draining toward the depressurized floor on a predictable slope.
Coil 40 is loud, not stealthy, Wireshark shows the PLC cycling it every scan, so any sniffer finds it. And self-recovery isn’t no-harm: the PLC regains control the moment you stop, but the physical process is already disrupted, Lviv recovered its controllers, and residents still spent 48 hours without heat.
The Data-Only Attack Ceiling: It's Time, Not Impossibility
This is the payoff the three scenarios were building toward: with unauthenticated Modbus letting us say anything to the reactor, can pure data injection actually break it? The answer has two halves, and the gap between them is the whole lesson.
A single command doesn't visibly break it. Slam every setpoint to 65535 in one write, watch for thirty seconds the process settles, nothing ruptures. That looks like safety. It is latency. Held, that same write ruptures the reactor. On a fresh sim, max_all_setpoints.py --no-restore (or a manual mbtget loop) keeping HR 1025–1028 pinned at max, left running for hours, not seconds drives the process to this max and then ruptures the reactor.
python3 max_all_setpoints.py # slam all setpoints, observe, restore
python3 max_all_setpoints.py --no-restore # leave them slammed and rupture the reactor.Press enter or click to view image in full size
GRFICSv3 3D reactor rupturing after a sustained max-setpoint holdFeed in, nothing out pressure climbs for hours, then the vessel ruptures. That’s the observed register state, not the chemistry, which the attacker never sees and doesn’t need to.
So the ceiling is real, but made of time, not impossibility: a fast data attack is bounded, a patient one isn’t. The script’s 30-second default and auto-restore are the only reason a casual test looks safe duration was always the limiter, never the write.
Recovering a degraded simulator
A ruptured or degraded model never comes back with a register write, whether you blew it up with a sustained hold or just wore it down with repeated scenario testing (stuck/depressed state: pressure bled down, product stream shut, values frozen). You must re-initialize the sim. Three moves, in order:
1. Stop the attack — restore setpoints/run bit, or the freshly-reset process gets re-wrecked by stale %MW values:
mbtget -w6 30801 -a 1025 -p 502 192.168.95.2 # a_setpoint → nominal
mbtget -w6 55295 -a 1026 -p 502 192.168.95.2 # pressure_sp → nominal
mbtget -w5 1 -a 40 -p 502 192.168.95.2 # run bit back on2. Reset the physics — the HMI Reset button only clears alarms; to re-init the model: docker compose restart simulation
3. Restart the PLC runtime if wedged — docker compose restart plc, then Start PLC at http://192.168.95.2:8080 (it does not auto-start).
Snapshot before you start the scenarios. it turns all of the above into a 30-second rollback.
Key Takeaway
So, once you can say anything to the process, can you actually break it? Yes: not with a packet, with patience. One slammed command looks survivable, that’s the trap. Held for hours, the reactor that shrugged off every command ruptures. Robust-looking control didn’t stop the data attack; it only slowed it.
The defence isn’t “block port 502”, it’s sane limits, segmentation, and trend/duration monitoring: a point-in-time alarm sees nothing; “pinned at max for forty minutes” sees everything. And this is just a model, real plants pay that gap in equipment and lives.
Oldsmar’s setpoint slam. Stuxnet’s slow drift. FrostyGoop’s run-bit kill. Three incidents, three scripts, one tool, one register map. That closes the GRFICSv3 hands-on arc; lab built, network mapped, reactor broken. What comes next isn’t another register: it’s the whole engagement around it — initial access, the pivot, lateral movement, impact, the report that lands on a plant manager’s desk. A different game, and where we go next. See you there.
Until then, let’s explore OT/ICS security and work toward making critical infrastructure more secure.
Stay curious, stay secure.