Skip to content

SetAttributeList performs an out-of-bounds read on a non-settable kCipShortString attribute in the default build via real TCP SendRRData #572

Description

@CarnegieMe

Summary

I found a remotely reachable server-side out-of-bounds read in the current master branch of OpENer.

The bug is triggered in the real TCP explicit-message path (44818 / RegisterSession / SendRRData) when SetAttributeList targets a real, default-registered, non-settable kCipShortString attribute: Identity class, instance 1, attribute 7 (product_name).

The root cause is that SetAttributeList() still tries to skip the incoming attribute value even when the attribute is not settable. For variable-length types, it calls GetCipDataTypeLength(attribute->type, message_router_request->data) on attacker-controlled request data without any remaining-length validation. For kCipShortString, this immediately performs an unbounded 1-byte read of the string length header through GetSintFromMessage().

By crafting a long padded EPath so that message_router_request->data is positioned at the end of the TCP stack receive buffer, the length-header read crosses the right boundary of incoming_message[512] and ASan reports a stack-buffer-overflow (OOB read).

At minimum, this is a remote unauthenticated denial of service in the default build.


Version

  • OpENer v2.3 / master branch up to commit 76b95cf

Affected path

Real request handling path:

main()
  -> executeEventLoop()
  -> NetworkHandlerProcessCyclic()
  -> HandleDataOnTcpSocket()
  -> HandleReceivedExplictTcpData()
  -> HandleReceivedSendRequestResponseDataCommand()
  -> NotifyCommonPacketFormat()
  -> NotifyMessageRouter()
  -> NotifyClass()
  -> SetAttributeList()
  -> GetCipDataTypeLength()
  -> GetSintFromMessage()

Root cause

1) SetAttributeList() reads attribute IDs, then enters the "attribute exists but is not settable" branch

CipUint attribute_count_request = GetUintFromMessage(
  &message_router_request->data);

...

for(size_t j = 0; j < attribute_count_request; j++) {
  attribute_number = GetUintFromMessage(&message_router_request->data);
  attribute = GetCipAttribute(instance, attribute_number);

  ...

  if(NULL != attribute) {

    uint8_t set_bit_mask =
      (instance->cip_class->set_bit_mask[CalculateIndex(attribute_number)]);
    if( 0 != ( set_bit_mask & ( 1 << (attribute_number % 8) ) ) ) {
      AddSintToMessage(kCipErrorSuccess, &message_router_response->message);
      AddSintToMessage(0, &message_router_response->message);
      attribute->decode(attribute->data,
                        message_router_request,
                        message_router_response);
    } else {
      AddSintToMessage(kCipErrorAttributeNotSetable,
                       &message_router_response->message);
      AddSintToMessage(0, &message_router_response->message);

      // move request message pointer
      size_t attribute_data_length = GetCipDataTypeLength(attribute->type,
                                                          message_router_request->data);
      if(0 != attribute_data_length) {
        message_router_request->data += attribute_data_length;
        message_router_response->general_status =
          kCipErrorAttributeListError;
      } else {
        message_router_response->general_status = kCipErrorPartialTransfer;
        return kEipStatusOkSend;
      }
    }
  }
}

The bug is here: for a non-settable attribute, the code still tries to parse/skip the attacker-supplied value from request data, but does not validate whether enough bytes remain.

2) GetCipDataTypeLength() performs a raw length-header read for kCipShortString

case kCipShortString:
  if(NULL != data){
    length = GetSintFromMessage(&data) + 1; // string length + 1 byte length indicator
  }
  break;

This function receives only a pointer, not a remaining-length argument.

3) GetSintFromMessage() is an unchecked 1-byte read

CipSint GetSintFromMessage(const EipUint8 **const buffer) {
  const unsigned char *const buffer_address = (unsigned char *) *buffer;
  EipUint8 data = buffer_address[0];
  *buffer += 1;
  return data;
}

So once message_router_request->data is at the end of the TCP receive buffer, this immediately becomes an OOB read.

4) CreateMessageRouterRequestStructure() stores the post-path pointer as request data

message_router_request->service = *data;
data++;
data_length--;

