By RUGERO Tesla (@404Saint).
An architectural investigation into the processes, communication interfaces, and Linux environment surrounding a software PLC runtime.
It has been a little over a month since I wrapped up my industrial protocol research series. At the time, I said something new was coming. Here it is.
The protocol series taught me a lot about how industrial systems communicate. For this next stage, I wanted to look at a different part of the picture... the PLC runtime itself.
I chose OpenPLC Runtime v4 as the first target for a new series of PLC security investigations. Before testing individual security properties, I wanted to understand what was running, how its components interacted, and what the surrounding Linux environment allowed them to do. That became the first investigation: establishing a baseline.
Why start with architecture?
A PLC runtime sits between control logic and the environment in which that logic executes. Its behavior depends on more than the code that runs inside it. Processes, local communication channels, loaded components, operating system permissions, and resource limits can all affect how it operates.
I wanted to answer a few practical questions before moving into deeper testing.
What processes make up the runtime? Which communication interfaces are visible? What privileges and operating system resources are available to the processes? How does the runtime behave when one of its components exits unexpectedly?
These questions helped define the scope. I was not trying to find a vulnerability in this first investigation. I wanted to document the environment well enough that future tests would have a reliable point of reference.
The target and lab
The target was OpenPLC Runtime v4, running in an isolated Docker container on my research machine. Keeping it in a container gave me a repeatable environment to inspect without involving a physical PLC or a production control system.
I started the container with the runtime image and published ports for the web interface and Modbus TCP:
docker run -d \
--name openplc-v4-lab \
-p 8443:8443 \
-p 502:502 \
ghcr.io/autonomy-logic/openplc-runtime:latest
One detail matters for anyone reproducing this work: the image uses the latest tag. That tag can point to a different image over time, so it should not be treated as a fixed version. For a reproducible investigation, record the image digest and runtime version used during the test.
The container provided a contained target for process inspection, socket enumeration, filesystem review, and observation of Linux process state. I used standard Linux tools and direct inspection of the container's /proc, /sys, and filesystem interfaces.
The investigation was divided into seven phases. Each phase examined a different part of the running environment, with observations recorded in the project notes.
Reconstructing the runtime
1. The processes
The first useful result was a clearer picture of the processes involved.
The container's initial process, PID 1, ran the Python web management application through its virtual environment. A separate process named etherdog handled runtime supervision and exposed a control socket. The PLC execution process appeared as plc_main.
These process IDs describe the snapshot I inspected, not permanent identities. The PLC process received a different PID after it was restarted, which is exactly why I recorded process names and executable paths alongside the IDs.
Thread-aware inspection also showed that these were not single-threaded processes. In the observed state, the management process had 11 threads, etherdog had two, and plc_main had four. One of the PLC process threads was named plc_watchdog.
I then deliberately terminated plc_main to observe what happened. The supervisor reported an unexpected exit with code -9 and attempted to restart the process. The replacement process initialized some runtime components and recreated the PLC runtime socket, but it could not find the expected libplc_*.so files in its build directory. It entered the EMPTY state and did not transition to RUNNING.
This was a useful observation of the runtime's recovery behavior under that specific condition. It does not establish how every failure would be handled, but it gave me a concrete starting point for future investigations into lifecycle management and recovery.
2. Local communication
The processes also had local communication interfaces worth examining.
The web application's Python code contained a Unix-domain socket client targeting /run/runtime/plc_runtime.socket. The corresponding C header defined the same socket path and specified buffer limits, including a maximum response size of 65,536 bytes.
A separate Python component, UnixLogServer, bound to /run/runtime/log_runtime.socket. Its implementation accepted local clients and handled incoming log lines.
The supervisor used another socket path, /run/runtime/etherdog.socket, for its control interface. The socket's presence and filesystem permissions were observable, while the exact authorization behavior of every communication path was outside the scope of this baseline.
These findings established that Unix-domain sockets were part of the runtime's internal communication design. They also gave me concrete paths and implementation files to revisit when investigating message handling or access control later.
3. Network listeners
I used ss to inspect the network sockets visible inside the container's network namespace.
The snapshot showed two listeners or bound sockets associated with the Python management process:
| Protocol | Local address | Observed state |
|---|---|---|
| TCP | 0.0.0.0:8443 |
Listening |
| UDP | 0.0.0.0:33333 |
Bound, no connected peer |
Both were associated with file descriptors held by PID 1. The wildcard addresses mean the sockets were bound across the container's available network interfaces, rather than to a single container interface address.
That observation alone does not establish external reachability. Docker port publishing, host firewall rules, routing, and other network conditions affect whether a service can be reached from outside the container.
I had published TCP port 502 when starting the container, but the inspection snapshot did not show a Modbus TCP listener on that port. Publishing a port in Docker does not prove that an application is listening behind it. I recorded the result as a finding about this particular runtime state, rather than a universal statement about OpenPLC Runtime v4.
I did not test web routes, authentication, or the behavior of the exposed services in this phase. Those questions require separate, scoped investigations.
Figure 1. A view of the observed runtime processes, local communication paths, and network interfaces. The diagram summarizes the lab snapshot; it should not be read as a complete specification of every internal connection.
The runtime's Linux environment
Knowing which processes existed was only part of the picture. I also needed to understand what those processes could access through the operating system.
1. Executables and runtime state
The main PLC executable was located at /workdir/build/plc_main, while the observed supervisor executable was at /workdir/build/etherdog. Both were owned by root:root and had mode 0755, allowing the owner to read, write, and execute them, while group members and other users had read and execute permissions.
The inspected runtime directory contained several relevant files and interfaces. These included /run/runtime/.env, the supervisor's JSON file, the SQLite database at /run/runtime/restapi.db, and the Unix-domain sockets described earlier.
I checked the permissions and metadata of these paths without reading the contents of .env or inspecting the database records. The database file had a SQLite header, which identified its format, but its contents were not part of this investigation.
This matters since a file's name, format, ownership, and mode tell us something about how it is exposed in the environment, but they do not tell us what data it contains or whether the application uses every file in the way its name suggests.
2. Capabilities, file descriptors, and devices
I inspected the Linux capabilities of the management and PLC processes. The observed capability sets included CAP_NET_RAW and did not include CAP_SYS_RAWIO.
Capabilities are one part of a process's effective authority. They help describe which privileged operations may be available, but they do not independently establish whether a particular operation is reachable through the application's code or permitted by other system controls.
I also reviewed open file descriptors and correlated socket inodes with entries in /proc/net/unix, /proc/net/tcp, and /proc/net/udp. This connected several of the observed descriptors to the runtime's local sockets and network endpoints. The management process also had the database open.
Some file descriptors referred to pipes shared between processes. That showed that the processes held descriptors for the same underlying pipe objects. It did not, by itself, establish the purpose or contents of every message passing through them.
The captured /dev listing contained standard Linux device nodes, including /dev/null, /dev/zero, /dev/random, and terminal-related entries. I did not see obvious dedicated GPIO, SPI, or I²C device nodes in that listing.
That is a limited observation. It does not prove that hardware access is impossible, or that every possible I/O mechanism is absent. The runtime may use software backends, network interfaces, other mounted resources, or interfaces that were not identifiable from this snapshot.
For this investigation, the useful result was a record of the capabilities, descriptors, and device nodes visible in the environment. Whether a particular interface can be used to influence physical I/O remains a separate question.
3. Memory mappings and loaded components
I inspected the PLC process's memory map through /proc. The main executable appeared in separate read-only, executable, and writable mappings. The process also had a heap, anonymous memory regions, a stack mapping, and mappings for shared libraries.
Two plugin libraries were particularly relevant to the runtime's industrial focus: libethercat_plugin.so and libs7comm_plugin.so. Both appeared in the process's mappings, alongside the C runtime, OpenSSL libraries, Python libraries, and native Python extensions.
This gave me evidence that these plugin libraries were mapped into the process at the time of inspection. It did not establish which plugin functions were active, what protocol operations were being performed, or how the runtime handled specific industrial messages.
Anonymous mappings were recorded as anonymous mappings. I did not assign them to particular runtime structures because the mapping information alone was insufficient to identify their contents reliably.
The snapshot was also taken before deploying user control logic. That boundary is important: the memory layout of a process running an actual project may differ from the baseline observed here.
4. Scheduling and resource limits
The final phase examined how the PLC process was scheduled and what resource limits were visible at the inspected cgroup level.
The PLC process was using SCHED_OTHER with priority zero. The four listed threads were also using normal time-sharing scheduling. The process affinity mask was fff, indicating an allowed CPU mask covering the corresponding CPU bits. In this environment, that mask represented CPUs 0 through 11.
The inspected cgroup files reported:
cpu.max max 100000
memory.max max
These values indicated that the inspected cgroup level did not specify a CPU quota or a maximum memory limit through those files. They do not mean the process had unlimited resources. Parent cgroups, Docker settings, host capacity, and contention can still constrain it.
During runtime observation, the watchdog also logged a failed attempt to set SCHED_FIFO priority 99, with the error Operation not permitted. That confirmed the scheduling request failed in this environment. It did not establish the runtime's timing accuracy, cycle jitter, or behavior under load. I did not measure real-time performance in this investigation. The scheduling and resource observations provide context for that work if it becomes relevant later.
Figure 2. A summary of the process environment, loaded components, visible operating system interfaces, and scheduling configuration observed during the investigation. These details describe the captured lab state, not every possible deployment configuration.
What this baseline establishes
By the end of the investigation, I had a working map of the environment around the runtime.
I identified the principal processes and their observed thread counts. I documented the local socket paths and the network sockets visible in the container. I recorded relevant executable and state-file permissions, inspected capabilities and open descriptors, and reviewed the PLC process's memory mappings. The scheduling and cgroup checks added context about the execution environment.
The unexpected termination test also showed a specific recovery path. The supervisor detected the PLC process exit and attempted to restart it, but the replacement did not reach the RUNNING state because expected shared libraries were not found in the inspected build directory.
These results give later investigations something concrete to compare against. If a subsequent test changes process behavior, introduces a new interface, or produces an unexpected failure, there is now a baseline to help distinguish the new observation from the environment that was already present.
The detailed phase notes, commands, paths, and supporting observations are available in the plc-runtime-architecture repository.
Where the research goes next
This first investigation closes with a baseline rather than a vulnerability investigation. I now have a better understanding of the processes surrounding OpenPLC Runtime v4, the interfaces they expose, and the Linux environment in which they run.
The next investigations can build on that knowledge and examine individual security properties in more detail. Each one will need its own question, evidence, and limits. After spending so much time examining how industrial protocols communicate, I am looking forward to digging into the software that makes a PLC runtime operate. Stay tuned.


Top comments (0)