cat ~/notes/behind-the-plc.md
finding l0 devices behind a modbus plc: when every unit id lies
[id]finding l0 devices behind a modbus plc: when every unit id lies
In the previous post, I built a small OT lab to test a scanner against real protocols and physical I/O.
The scanner found the esp32 and the raspi. Modbus, S7, and BACnet all responded as expected.
Then I changed the topology. The raspi became a soft-PLC running openplc, with a real RTU device connected over USB-RS485. The device was a small red and green signal light.
The scanner should have found the light behind the PLC. Instead, it identified the PLC as Siemens and reported no downstream device at all.
Both failures came from the same assumption: a Modbus TCP endpoint is not always a transparent gateway.
What is an L0 device?
A Modbus TCP gateway on port 502 often fronts an RS-485 serial bus.
That bus contains RTU slaves such as sensors, actuators, and meters.
Each slave has an address called a unit id.
A request sent to the gateway with unit id 3 should be forwarded to slave 3 on the serial bus.
Those downstream slaves are the L0 devices in this scanner. A normal network scan finds the gateway and stops. L0 discovery has to pass through the gateway, enumerate the slave addresses, and classify whatever answers.
That model works for a transparent gateway. Openplc does something else.
Why did a raspi look like Siemens?
The first bad result was the PLC identity.
Openplc can expose an S7 server on port 102 through snap7.
The snap7 handshake contains a hardcoded identity:
system: SNAP7-SERVER
module: CPU 315-2 PN/DP
copyright: Original Siemens Equipment
The scanner read a valid S7 response and did exactly what it was designed to do. The problem was that the protocol identity described the emulation layer, not the raspi running it.
This was useful context, but it did not explain the missing light.
For that, I had to look at how openplc handled unit id values.
The gateway that answers for everyone
I started with the obvious scan: sweep unit id 1 through 247 and fingerprint every response.
Openplc answered all of them. Unit 1, unit 50, and unit 200 returned the same process image byte for byte.
That is reflection. Without a guard, one soft-PLC turns into hundreds of phantom devices.
A transparent gateway, such as a moxa, forwards each unit id to a distinct RTU slave.
Openplc converts Modbus RTU data into one process image, then answers every unit id from that shared image.
The scanner now probes three points before running a full sweep:
- one low valid ID
- one valid ID near the middle of the range
- one reserved canary ID at 250
If the canary answers with the same fingerprint, the endpoint is reflecting one image across the address space.
The scanner sets modbus_reflection and stops enumerating IDs.
No more phantom devices.
Finding the light in the address map
Stopping the sweep fixed the false positives, but the light was still physically connected.
If it was not represented by a unit id, where did it go?
Openplc maps its own logic I/O into the low address band, coils 0 through 799.
Downstream slave outputs begin at %QX100.0, which maps to coil 800.
The light was flattened into that process image:
- green output at coil 800
- red output at coil 807
I scanned the downstream address band using read-only requests. Active I/O appeared at 800 and 807.
Found it…
{
"modbus_reflection": true,
"l0_devices": [{
"type": "actuator",
"method": "slave_offset_band",
"io_profile": {"coils": true, "input_registers": false},
"mapped_range": [800, 807]
}]
}
There is no text identity in that data. The device name, model, and serial number only exist in the PLC configuration.
The scanner can still infer a coarse type from the I/O profile. Coils without analog registers point to a digital output such as a relay, light, or another actuator.
The read-only boundary
The detection worked while the light was on. Then I turned it off and the entire band read zero.
Openplc returns zero for every address inside its process image, whether a device is mapped there or not. From a read-only scan, these two states are identical:
actuator mapped at coil 800, currently off
empty address at coil 800
An active sensor usually carries a register value, so it can remain visible at rest. An idle coil-only actuator has no equivalent signal.
I could write to the coil and watch for a physical response. That would also mean a network scanner could move a motor, switch a valve, or trigger another real output. Writing stays a lab-only characterization technique.
Against a reflecting soft-PLC, read-only Modbus discovery can recover presence, I/O profile, coarse type, and mapped range. It cannot recover the real identity or prove that an inactive actuator exists.
That is the protocol boundary.
Testing the transparent path without a gateway
The scanner still needed coverage for a normal transparent gateway, but the lab only had openplc.
I first tried a pymodbus server.
It automatically answered FC 0x11 and MEI, then routed slave 0 to every unit id.
That reproduced the reflection problem instead of a transparent gateway.
So I built the smallest useful mock with a raw socket and the standard library. Each slave answered only its own ID, while canary 250 stayed silent.
unit 0 Light coils only -> actuator
unit 1 TempSensor input registers only -> sensor
unit 2 FlowMeter holding registers -> sensor
The scanner enumerated all three and classified them from their I/O profiles. Roughly 200 lines of socket code covered the path that the real soft-PLC could not exercise.
Two endpoints, two discovery strategies
A transparent gateway and a soft-PLC can both listen on port 502 and front serial devices.
Their behavior is different enough that one discovery strategy cannot safely handle both.
The scanner now checks for reflection first. If IDs remain distinct, it enumerates slaves. If one image is reflected, it scans the mapped downstream address bands and reports the limits of what it found.
The important distinction is not which port is open. It is whether the endpoint forwards an address or answers on its behalf.