size_t number_of_decoded_bytes;
const EipStatus path_result =
  DecodePaddedEPath(&(message_router_request->request_path),
                    &data,
                    &number_of_decoded_bytes);

...

message_router_request->data = data;
message_router_request->request_data_size = data_length -
                                            number_of_decoded_bytes;

A long padded EPath can therefore push message_router_request->data very close to the end of the TCP input buffer.

5) The TCP explicit-message receive buffer is stack-backed and 512 bytes by default

CipOctet incoming_message[PC_OPENER_ETHERNET_BUFFER_SIZE] = { 0 };

Default build configuration:

set( OPENER_ETHERNET_BUFFER_SIZE "512" CACHE STRING "Number of bytes used for the Ethernet message buffer")
add_definitions(-DPC_OPENER_ETHERNET_BUFFER_SIZE=${OPENER_ETHERNET_BUFFER_SIZE} )

Trigger condition

A crafted SendRRData request uses a long padded EPath to place message_router_request->data near the end of incoming_message[512].

Then SetAttributeList() consumes:

  • 2 bytes: attribute_count = 1
  • 2 bytes: attribute_id = 7

At that point, the not-settable branch tries to compute the length of the attacker-supplied kCipShortString value by reading its 1-byte length header:

GetCipDataTypeLength(kCipShortString, message_router_request->data)
  -> GetSintFromMessage(&data)

That length-header read crosses the right boundary of incoming_message[512].

Reproduction

POC

poc.zip

Build

cmake -S source -B build \
  -DCMAKE_C_COMPILER=clang \
  -DCMAKE_CXX_COMPILER=clang++ \
  -DOpENer_PLATFORM:STRING=POSIX \
  -DCMAKE_BUILD_TYPE:STRING=RelWithDebInfo \
  -DBUILD_SHARED_LIBS:BOOL=OFF \
  -DOpENer_TRACES:BOOL=ON \
  -DOpENer_TRACE_LEVEL_ERROR:BOOL=ON \
  -DCMAKE_C_FLAGS:STRING="-O1 -g -fno-omit-frame-pointer -fno-optimize-sibling-calls -fsanitize=address,undefined" \
  -DCMAKE_CXX_FLAGS:STRING="-O1 -g -fno-omit-frame-pointer -fno-optimize-sibling-calls -fsanitize=address,undefined" \
  -DCMAKE_EXE_LINKER_FLAGS:STRING="-fsanitize=address,undefined -pthread"

cmake --build build --target OpENer -j"$(nproc)"

Run server

./build-asan/src/ports/POSIX/OpENer ens33

Baseline packet

python3 poc.py baseline 192.168.153.128

Expected baseline response:

reply_service=0x84 general_status=0x0a additional_status=[] payload=010007000e00

This shows that the default build really reaches Identity / SetAttributeList / attribute 7, and that attribute 7 is present but not settable.

Crash packet

python3 poc.py crash 192.168.153.128

Typical client-side result:

response=timeout

Server-side ASan result:

AddressSanitizer: stack-buffer-overflow
#0 GetSintFromMessage
#1 GetCipDataTypeLength
#2 SetAttributeList
...

ASan Outcome

Observed crash:

==84907==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd7cfea830
READ of size 1 at 0x7ffd7cfea830 thread T0
    #0 GetSintFromMessage ... endianconv.c:33
    #1 GetCipDataTypeLength ... ciptypes.c:70
    #2 SetAttributeList ... cipcommon.c:1302
    #3 NotifyClass ... cipcommon.c:127
    #4 NotifyMessageRouter ... cipmessagerouter.c:217
    #5 NotifyCommonPacketFormat ... cpf.c:60
    #6 HandleReceivedSendRequestResponseDataCommand ... encap.c:558
    #7 HandleReceivedExplictTcpData ... encap.c:186
    #8 HandleDataOnTcpSocket ... generic_networkhandler.c:864

ASan identifies the overflown object as the TCP receive stack buffer:

