By RUGERO Tesla (@404Saint).
Industrial protocols always felt like an intentional black box to me.
Most tutorials, guides, and bootcamps follow a predictable script: you pip install a heavy, third-party framework, invoke an abstracted wrapper function, and print a sanitized result. But very few actually pull back the curtain to explain what is happening down on the wire. When you rely completely on vendor abstraction layers, you completely miss the underlying engineering quirks—and core implementation flaws that define how these daemons actually process packets, handle exceptions, and fail.
So, I decided to do something about it. I turned off the libraries, opened up a raw Python socket, and spent several days manually reverse-engineering and implementing Modbus TCP from the ground up.
The goal wasn't to write a production-ready Modbus client. The goal was to understand exactly how Programmable Logic Controllers (PLCs) talk at the byte level.
By stripping away the third-party fluff, my research quickly progressed from reading a single baseline register to building an automated capability fingerprinting loop, discovering hard silent memory boundaries, fuzzing input validation parsing, and engineering high-fidelity detection heuristics that blue teams can deploy in live Operational Technology (OT) environments.
Why Modbus TCP?
If you step onto a modern factory floor, water treatment facility, or power grid control room, you will find Modbus driving critical infrastructure data loops. It is the connective tissue for:
- Programmable Logic Controllers (PLCs)
- Human-Machine Interfaces (HMIs)
- Supervisory Control and Data Acquisition (SCADA) runtimes
- Building Automation Sensors
Despite being decades old, its utter simplicity and interoperability keep it firmly entrenched in modern automated control stacks. That exact structural simplicity also makes it the definitive playground for anyone trying to bridge the gap between pure software exploitation and physical physical-layer control routing.
The Simulation Topology
To execute this safely without running into real-world hardware damage or kernel routing hell, I set up a minimal simulation sandbox:
+-----------------------+
| Attacker Host |
| (Raw Python Suite) |
+-----------+-----------+
|
| TCP Port 502
| (Raw Layer Zero Stream)
|
+-----------v-----------+
| OpenPLC Runtime |
| (Target Server) |
+-----------------------+
-
The Target: OpenPLC Runtime Engine with its local Modbus TCP server daemon enabled and listening on standard port
502. -
The Analysis Workstation: Arch Linux workspace leveraging
tcpdumpbackground streams and Wireshark for direct frame validation.
Instead of calling pymodbus or letting Scapy automatically format my payloads, I manually built every command block out of the standard Python socket and struct modules.
Down in the Wire: Protocol Anatomy
Before writing a single line of executable automation code, I had to dissect the structural layout of a standard Modbus transaction. On the wire, a basic request looks like a flat string of hexadecimal data:
00 01 00 00 00 06 01 03 00 00 00 01
When you map the offsets out byte-by-byte, the packet splits cleanly into a 7-byte MBAP Header (Modbus Application Protocol) and a downstream PDU (Protocol Data Unit):
-
00 01| Transaction Identifier: Synchronized sequential number tracking for request/response pairing. -
00 00| Protocol Identifier: Hardcoded to zero specifically for Modbus TCP networks. -
00 06| Length: The precise numerical count of all remaining bytes inside the frame. -
01| Unit Identifier: Slave/routing destination index for the controller node. -
03| Function Code: The direct operational command (e.g.,FC03- Read Holding Registers). -
00 00| Starting Address: The memory index offset to begin polling from. -
00 01| Quantity of Registers: The exact number of 16-bit word data blocks requested.
Once this memory geometry made sense, translating it into raw, hard-packed binary network data streams became trivial.
Phase 1: Passive Data Polling (FC03 & FC01)
Reading Holding Registers (FC03)
My first operational script focused on assembling an FC03 transaction loop to pull analog configuration values out of the PLC data block.
-
My Hex Request:
00 01 00 00 00 06 01 03 00 00 00 01 -
Target Response:
00 01 00 00 00 05 01 03 02 00 00
Seeing the clean, non-abstracted raw bytes echo back onto my socket interface was the exact moment the protocol stopped looking like magic and started looking like a transparent data pipe.
Reading Discrete Coils (FC01)
I implemented FC01 shortly after to verify how binary bit flags are handled. Tracking the bit-packed, Least Significant Bit (LSB) format across the wire proved how lightweight and highly predictable industrial status checking really is under normal operation.
Phase 2: Active State Manipulation (FC06 & FC16)
Reading variables lets you monitor a process; writing to them lets you change physical reality.
Single Variable Injections (FC06)
I extended my socket core to handle Function Code 06 (Write Single Register), firing a value of 1337 straight into holding register offset 0. An immediate readback command confirmed that the variable shifted instantly in runtime space. It was a stark reminder of how easily an unauthenticated network injection can alter a controller's parameters.
Bulk Payload Overwrites (FC16)
Single points are fine, but bulk manipulation is where things get interesting. Using FC16 (Write Multiple Registers), I pushed an entire array sequence into memory simultaneously:
[100, 200, 300, 400, 500]
The server accepted the payload instantly, confirming that complex block states can be rewritten in a single socket transaction.
Phase 3: Capability Fingerprinting & Recon
With standard data I/O paths verified, I wanted to map out the complete, unadulterated functional footprint of the OpenPLC engine without consulting vendor documentation. I built an automated capability mapping loop (modfingerprint.py) to systematically scan the command spectrum from 0x01 directly through 0x7F.
The fingerprint execution mapped out the active operational layout:
[+] FC01 Supported (Read Coils)
[+] FC02 Supported (Read Discrete Inputs)
[+] FC03 Supported (Read Holding Registers)
[+] FC04 Supported (Read Input Registers)
[+] FC05 Supported (Write Single Coil)
[+] FC06 Supported (Write Single Register)
[+] FC15 Supported (Write Multiple Coils)
[+] FC16 Supported (Write Multiple Registers)
Any unsupported function codes fired outside this specific baseline were cleanly intercepted by the target daemon and rejected via standard Modbus error frames: Exception 01 (Illegal Function).
Probing for Metadata (FC43)
I tried to pull asset tracking data like vendor tags, firmware levels, and hardware naming schemes using Function Code 43 (Modbus Encapsulated Interface Type 0x0E). The OpenPLC daemon dropped the packet with an Illegal Function exception, successfully protecting its asset signature from basic network profiling tools.
Phase 4: Fuzzing, Exceptions, and Memory Boundaries
Exception Isolation Under Stress
To test the resilience of the target parser, I deliberately pumped malformed frames, incorrect lengths, out-of-bounds quantities, and invalid functions straight into the daemon interface.
The PLC correctly generated spec-compliant exceptions:
-
01- Illegal Function: Triggered by out-of-range commands. -
02- Illegal Data Address: Triggered by requests targeting dead space. -
03- Illegal Data Value: Triggered by skewed parameters.
The most notable observation here? The background runtime service handled the error parsing gracefully. I couldn't crash or hang the daemon; it isolated the bad inputs and kept the socket loop completely stable.
Automated Memory Boundary Discovery
I built a specialized optimization loop (modboundary.py) that leveraged a binary-search style algorithm to trace the exact ceiling of the accessible holding register memory space.
Instead of sequential polling, the script rapidly converged on offset 8191 as the absolute maximum boundary (confirming an overall block allocation of exactly 8,192 address points, or 16 KB of volatile RAM mapping). Attempting to read index 8192 instantly triggered an Illegal Data Address error.
An Unexpected Implementation Quirk
During the discrete coil fuzzing run (FC05), I caught a clear deviation from the official Modbus specification documentation. The formal standard explicitly states that only two fixed values are valid for changing coil states: 0xFF00 (ON) and 0x0000 (OFF). Any other values must throw an exception.
However, the target parser cleanly accepted and mirrored back non-compliant arbitrary hexadecimal strings like 0x0001, 0x1234, and 0xFFFF, treating any non-zero value as a logical TRUE. Finding where active software deployments break away from standard documentation parameters is exactly why bare-metal research pays off.
The Systemic Security Reality
The single biggest takeaway from this research wasn't finding a software bug. It was the fundamental security posture of the protocol itself.
Throughout every phase of this research lab:
- Authentication: Non-Existent.
- Authorization: Non-Existent.
- Encryption: Non-Existent.
The operational parameters are absolute: if an endpoint can achieve basic IP connectivity to TCP/502, it inherits total master capability to query data blocks, execute arbitrary writes, fuzz data vectors, and manipulate the controller's state at will. This reinforces a clear architectural lesson: legacy industrial protocols are built for raw performance and interoperability, meaning security must be aggressively enforced externally through rigid network segmentation, whitelist firewalls, and active logging.
Engineering High-Signal Detections
Because Modbus scanning and state injection techniques are highly structured, an adversary cannot map or abuse a device silently. Their activity creates distinctive network artifacts that look completely different from the highly cyclical, flat baseline of standard industrial automation processes.
Blue teams can construct highly effective detection rules by monitoring for these explicit behavioral indicators:
-
Function Code Sweeps: Track clients issuing multiple distinct or sequential function code requests (scanning from
FC01onward) within a brief time frame. -
Boundary Profiling Loops: Signature patterns making alternating, binary-search style mathematical jumps across address offsets followed by targeted clusters of
Exception 02frames. -
Exception Storm Monitoring: Immediate alerting on sharp quantitative spikes in exception codes (
01,02, or03) from a single node, pointing directly to active fuzzing engines or automated reconnaissance scanners. -
Asymmetric State Modification: Whitelisting write payloads (
FC05,FC06,FC15,FC16) strictly to legitimate HMI runtime assets and dedicated engineering terminals, while dropping and logging any writes coming from enterprise jump hosts or unmapped IP spaces.
Exploring the Master Architecture
The complete codebase for this research including the custom Python packet-crafting tools, behavioral anomaly indicators, and the complete step-by-step lab reproduction guide—is fully open-sourced inside my new master portfolio repository.
You can pull the blueprints, run the code, and test your own environments here:
This lab started as a baseline attempt to demystify a protocol and turned into one of the most educational security milestones I've built. If you are serious about understanding OT/ICS security engineering, turn off your third-party abstraction frameworks, build a small simulation lab, spin up raw sockets, and write the packets yourself. The wire doesn't lie.
Disclaimer: All testing and research documented in this post was executed exclusively within an isolated, authorized laboratory environment using OpenPLC for educational and defensive security engineering research purposes. Do not target production infrastructure, utility control loops, or live automated environments without formal authorization and explicit safety controls.

