Skip to main content

Command Palette

Search for a command to run...

Observing Baseline vs Anomalous Modbus Traffic: A Beginner’s ICS/OT Security Lab

Published
7 min read
Observing Baseline vs Anomalous Modbus Traffic: A Beginner’s ICS/OT Security Lab

Introduction

In support of my recent research into ICS/OT Security Operations, I wanted to set up a small, controlled environment where I could

Coming from an IT and security operations background, one of the biggest mindset shifts in OT security is learning to prioritize visibility, context, and operational impact over traditional ideas of exploitation or compromise. This lab was designed to help bridge that gap in a practical, beginner-friendly way.

The goal in the following lab is not to simulate a realistic attack, but to:

  • Observe what normal OT traffic looks like

  • Introduce a small deviation from that baseline

  • Analyze the change using packet-level evidence

  • Practice thinking like an OT security analyst

This walkthrough assumes basic familiarity with Python, Wireshark, and packet analysis, but no deep prior experience with ICS/OT protocols.

If you’re already feeling lost and need an introduction to ICS/OT Security Operations, I would point you to two helpful resources:


Lab Overview

To keep the environment simple and focused, I used a simulated Modbus TCP PLC (Programmable Logic Controller) and a lightweight client acting as an HMI (Human Machine Interface) or polling system.

Modbus TCP was chosen due to its simplicity, widespread use, and lack of native authentication, making it a useful protocol for learning visibility and detection fundamentals.

Components

  • Simulated PLC: Python-based Modbus TCP server

  • Client/HMI: Python-based Modbus TCP client

  • Monitoring Tool: Wireshark

  • Protocol: Modbus TCP

  • Port: 5020

Both the server and client were run on the same host using the loopback interface (127.0.0.1). This allowed me to focus on protocol behavior and detection logic, rather than network topology.


Environment Setup

I created:

  • A Python script acting as a PLC, exposing holding registers: plc_server.py

    •   from pymodbus.server import StartTcpServer
        from pymodbus.datastore import ModbusSequentialDataBlock
        from pymodbus.datastore import ModbusDeviceContext, ModbusServerContext
      
        def main() -> None:
            device = ModbusDeviceContext(
                di=ModbusSequentialDataBlock(0, [0]*100),
                co=ModbusSequentialDataBlock(0, [0]*100),
                hr=ModbusSequentialDataBlock(0, [0]*100),
                ir=ModbusSequentialDataBlock(0, [0]*100)
            )
      
            context = ModbusServerContext(devices={1: device}, single=False)
      
            print("Starting simulated PLC on 0.0.0.0:5020")
            StartTcpServer(context=context, address=("0.0.0.0", 5020))
      
        if __name__ == "__main__":
            main()
      

A second Python script acting as a client, polling those registers every few seconds: modbus_client.py

import time
from pymodbus.client import ModbusTcpClient

def main() -> None:
    client = ModbusTcpClient("127.0.0.1", port=5020)
    if not client.connect():
        raise RuntimeError("Could not connect to PLC server")

    try:
        while True:
            rr = client.read_holding_registers(address=0, count=10, device_id=1)
            if rr.isError():
                print("Read error:", rr)
            else:
                print("Read registers:", rr.registers)
            time.sleep(5)
    finally:
        client.close()

if __name__ == "__main__":
    main()

I ran both the server and the client on Loopback Interface (127.0.0.1)

This established a predictable, repeatable traffic pattern.


Observing the Baseline

With the client polling the PLC, I was able to view the following baseline registers in the client command line:

Read registers: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Read registers: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Read registers: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Read registers: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Read registers: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Using the filter “tcp.port == 5020” on the loopback interface in Wireshark, I was able to capture baseline traffic.

Baseline Characteristics

In the baseline capture, I observed:

  • Repeating Modbus TCP transactions

  • Function Code 03 (Read Holding Registers)

  • Regular polling intervals (~every 5 seconds)

  • No write or control commands

  • A single client communicating with the PLC

This is a common pattern in OT environments, where HMIs or monitoring systems frequently poll PLCs for state information.

Establishing this baseline is critical. Without it, there is no meaningful way to identify deviations.


Introducing Anomalous Behaviour

To simulate an anomaly, I modified the client so that every 30 seconds it would issue a write to a holding register.

To do this, within the While Loop, after “time.sleep(5)” and before “finally", I added the following clause to modbus_client.py:

            if int(time.time()) % 30 == 0:
                wr = client.write_register(address=5, value=123, device_id=1)
                print("Write register 5 -> 123:", "OK" if not wr.isError() else wr)

This represents a scenario such as:

  • A misconfigured HMI

  • Maintenance activity outside an expected window

  • An unauthorized system interacting with the PLC

This is not an exploit - it is simply control-plane activity that deviates from the baseline.


Observing the Anomaly

In the anomaly capture, the baseline read traffic continued unchanged. However, every 30 seconds an additional Modbus TCP packet appeared.

The Modbus payload of the anomalous packet was:

00790000000601060005007b

This packet did not appear in the baseline capture thus it would warrant review by a Security Analyst or alerting logic designed to flag unexpected control-plane activity.


Breaking Down the Packet

Decoding the Modbus TCP payload reveals the nature of the anomaly.

FieldValueMeaning
Transaction ID0x0079Request identifier
Protocol ID0x0000Modbus
Length0x0006Bytes following
Unit ID0x01Device ID
Function Code0x06Write Single Register
Register Address0x0005Register 5
Value0x007bDecimal 123

Function Code 06 indicates a Write Single Register operation.

This is a key distinction:

  • Baseline traffic = observation (reads)

  • Anomalous traffic = influence (writes)


Why This Matters in an OT Context

In many industrial networks:

  • PLCs are expected to service read-only requests during normal operations

  • Write operations are restricted to specific systems or maintenance windows

  • Unexpected writes are treated as high-signal events

While this activity could be benign, it would typically prompt questions such as:

  • Is this write expected?

  • Who is authorized to issue it?

  • Is there an active maintenance task?

  • Has something changed in the environment?

From a security operations perspective, the goal is not to immediately assume malicious intent, but to validate context and reduce uncertainty.


Mapping to MITRE ATT&CK for ICS

This behavior aligns with MITRE ATT&CK for ICS techniques related to unauthorized control or manipulation of control devices, where write operations are issued outside expected operational context.

While this lab does not simulate a full attack chain, it demonstrates how:

  • Low-level protocol activity

  • Combined with baseline understanding

  • Can surface behaviors that warrant further investigation

In a real environment, additional telemetry such as asset inventories, engineering workstation logs, and change records would be used to bolster the investigation.


Lessons Learned

This exercise reinforced several key ideas for someone new to ICS/OT security:

  1. Baseline is everything
    Without understanding normal behavior, anomalies are meaningless.

  2. OT security is context-driven
    The same packet can be benign or serious depending on timing, role, and authorization.

  3. Simple deviations can be high-signal
    A single write command can matter more than thousands of read requests.

  4. Detection starts with visibility, not exploits
    Packet-level inspection remains a powerful tool in OT environments.

  5. Frameworks support reasoning, not conclusions
    MITRE ATT&CK helps structure thinking, but does not replace operational judgement.


Closing Thoughts

This small lab helped me better appreciate the analyst mindset required for OT security operations: patient, methodical, and grounded in operational reality. For anyone with an IT or SOC background looking to move into ICS/OT security, setting up a simple protocol-focused lab like this is an effective way to build intuition and confidence.

Until next time…

— Cyber Wizard

NOTE: This lab was inspired by the learning materials linked at the beginning of the article. The lab was designed with guidance and editorial assistance from AI tools.