In the previous article, “Malware in the engine room: Deviations from the normal operation,” we provided an overview of a simplified industrial environment that was subjected to attack. This article takes a deeper dive into the technical details surrounding that environment.
Industrial control systems use different protocols to communicate with each other. These protocols run on different ports and support various functions. It’s important to become familiar with them if working with control systems.
Image Source: ICS Cheat Sheets | It’s Not Cheating If You Have an Effective and Safe Approach!
Modbus TCP is one of the most commonly used protocols with PLC, RTU, or other control systems.
Taking a closer look at the definition of Modbus TCP, we see that the packets are usually the same length.
The following image shows a “session” for the protocol.
The image shows the client wants to read (0x03) from a device with ID 9 (0x09). The client wants to get the value of Register 1 (0x0001).
The server replies with the same function code (0x03) and the value (0x005). This could, for instance, be the pressure in a tank.
If we want to write to a device’s register, we can use function code 0x06, with a value of 0x0001, for example. This could be sent to start a pump.
This is how control systems send packets back and forth to operate a complex facility with pumps, tanks, or machinery.
DRAGOS reports that over 46,000 units are publicly available via this protocol. This makes it very attractive for threat actors, as they can reach industrial control systems by simply sending some Modbus packets.
Dragos-FrostyGoop-ICS-Malware-Intel-Brief-0724_r2.pdf
If there is a need, in a simple way, to simulate Modbus traffic between "master" and "slaves", there is a GitHub repo available for this:
If you want to simulate control systems, you can use a combination of OpenPLC and Factory.io.
How does malware use the Modbus protocol to attack industrial control systems and facilities? There are multiple methods and types of malware. I will use the FrostyGoop malware as a starting point.
It’s available for download here: https://bazaar.abuse.ch/browse/tag/FrostyGoop/
Investigation reports show FrostyGoop used a JSON file to get instructions on what to execute. Here are two JSON files with different instructions.
The image on the left shows function code 1, “Read Coils,” running against 127.0.0.1.
The image on the right shows instruction code 15, “Write Multiple Coils,” running with a value of 0x11110000.
This often happens in sequence, but not necessarily at the same time. As an attacker, you want to learn something about the control system, so you send “Read” instructions to the system. Then, the values need to be interpreted to send a “Write” instruction.
If you don’t want to reuse malware, you can code your own in Golang using the “rolfl\modbus” library. It’s quite simple to make a program that communicates with control systems since the protocol is simple, and you only need to send a single network packet to get the answer you’re looking for.
Here’s a simplified example based on the previous image to the right. Note how the variable vals is initialized with the same value as in the image, and the client executes the same function code as in the image.
package main
import (
"fmt";"log";"time";
"github.com/rolfl/modbus"
)
func main() {
mb, err := modbus.NewTCP("127.0.0.1:502")
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
client := mb.GetClient(254)
timeout := 5 * time.Second
vals := []int{1, 1, 1, 1, 0, 0, 0, 0}
_, err = client.WriteMultipleCoils (0, vals, timeout)
if err != nil {
fmt.Println("WriteMultipleCoils failed:", err)
}
}
Imagine that this program now runs continuously without stopping.
What happens here in the image below that might be abnormal? Possibly difficult to see and guess.
The answer is that both the pump is turned on and the discharge valve is opened. This simulates a behavior that really should be impossible to achieve. With water, perhaps not much harm happens except equipment damage. But there can be other environments where two things shouldn’t happen at the same time.
A machine or facility operates in a pattern so that several modules can interact with each other. This is what we consider the normal state. But when an unknown application starts sending instructions, an abnormal state occurs that we should be able to detect.
Below is a Zeek script that generates alerts if a specific instruction occurs, or if suddenly a large number of Modbus packets appear.
The script works in this way: Each time a Modbus packet arrives, Zeek tracks who sent it and which instruction was sent. This is then measured against a threshold value set at the top of the script. These values are tied to a time window (epoch). Whether it’s a second or a minute, it can be adjusted.
Filename: modbus-thresholds.zeek
##!
## Zeek Script: Modbus Multi-Function & Total Packet Flood Alert
##
## This script monitors multiple Modbus function codes and total packet counts,
## generating notices when thresholds are exceeded within a time window.
##
@load base/protocols/modbus/main
module ModbusThreshold;
export {
redef enum Notice::Type += {
Excessive_Modbus_Function,
Excessive_Total_Modbus_Packets
};
}
# --- Customizable parameters ---
# Function-specific thresholds: [function_name] = threshold_count
const function_thresholds: table[string] of count = {
["WRITE_SINGLE_REGISTER"] = 1, # <-- Threshold for Write Single Register
["READ_HOLDING_REGISTERS"] = 20, # <-- Threshold for Read Holding Registers
["WRITE_MULTIPLE_REGISTERS"] = 20, # <-- Threshold for Write Multiple Registers
["WRITE_MULTIPLE_COILS"] = 20, # <-- Threshold for Write Multiple Registers
["READ_COILS"] = 15, # <-- Threshold for Read Coils
["MASK_WRITE_REGISTER"] = 1 # <-- Threshold for Mask Write Register
};
const total_packet_threshold = 170; # <-- Total Modbus packets threshold per IP
const epoch = 1sec; # <-- Time window for counting
# --------------------------------
# Per-IP counters for each function type
global func_counts: table[addr, string] of count &default=0;
# Per-IP total packet counters
global total_counts: table[addr] of count &default=0;
event Modbus::log_modbus(rec: Modbus::Info) {
local source_ip = rec$id$orig_h;
# Count total packets per IP
if ( source_ip !in total_counts ) {
total_counts[source_ip] = 0;
}
total_counts[source_ip] += 1;
# Count function-specific packets per IP
if ( rec?$func ) {
local func_name = rec$func;
# Check if this function is monitored
if ( func_name in function_thresholds ) {
if ( [source_ip, func_name] !in func_counts ) {
func_counts[source_ip, func_name] = 0;
}
func_counts[source_ip, func_name] += 1;
}
}
}
event modbus_check_thresholds() {
# Check function-specific thresholds
for ( [source_ip, func_name] in func_counts ) {
local cnt = func_counts[source_ip, func_name];
local threshold = function_thresholds[func_name];
if ( cnt > threshold ) {
NOTICE([$note=Excessive_Modbus_Function,
$msg=fmt("Excessive Modbus Function '%s': %d packets in %s from %s (threshold: %d)",
func_name, cnt, epoch, source_ip, threshold),
$sub=fmt("Function '%s' threshold exceeded", func_name),
$src=source_ip]);
}
}
# Check total packet thresholds
for ( source_ip in total_counts ) {
local total_cnt = total_counts[source_ip];
if ( total_cnt > total_packet_threshold ) {
NOTICE([$note=Excessive_Total_Modbus_Packets,
$msg=fmt("Excessive Total Modbus Packets: %d packets in %s from %s (threshold: %d)",
total_cnt, epoch, source_ip, total_packet_threshold),
$sub="Total Modbus packet threshold exceeded",
$src=source_ip]);
}
}
# Reset all counters
func_counts = table();
total_counts = table();
# Reschedule for next epoch
schedule epoch { modbus_check_thresholds() };
}
event zeek_init() {
print fmt("DEBUG: Multi-function Modbus monitoring started");
print fmt("DEBUG: Monitoring %d function types with total threshold %d over %s windows",
|function_thresholds|, total_packet_threshold, epoch);
# Print monitored functions
for ( func_name in function_thresholds ) {
print fmt("DEBUG: Monitoring '%s' with threshold %d",
func_name, function_thresholds[func_name]);
}
schedule epoch { modbus_check_thresholds() };
}
event zeek_done() {
print "DEBUG: Multi-function Modbus monitoring finished";
}
And it will look like this:
This log file can then be fed into a SIEM solution to generate alerts, and—crucially—enable rapid response and investigation of this anomaly.
We must become familiar with the OT environment to achieve proper detection and response. We need to know which protocols are used and what the normal state looks like. It also helps to know how the facility is structured and which components are in operation.
Without this information, we have nothing to go on! But once we have that information, we can look for anomalies and set up alerts based on the normal state, at a level close to the control systems themselves.