Address 0x7ffd7cfea830 is located in stack of thread T0 at offset 560 in frame
    #0 HandleDataOnTcpSocket ... generic_networkhandler.c:720

  This frame has 6 object(s):
    [48, 560) 'incoming_message' (line 732) <== Memory access at offset 560 overflows this variable

This is exactly consistent with a 1-byte OOB read of the short-string length header beyond incoming_message[512].

Security impact

This issue is reachable in the default build through the real TCP explicit-message path.

The currently demonstrated impact is a reliable remote unauthenticated denial of service through an out-of-bounds read on stack-backed request data.

Although the demonstrated primitive is a read, the code also treats the adjacent out-of-bounds byte as a kCipShortString length header and uses it to advance the request pointer, so the bug should be treated as a real memory-safety issue rather than a benign parse failure.

Why this is distinct from other recently reported issues

This issue is not the same root cause as:

  • CPF payload lifetime / stack-use-after-return issues
  • GetAttributeList() response assembly overflows
  • connected-path underflow into DecodePaddedEPath
  • SetAttributeSingle() cases where an empty value is passed to an attribute decoder

Here, the vulnerability is specifically:

  • in SetAttributeList()
  • in the not-settable attribute branch
  • while trying to skip a variable-length request value
  • through GetCipDataTypeLength(kCipShortString, attacker_controlled_pointer)
  • which performs an unchecked length-header read

##Suggested fix direction

The core problem is that variable-length parsing is done from a raw pointer without a remaining-length argument.

1) Track and validate remaining request bytes inside SetAttributeList()

Instead of only advancing message_router_request->data, keep a local remaining-length variable and decrement it after every read.

Minimal direction:

size_t remaining_request_data_size = message_router_request->request_data_size;

if (remaining_request_data_size < sizeof(CipUint)) {
  message_router_response->general_status = kCipErrorPartialTransfer;
  return kEipStatusOkSend;
}

CipUint attribute_count_request = GetUintFromMessage(&message_router_request->data);
remaining_request_data_size -= sizeof(CipUint);

for (size_t j = 0; j < attribute_count_request; j++) {
  if (remaining_request_data_size < sizeof(CipUint)) {
    message_router_response->general_status = kCipErrorPartialTransfer;
    return kEipStatusOkSend;
  }

  attribute_number = GetUintFromMessage(&message_router_request->data);
  remaining_request_data_size -= sizeof(CipUint);

  ...
}

2) Replace GetCipDataTypeLength() with a checked variant that takes remaining length

Example direction:

EipBool8 GetCipDataTypeLengthChecked(CipDataType type,
                                     const EipUint8 *data,
                                     size_t remaining_bytes,
                                     size_t *out_length) {
  switch(type) {
    case kCipShortString:
      if (remaining_bytes < 1) {
        return false;
      }
      *out_length = (size_t)data[0] + 1;
      if (*out_length > remaining_bytes) {
        return false;
      }
      return true;

    case kCipString:
    case kCipString2:
    case kCipStringN:
      if (remaining_bytes < 2) {
        return false;
      }
      *out_length = (size_t)(data[0] | (data[1] << 8)) + 2;
      if (*out_length > remaining_bytes) {
        return false;
      }
      return true;

    default:
      ...
  }
}

3) In the "attribute not settable" branch, fail if the full value is not present

Instead of blindly doing:

size_t attribute_data_length = GetCipDataTypeLength(attribute->type,
                                                    message_router_request->data);
message_router_request->data += attribute_data_length;

do:

size_t attribute_data_length = 0;
if (!GetCipDataTypeLengthChecked(attribute->type,
                                 message_router_request->data,
                                 remaining_request_data_size,
                                 &attribute_data_length)) {
  message_router_response->general_status = kCipErrorPartialTransfer;
  return kEipStatusOkSend;
}

message_router_request->data += attribute_data_length;
remaining_request_data_size -= attribute_data_length;
message_router_response->general_status = kCipErrorAttributeListError;

4) Optional hardening

A smaller short-term mitigation would be to special-case the not-settable path and reject truncated or malformed values immediately, but the more robust fix is to stop parsing variable-length types anywhere in the stack without carrying a remaining-length parameter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions