Talos Vulnerability Report

TALOS-2024-2004

OpenPLC OpenPLC_v3 OpenPLC Runtime EtherNet/IP PCCC out-of-bounds read vulnerability

September 18, 2024
CVE Number

CVE-2024-36980,CVE-2024-36981

SUMMARY

An out-of-bounds read vulnerability exists in the OpenPLC Runtime EtherNet/IP PCCC parser functionality of OpenPLC_v3 b4702061dc14d1024856f71b4543298d77007b88. A specially crafted network request can lead to denial of service. An attacker can send a series of EtherNet/IP requests to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

OpenPLC _v3 b4702061dc14d1024856f71b4543298d77007b88

PRODUCT URLS

OpenPLC_v3 - https://github.com/thiagoralves/OpenPLC_v3

CVSSv3 SCORE

7.5 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CWE

CWE-125 - Out-of-bounds Read

DETAILS

OpenPLC is an open-source programmable logic controller (PLC) designed to provide a low cost option for automation. The Runtime can be deployed on a variety of platforms including Windows, Linux, and various microcontrollers. Common uses for OpenPLC include home automation and industrial security research.

When a PCCC request with an unsupported command/function pair is sent to the runtime, an error is raised within the controller by returning the value -1.

/* Determine the Command that is being requested to execute */
uint16_t Command_Protocol(pccc_header header, unsigned char *buffer, int buffer_size)
{
    uint16_t var_pccc_length;

    /*If Statement to determine the command code from the Command Packet*/
    if(((unsigned int)*header.HD_CMD_Code == 0x0f) && ((unsigned int)*header.HD_Data_Function_Code == 0xA2))//Protected Logical Read
    {	
        var_pccc_length = Protected_Logical_Read_Reply(header,buffer,buffer_size);
        return var_pccc_length;
    }
    else if(((unsigned int)*header.HD_CMD_Code == 0x0f) && ( ((unsigned int)*header.HD_Data_Function_Code == 0xAA) || ((unsigned int)*header.HD_Data_Function_Code == 0xAB)))//Protected Logical Write
    {	
        var_pccc_length = Protected_Logical_Write_Reply(header,buffer,buffer_size);
        return var_pccc_length;
    }
    else
    {
        /*initialize logging system*/
        char log_msg[1000];
        sprintf(log_msg, "PCCC: Unsupportedd Command/Data Function Code!\n");
        log(log_msg); 
        return -1;
    }//return length as -1 to signify that the CMD Code/Function Code was not recognize
}

That length of -1 gets returned into the ParsePCCCData function, and subsequently returned again

uint16_t ParsePCCCData(unsigned char *buffer, int buffer_size)
{	
    /*Variables*/
    int new_pccc_length; //Variable for new PCCC length
    pccc_header header;

    header.HD_CMD_Code = &buffer[0];//[1] -> Command Code
    header.HD_Status = &buffer[1];////[1] -> Status Code
    header.HD_TransactionNum = &buffer[2];//[2] -> Transaction Number
    header.HD_Data_Function_Code = &buffer[4];//[1] -> Data Function Code

    /*Determine what command is being requested*/
    new_pccc_length = Command_Protocol(header,buffer,buffer_size);

    return new_pccc_length; //Return the new pccc length
}

The processPCCCMessage function then proceeds to return the length back to the encapsulating EtherNet/IP processing.

//This function takes in the data from enip.cpp and places the data in the appropriate structure variables
uint16_t processPCCCMessage(unsigned char *buffer, int buffer_size)
{
    /* Variables */
    int new_pccc_length; //New PCCC Length
    pccc_header header;
    header.Data = buffer;
    header.Data_Size = buffer_size;

    /*Determine the new pccc length*/
    new_pccc_length = ParsePCCCData(buffer,buffer_size);
    return new_pccc_length;	 //Return the length to enip.cpp
}

processPCCCMessage is called from three places where the underlying issue is introduced: * Twice in the sendRRData processing * Once in the sendUnitData processing

In all three locations the length value returned by processPCCCMessage is cast as a uint16_t and subsequently compared to the signed value -1 to check for an error case.

//send pccc Data to pccc.cpp to be parsed and craft response
// returns the new PCCC data size
uint16_t newPcccSize = processPCCCMessage(pcccData, currentPcccSize);
if (newPcccSize == -1)
	return -1;	//error in PCCC.cpp

The issue arises here as a uint16_t is being compared with a signed value that isn’t cast, which is always going to cause the if statement to fail, bypassing the error in PCCC.cpp return. When this occurs, execution is allowed to continue with a newPcccSize variable containing the value 0xFFFF.

CVE-2024-36980 - sendRRData enipType 2

