Writing Variables on CODESYS PLCs the Hard Way

Most PLCs come with their dedicated IDEs that are used to program and debug them. The debug functionality is of special interest to us at ENLYZE because once we’ve reverse-engineered the underlying network protocol, we can use it to programmatically read and write values on PLCs. In some cases, PLCs also have a way to expose variables over an official interface, but often these interfaces are more limited and may not be enabled, so debug interfaces generally expose the most functionality. While PLC protocols are all a little bit different, simple read and write operations often share a lot in common: Read requests often specify variables with a logical identifier or an absolute offset. In some cases, they also contain the expected type or size of the variable. The PLC will use that information to directly look up the corresponding value in its memory and send the result back to the IDE. As one might expect, write requests tend to contain the same information as read requests in addition to the value that should be written.

Reading Variables on CODESYS PLCs

A while ago we reverse-engineered the network protocol used by CODESYS 3.5 PLCs. In particular, we started by looking at how reads are done. We weren’t surprised: A read request contains information for one or more variables comprised of the size of the variable, its type, and its location. Each location is comprised of an area and an offset. Additionally, each variable is associated with a unique identifier which is echoed in the response next to the variable’s value.

Let’s take a brief look at how this information is encoded. At this point in the network stack, messages are encoded as “tag messages”. There are two types of tags: Simple tags contain some bytes directly translated from an integer, string, or some other data. Complex tags are containers for more tags and can be nested arbitrarily deep. Each tag is prefixed with a number used to identify it. For read requests, each variable is described with a complex tag containing five simple tags:

Tag(id=130, length=36):                   <-- read request item
    Tag(id=1, length=2): 0100             <-- id: 1
    Tag(id=2, length=4): 02000000         <-- variable size: 2
    Tag(id=3, length=4): 07000000         <-- variable type: 7 <-- Int
    Tag(id=4, length=2): 0700             <-- real length of the next field: 7
    Tag(id=5, length=8): 034a400100060000 <-- variable location + padding

The variable location can be further split up:

03 <-- constant
  4a400100 <-- variable offset: 0x0001404a
          06 <-- constant
            00 <-- variable area: 0x00
              00 <-- padding

There are some constants here that we’re not quite sure about, but this isn’t actually that uncommon. Proprietary protocols contain all sorts of weird constants all the time, so we didn’t give this much thought. We identified all the important bits and if we wanted to read out a different variable, we knew exactly what tag values to change.

Writing

While our main business at ENLYZE is reading from PLC variables, we’ve recently begun exploring writing to them as well. So last week I dusted off my CODESYS installation(s), started up our tooling, and investigated how the IDE does debug writes. The first, most obvious difference between read requests and write requests was that writes don’t have as many tags, but just two. Even worse, the first one of these just contained information about the targeted application (CODESYS PLCs can have more than one application running), so all information about the variable and value had to be in the other tag. The good news was that at least some of the content was recognizable:

0000 <-- id?
    02000000 <-- variable size: 2
            3713 <-- value to write: 0x1337
                1d00 <-- size of next field: 29
          ????? --> 151000039c3901000600171009041b060001000017080908170c090404
                                                                 padding -->  00

The bad news is twofold: 1. There’s this big blob which we don’t understand and 2. this blob is different for different CODESYS PLCs, there are at least two variations. Obviously, we want to avoid any mistakes while writing values because these mistakes could have real, physical consequences. It became clear that we needed to do a full investigation on the blob.

Blob

Here’s what we found out: The blob contains instructions for a turing-complete stack-based virtual machine that, when evaluated, copies the value from the request into the PLC’s memory. Let that sink in. Instead of just specifying the value and destination in the request like is done for most other PLCs, the request instead contains more than a dozen instructions to describe this very simple write operation:

00: 15 1000     FAlloc 0x0010
03: 03 9c390100 Ld32   0x0001399c
08: 06 00       Rao    0x00
0a: 17 10       FAddrB 0x10
0c: 09 04       Der    0x04
0e: 1b 0600     BnZ    0x0006
11: 01 00       Ld8    0x00
13: 00          Halt
14: 17 08       FAddrB 0x08
16: 09 08       Der    0x08
18: 17 0c       FAddrB 0x0c
1a: 09 04       Der    0x04
1c: 04          Copy

Here’s what the individual instructions do:

We’re still not entirely sure about the meaning of the FAlloc instruction, but it’s clear that together with FAddrB, it’s used to load values for the write request.

00: 15 1000     FAlloc 0x0010
03: 03 9c390100 Ld32   0x0001399c <-- Push variable offset.
08: 06 00       Rao    0x00       <-- Combine with variable area to get an address.
0a: 17 10       FAddrB 0x10
0c: 09 04       Der    0x04
0e: 1b 0600     BnZ    0x0006
11: 01 00       Ld8    0x00
13: 00          Halt
14: 17 08       FAddrB 0x08       <-- Push address of address to value in the write request.
16: 09 08       Der    0x08       <-- Load address to value in the write request.
18: 17 0c       FAddrB 0x0c       <-- Push address of variable size in write request.
1a: 09 04       Der    0x04       <-- Load variable size from write request.
1c: 04          Copy              <-- Do the write.

Interestingly, right after the address of the variable is resolved, there’s an assertion that a value at function memory offset 0x10 is not 0.

00: 15 1000     FAlloc 0x0010
03: 03 9c390100 Ld32   0x0001399c
08: 06 00       Rao    0x00
0a: 17 10       FAddrB 0x10       <-- Push relative address to offset 0x10.
0c: 09 04       Der    0x04       <-- Load value.
0e: 1b 0600     BnZ    0x0006     <-- Skip after assertion if the value was not 0. >--V
11: 01 00       Ld8    0x00       <-- Push error code 0.                              |
13: 00          Halt              <-- Abort execution.                                |
14: 17 08       FAddrB 0x08       <---------------------------------------------------<
16: 09 08       Der    0x08
18: 17 0c       FAddrB 0x0c
1a: 09 04       Der    0x04
1c: 04          Copy

Removing this assertion stops the write request from succeeding. Our guess is that the instructions are executed at least twice: Once with the value set to 0 and once with the value being set to some other value. The PLC seems to check that execution aborts if the value is not 0. We’re not sure why this exists.

Blob Variation

As previously mentioned, we observed two blob variants for different CODESYS PLC models. Now that we know that these blobs encode instructions, let’s look at how the instructions differ:

00: 15 1000     FAlloc 0x0010     | 00: 15 0c00     FAlloc 0x000c     <-- Changed
03: 03 9c390100 Ld32   0x0001399c | 03: 03 9c390100 Ld32   0x0001399c
08: 06 00       Rao    0x00       | 08: 06 00       Rao    0x00
0a: 17 10       FAddrB 0x10       | 0a: 17 0c       FAddrB 0x0c       <-- Changed
0c: 09 04       Der    0x04       | 0c: 09 04       Der    0x04
0e: 1b 0600     BnZ    0x0006     | 0e: 1b 0600     BnZ    0x0006
11: 01 00       Ld8    0x00       | 11: 01 00       Ld8    0x00
13: 00          Halt              | 13: 00          Halt
14: 17 08       FAddrB 0x08       | 14: 17 04       FAddrB 0x04       <-- Changed
16: 09 08       Der    0x08       | 16: 09 04       Der    0x04       <-- Changed
18: 17 0c       FAddrB 0x0c       | 18: 17 08       FAddrB 0x08       <-- Changed
1a: 09 04       Der    0x04       | 1a: 09 04       Der    0x04
1c: 04          Copy              | 1c: 04          Copy

It looks like the value containing the value address in the function memory shrunk from 8 bytes to 4 bytes. Accordingly, the load size was reduced from 8 bytes to 4 bytes and the offsets had to be adjusted. It turns out there’s a good explanation for this: The first blob we’ve seen was sent to a PLC simulator running in 64-bit mode, while the second blob was sent to a much older version of the same simulator running in 32-bit mode. It makes sense that a value containing an address would shrink from 8 bytes to 4 bytes when switching from 64-bit mode to 32-bit mode.

Compatibility

Now that we understand why the IDE sends different blobs to different CODESYS PLC models, we still have one problem left: How do we figure out the correct instructions for a given PLC? As far as we can tell, the IDE figures this out by hard coding for each PLC whether it runs in 32-bit or 64-bit mode. This isn’t feasible for us, we don’t want to have to update our code every time we see a new PLC at a customer.