When this occurs in a SendRRData request with an enipType of 0x02, the large newPcccSize value is used directly in a memmove call after being increased by 0x07.

int sendRRData(int enipType, struct enip_header *header, struct enip_data_Unknown *enipDataUnknown, struct enip_data_Unconnected *enipDataUnconnected, struct enip_data_Connected *enipDataConnected)
{
    if (enipType == 1)
    {	
        ...
    }
    else if (enipType == 2)
    {	
        ...

        //send pccc Data to pccc.cpp to be parsed and craft response
        // returns the new PCCC data size
        uint16_t newPcccSize = processPCCCMessage(pcccData, currentItem2Size - 13); // get length of new pccc size
        if (newPcccSize == -1)                                                                                                     [1]
            return -1;	//error in PCCC.cpp

        ...
        
        //move data forward
        memmove(&enipDataUnconnected->request_path[2], enipDataUnconnected->requestor_idLength, newPcccSize + 7);//11);
        
        ...
    }

Since the third parameter of memmove is typed as a size_t this allows the count to be set to the value 0x10006.

Thread 5 "openplc" hit Breakpoint 1, __memmove_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:211
211     ../sysdeps/aarch64/multiarch/../memcpy.S: No such file or directory.
(gdb) i r
x0             0xffff817ac114      281472854049044                                 // memmove param 1 (*dst)
x1             0xffff817ac116      281472854049046                                 // memmove param 2 (*src)
x2             0x10006             65542                                           // memmove param 3 (count)
...
x30            0xaaaac25ffaf0      187650382232304
sp             0xffff817ab630      0xffff817ab630
pc             0xffff82867cd0      0xffff82867cd0 <__memmove_generic>
cpsr           0x60001000          [ EL=0 BTYPE=0 SSBS C Z ]
fpsr           0x0                 [ ]
fpcr           0x0                 [ RMode=0 ]
pauth_dmask    0x7f000000000000    35747322042253312
pauth_cmask    0x7f000000000000    35747322042253312
(gdb) 

This creates a memmove call similar to the following:

memmove(0xffff817ac114, 0xffff817ac116, 0x10006)

With such a large count value, memmove eventually attempts to access data outside of the memory region containing the PCCC request.

Thread 8 "openplc" hit Breakpoint 2, __memcpy_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:184                                                                              
184     in ../sysdeps/aarch64/multiarch/../memcpy.S                                                                                                                                   
(gdb) i r
x0             0xffffa515c114      281473451409684
x1             0xffffa515ffd2      281473451425746
x2             0xc0fa              49402
x3             0xffffa515ff90      281473451425680
x4             0xffffa516c11c      281473451475228
x5             0xffffa516c11a      281473451475226
x6             0x0                 0
x7             0x0                 0
x8             0x0                 0
x9             0x0                 0
...
(gdb) x/i $pc
=> 0xffffa6a27c7c <__memcpy_generic+316>:       ldp     x8, x9, [x1, #32]
(gdb) 

In the ldp instruction above, 0x08 bytes starting from address 0xFFFFA515FFF2 ($x1+32) are loaded into $x8. Subsequently another 0x08 bytes are attempted to be loaded from address 0xFFFFA515FFFA ($x1+32+8) into $x9.

Inspecting the process’ memory map reveals the following two regions of note:

user@machine:$ cat /proc/705332/maps
...
ffffa4960000-ffffa5160000 rw-p 00000000 00:00 0 
ffffa5160000-ffffa5170000 ---p 00000000 00:00 0 
...

In the second operation of the ldp instruction above, the tail bytes are being read from the second of the two memory regions listed. Since this region does not have read permissions, a SIGSEV is thrown and the runtime crashes.

Crash Information

(gdb) stepi
Thread 8 "openplc" received signal SIGSEGV, Segmentation fault.
__memcpy_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:184
184     in ../sysdeps/aarch64/multiarch/../memcpy.S
(gdb) bt
#0  __memcpy_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:184
#1  0x0000aaaaded7faf0 in sendRRData(int, enip_header*, enip_data_Unknown*, enip_data_Unconnected*, enip_data_Connected*) ()
#2  0x0000aaaaded8000c in processEnipMessage(unsigned char*, int) ()
#3  0x0000aaaaded930c8 in processMessage(unsigned char*, int, int, int) ()
#4  0x0000aaaaded93228 in handleConnections(void*) ()
#5  0x0000ffffa6a0d5c8 in start_thread (arg=0x0) at ./nptl/pthread_create.c:442
#6  0xf0e00000ffffa6a7 in ?? ()
Backtrace stopped: previous frame identical to this frame (corrupt stack?)
(gdb) i r
x0             0xffffa515c114      281473451409684
x1             0xffffa515ffd2      281473451425746
x2             0xc0fa              49402
x3             0xffffa515ff90      281473451425680
x4             0xffffa516c11c      281473451475228
x5             0xffffa516c11a      281473451475226
x6             0x0                 0
x7             0x0                 0
x8             0x0                 0
x9             0x0                 0
x10            0x0                 0
x11            0x0                 0
x12            0x0                 0
x13            0x0                 0
x14            0x4                 4
x15            0x65687420726f6620  7307218078116308512
x16            0xaaaadedc0cc0      187650860125376
x17            0xffffa6a27cd0      281473477410000
x18            0x0                 0
x19            0x0                 0
x20            0xffffa515f4fc      281473451422972
x21            0xffffa596e2be      281473459872446
x22            0x80e920            8448288
x23            0xffffa596e2bf      281473459872447
x24            0x0                 0
x25            0xffffa4950000      281473442971648
x26            0x80e920            8448288
x27            0xffffa596f0e0      281473459876064
x28            0xffffa4950000      281473442971648
x29            0xffffa515b630      281473451406896
x30            0xaaaaded7faf0      187650859858672
sp             0xffffa515b630      0xffffa515b630
pc             0xffffa6a27c7c      0xffffa6a27c7c <__memcpy_generic+316>
cpsr           0x20201000          [ EL=0 BTYPE=0 SSBS SS C ]
fpsr           0x0                 [ ]
fpcr           0x0                 [ RMode=0 ]
pauth_dmask    0x7f000000000000    35747322042253312
pauth_cmask    0x7f000000000000    35747322042253312
(gdb) 

Mitigation

Update your version of OpenPLC one that has this issue patched. If that is not possible, modify the source code to cast -1 to a matching type in the affected comparision, similar to the snippet below.

uint16_t newPcccSize = processPCCCMessage(pcccData, currentItem2Size - 13); // get length of new pccc size
if (newPcccSize == (uint16_t) -1)
    return -1;  //error in PCCC.cpp

CVE-2024-36981 - sendUnitData

When this occurs in a SendUnitData request, the large newPcccSize value is used directly in a memmove call after being increased by 0x07.

int sendUnitData(struct enip_header *header, struct enip_data_Connected_0x70 *enipDataConnected_0x70)
{
    ...
    
    //send pccc Data to pccc.cpp to be parsed and craft response
    // returns the new PCCC data size
    uint16_t newPcccSize = processPCCCMessage(pcccData, currentPcccSize);
    if (newPcccSize == -1)                                                                                                     [2]
        return -1;	//error in PCCC.cpp

    ...

    //move data forward
    memmove(&enipDataConnected_0x70->request_path[2], enipDataConnected_0x70->requestor_id, newPcccSize + 7);

    ...
}

Since the third parameter of memmove is typed as a size_t this allows the count to be set to the value 0x10006.

Thread 5 "openplc" hit Breakpoint 1, 0x0000aaaab8d8fc9c in sendUnitData(enip_header*, enip_data_Connected_0x70*) ()
(gdb) b memmove
Breakpoint 2 at 0xffff9e987cd0: memmove. (2 locations)
(gdb) c
Continuing.

Thread 5 "openplc" hit Breakpoint 2, __memmove_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:211
211     ../sysdeps/aarch64/multiarch/../memcpy.S: No such file or directory.
(gdb) i r
x0             0xffff9e0dc11a      281473333444890                                 // memmove param 1 (*dst)
x1             0xffff9e0dc11c      281473333444892                                 // memmove param 2 (*src)
x2             0x10006             65542                                           // memmove param 3 (count)
...
x30            0xaaaab8d8fe7c      187650222390908
sp             0xffff9e0db660      0xffff9e0db660
pc             0xffff9e987cd0      0xffff9e987cd0 <__memmove_generic>
cpsr           0x60001000          [ EL=0 BTYPE=0 SSBS C Z ]
fpsr           0x0                 [ ]
fpcr           0x0                 [ RMode=0 ]
pauth_dmask    0x7f000000000000    35747322042253312
pauth_cmask    0x7f000000000000    35747322042253312
(gdb)

This creates a memmove call similar to the following:

memmove(0xffff9e0dc11a, 0xffff9e0dc11c, 0x10006)

With such a large count value, memmove eventually attempts to access data outside of the memory region containing the PCCC request.

(gdb) c                                                                                   
Continuing.                                                                               
[Thread 0xffff9c8af0e0 (LWP 716796) exited]                                               

Thread 5 "openplc" hit Breakpoint 3, __memcpy_generic () at ../sysdeps/aarch64/multiarch/.
./memcpy.S:184                                                                            
184     in ../sysdeps/aarch64/multiarch/../memcpy.S                                       
(gdb) i r
x0             0xffff9e0dc11a      281473333444890
x1             0xffff9e0dffd2      281473333460946
x2             0xc100              49408
x3             0xffff9e0dff90      281473333460880
x4             0xffff9e0ec122      281473333510434
x5             0xffff9e0ec120      281473333510432
x6             0x0                 0
x7             0x0                 0
x8             0x0                 0
x9             0x0                 0
...
(gdb) x/i $pc
=> 0xffff9e987c7c <__memcpy_generic+316>:       ldp     x8, x9, [x1, #32]
(gdb)

In the ldp instruction above, 0x08 bytes starting from address 0xFFFF9E0DFFF2 ($x1+32) are loaded into $x8. Subsequently another 0x08 bytes are attempted to be loaded from address 0xFFFF9E0DFFFA ($x1+32+8) into $x9.

user@machine:$ cat /proc/716795/maps
...
ffff9d8e0000-ffff9e0e0000 rw-p 00000000 00:00 0 
ffff9e0e0000-ffff9e0f0000 ---p 00000000 00:00 0 
...

In the second operation of the ldp instruction above, the tail bytes are being read from the second of the two memory regions listed. Since this region does not have read permissions, a SIGSEV is thrown and the runtime crashes.

Crash Information

Thread 5 "openplc" hit Breakpoint 3, __memcpy_generic () at ../sysdeps/aarch64/multiarch/.
./memcpy.S:184                                                                            
184     in ../sysdeps/aarch64/multiarch/../memcpy.S                                       
(gdb) stepi                                                                               

Thread 5 "openplc" received signal SIGSEGV, Segmentation fault.                           
__memcpy_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:184
184     in ../sysdeps/aarch64/multiarch/../memcpy.S
(gdb) i r
x0             0xffff9e0dc11a      281473333444890
x1             0xffff9e0dffd2      281473333460946
x2             0xc100              49408
x3             0xffff9e0dff90      281473333460880
x4             0xffff9e0ec122      281473333510434
x5             0xffff9e0ec120      281473333510432
x6             0x0                 0
x7             0x0                 0
x8             0x0                 0
x9             0x0                 0
x10            0x0                 0
x11            0x0                 0
x12            0x0                 0
x13            0x0                 0
x14            0xa                 10
x15            0x65687420726f6620  7307218078116308512
x16            0xaaaab8dd0cc0      187650222656704
x17            0xffff9e987cd0      281473342536912
x18            0x0                 0
x19            0x0                 0
x20            0xffff9e0df4fc      281473333458172
x21            0xffff9d8ce2be      281473324999358
x22            0x80e920            8448288
x23            0xffff9d8ce2bf      281473324999359
x24            0x0                 0
x25            0xffff9d8d0000      281473325006848
x26            0x80e920            8448288
x27            0xffff9d8cf0e0      281473325002976
x28            0xffff9d8d0000      281473325006848
x29            0xffff9e0db660      281473333442144
x30            0xaaaab8d8fe7c      187650222390908
sp             0xffff9e0db660      0xffff9e0db660
pc             0xffff9e987c7c      0xffff9e987c7c <__memcpy_generic+316>
cpsr           0x20201000          [ EL=0 BTYPE=0 SSBS SS C ]
fpsr           0x0                 [ ]
fpcr           0x0                 [ RMode=0 ]
pauth_dmask    0x7f000000000000    35747322042253312
pauth_cmask    0x7f000000000000    35747322042253312
(gdb) bt
#0  __memcpy_generic () at ../sysdeps/aarch64/multiarch/../memcpy.S:184
#1  0x0000aaaab8d8fe7c in sendUnitData(enip_header*, enip_data_Connected_0x70*) ()
#2  0x0000aaaab8d8ff50 in processEnipMessage(unsigned char*, int) ()
#3  0x0000aaaab8da30c8 in processMessage(unsigned char*, int, int, int) ()
#4  0x0000aaaab8da3228 in handleConnections(void*) ()
#5  0x0000ffff9e96d5c8 in start_thread (arg=0x0) at ./nptl/pthread_create.c:442
#6  0xf0e00000ffff9e9d in ?? ()
Backtrace stopped: previous frame identical to this frame (corrupt stack?)
(gdb) 

Mitigation

Update your version of OpenPLC one that has this issue patched. If that is not possible, modify the source code to cast -1 to a matching type in the affected comparision, similar to the snippet below.

uint16_t newPcccSize = processPCCCMessage(pcccData, currentPcccSize);
if (newPcccSize == (uint16_t) -1)
    return -1;  //error in PCCC.cpp
TIMELINE

2024-06-10 - Initial Vendor Contact
2024-06-10 - Vendor Disclosure
2024-09-17 - Vendor Patch Release
2024-09-18 - Public Release

Credit

Discovered by Jared Rittle of Cisco Talos.