Instead, we were interested in a different strategy: If we could come up with a sequence of instructions that gets executed differently by the 32-bit PLCs and 64-bit PLCs, we could send both variations and jump to the correct one at runtime. Luckily for us, such a sequence exists: If you’re familiar with other stack-based virtual machines such as the JVM (Java Virtual Machine), you may know that some VMs choose to represent one 64-bit value as two 32-bit stack values. The reason for this is simple: On 32-bit systems, most values including addresses will be 32-bit so it makes sense to use 32-bit as the default size to avoid wasting memory. If 64-bit values are needed, they can still be represented by two 32-bit stack values. On 64-bit systems, however, 32-bit is not a good default size because 32-bit stack values can’t be used to represent 64-bit addresses natively. In addition to that, the larger register sizes of 64-bit systems make the overhead of 64-bit values much lower. For those reasons, 64-bit may be the better choice for stack values when running in 64-bit mode. An important thing to note here is that the stack value size is usually not directly exposed to the programmer. They simply use special instructions that operate on 64-bit values so they shouldn’t have to care about whether it operates on two 32-bit stack values or one 64-bit stack value 1. As long as they don’t mix the instructions for 32-bit and 64-bit values when accessing the same value, different stack value sizes shouldn’t be noticeable. All of this means that if we carefully mix instructions operating on 32-bit values and 64-bit values, we can exploit differences in the stack value size to figure out whether the instructions run on a 32-bit PLC or a 64-bit PLC.

Let’s take a look at some code:

Ld8    0x01      <-- Always pushes one stack value.
Ld8    0x00      <-- Always pushes one stack value.
Ld8    0xff      <-- Always pushes one stack value.
Pop64            <-- Pops two stack values (32-bit) or one stack value (64-bit).
BnZ    <offset>

The Pop64 instruction always pops the first stack value, 0xff, from the stack. If the instruction is executed in 32-bit mode, it also pops the second stack value, 0x00, from the stack. As a result, after the Pop64 instruction, the next value on the stack is 1 in 32-bit mode and 0 in 64-bit mode. The BnZ can be used to branch to a different instruction depending on whether or not the value is 0.

We can use this to build some instructions that correctly execute a write on both 32-bit and 64-bit PLCs:

# Exploit instruction differences to branch on 32-bit PLCs.
Ld8    0x01
Ld8    0x00
Ld8    0xff
Pop64
BnZ    32_bit_label

# Write instructions for 64-bit PLCs.
FAlloc 0x0010
Ld32   0x0001399c
Rao    0x00
FAddrB 0x10
Der    0x04
BnZ    0x0006
Ld8    0x00
Halt
FAddrB 0x08
Der    0x08
FAddrB 0x0c
Der    0x04
Copy
# Skip 32-bit code.
Ld8    0x01
BnZ    end_label

# Write instructions for 32-bit PLCs.
32_bit_label:
FAlloc 0x000c
Ld32   0x0001399c
Rao    0x00
FAddrB 0x0c
Der    0x04
BnZ    0x0006
Ld8    0x00
Halt
FAddrB 0x04
Der    0x04
FAddrB 0x08
Der    0x04
Copy

end_label:

Nice!

Revisiting Reads

We can use our new-found knowledge to explain the mysterious constants in read request items. The read request items also contain VM instructions and the constants we saw were instruction bytes:

00: 03 4a400100 Ld32 0x0001404a <-- Push variable offset.
05: 06 00       Rao  0x00       <-- Combine with variable area to get an address.

Interestingly for reads the Copy instruction is not used. Instead, the instructions leave the address to read from on the top of the stack.

Conclusion

Network protocols used by PLCs can be plenty complicated, but usually the information in the messages transmitted for reads and writes is fairly simple. This is not the case for CODESYS PLCs: Here reads and writes contain powerful instructions that can be used to express complex logic. We can use this to our advantage to run different code for different PLC models.


  1. This is where the analogy to the JVM breaks down. In the JVM the stack value size is not an implementation detail, but part of the spec. It explicitly defines 64-bit values as being represented using two 32-bit values. Accordingly, the pop2 instruction is explicitly allowed to be used to pop either two 32-bit values or one 64-bit value.

    [return]