Skip to content

Remote stack-based out-of-bounds read in ForwardOpen connection path parsing due to incorrect Production Inhibit Time path-length accounting #571

@CarnegieMe

Description

@CarnegieMe

Summary

A crafted EtherNet/IP ForwardOpen request can trigger a stack-based out-of-bounds read in OpENer's server-side connection-establishment path.

The issue is in ParseConnectionPath() in source/src/cip/cipconnectionmanager.c. The connection path length is tracked in 16-bit words, but the trailing Production Inhibit Time in milliseconds network segment is incorrectly subtracted as 2 words even though the segment only consumes 2 bytes, i.e. 1 word.

When this segment appears as the last word of the connection path, remaining_path underflows from 1 to a very large size_t value. The parser then continues the while (remaining_path > 0) loop and calls GetPathSegmentType() on a pointer that has already advanced past the end of the received TCP message buffer.

With ASan/UBSan enabled, this results in a reproducible stack-buffer-overflow / READ of size 1 in GetPathSegmentType().

Impact

A remote unauthenticated client can trigger this issue through the normal EtherNet/IP TCP/44818 ForwardOpen path after registering a session.

With ASan/UBSan enabled, the issue produces a reliable crash due to a stack-based out-of-bounds read.

In non-sanitized builds, the parser still reads past the stack-backed TCP receive buffer and interprets adjacent stack bytes as an EPath segment header. This is undefined behavior and may result in process instability or denial of service depending on stack layout and runtime conditions.

This should be classified as an out-of-bounds read, not an out-of-bounds write.

Suggested CWE classification:

  • CWE-125: Out-of-bounds Read
  • CWE-191: Integer Underflow

Affected component

  • Component: EtherNet/IP / CIP Connection Manager
  • Service: ForwardOpen
  • Path: unconnected SendRRData -> Message Router -> Connection Manager
  • Affected source files:
    • source/src/ports/POSIX/main.c
    • source/src/ports/generic_networkhandler.c
    • source/src/enet_encap/encap.c
    • source/src/enet_encap/cpf.c
    • source/src/cip/cipconnectionmanager.c
    • source/src/cip/cipepath.c

Version

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

Reachability

The issue is reachable through the normal TCP server path. The PoC connects to the OpENer server on TCP/44818, performs RegisterSession, and then sends a crafted unconnected ForwardOpen request.

Observed call chain:

main
 -> executeEventLoop
 -> NetworkHandlerProcessCyclic
 -> HandleDataOnTcpSocket
 -> HandleReceivedExplictTcpData
 -> HandleReceivedSendRequestResponseDataCommand
 -> NotifyCommonPacketFormat
 -> NotifyMessageRouter
 -> NotifyClass
 -> ForwardOpen
 -> ForwardOpenRoutine
 -> HandleNonNullNonMatchingForwardOpenRequest
 -> ParseConnectionPath
 -> GetPathSegmentType

This is not a parser-only test case. The crash is triggered through the unmodified server's normal network-facing EtherNet/IP handling path.

Root cause

ParseConnectionPath() reads the connection path size as a word count:

EipUint8 ParseConnectionPath(CipConnectionObject *connection_object,
                             CipMessageRouterRequest *message_router_request,
                             EipUint16 *extended_error) {
  const EipUint8 *message = message_router_request->data;
  const size_t connection_path_size = GetUsintFromMessage(&message); /* length in words */

  if(0 == connection_path_size) {
    return kEipStatusError;
  }

  size_t remaining_path = connection_path_size;
  ...

The length check also treats remaining_path as a number of 16-bit words:

if( ( header_length + remaining_path * sizeof(CipWord) ) < message_router_request->request_data_size ) {
  *extended_error = 0;
  return kCipErrorTooMuchData;
}

if( ( header_length + remaining_path * sizeof(CipWord) ) > message_router_request->request_data_size ) {
  *extended_error = 0;
  OPENER_TRACE_INFO("Message not long enough for path\n");
  return kCipErrorNotEnoughData;
}

Therefore, every 2 bytes consumed from the connection path should decrement remaining_path by 1.

This is handled consistently in several other parts of the function. For example, simple data segments store their length in 16-bit words, and the code converts the payload length to bytes before subtracting the corresponding word count:

case kDataSegmentSubtypeSimpleData:
  g_config_data_length = message[1] * 2; /*data segments store length 16-bit word wise */
  g_config_data_buffer = (EipUint8 *) message + 2;
  remaining_path -= (g_config_data_length + 2) / 2;
  message += (g_config_data_length + 2);
  break;

However, the trailing Production Inhibit Time in milliseconds network segment branch contains an inconsistent decrement:

case kNetworkSegmentSubtypeProductionInhibitTimeInMilliseconds:
  if(kConnectionObjectTransportClassTriggerProductionTriggerCyclic !=
     ConnectionObjectGetTransportClassTriggerProductionTrigger(connection_object) ) {
    /* only non cyclic connections may have a production inhibit */
    connection_object->production_inhibit_time = message[1];
    message += 2;
    remaining_path -= 2;
  } else {
    *extended_error = connection_path_size - remaining_path;
    return kCipErrorPathSegmentError;
  }
  break;

The Production Inhibit Time in milliseconds segment only occupies 2 bytes:

byte 0: network segment header
byte 1: PIT value in milliseconds

cipepath.c also treats the milliseconds PIT value as the second byte of the segment:

CipUsint GetPathNetworkSegmentProductionInhibitTimeInMilliseconds(
    const unsigned char *const cip_path) {
  OPENER_ASSERT(kSegmentTypeNetworkSegment == GetPathSegmentType(cip_path) );
  OPENER_ASSERT(kNetworkSegmentSubtypeProductionInhibitTimeInMilliseconds ==
                GetPathNetworkSegmentSubtype(cip_path) );

  return *(cip_path + 1);
}

So the correct accounting should be:

message += 2;
remaining_path -= 1;

The current code instead performs:

message += 2;
remaining_path -= 2;

This consumes 2 bytes but subtracts 2 words.

Failure mechanism

The vulnerable state is reached when the last remaining word in the connection path is a valid Production Inhibit Time in milliseconds network segment.

Before processing the segment:

remaining_path == 1
message points to the final 2-byte PIT segment

The vulnerable branch executes:

connection_object->production_inhibit_time = message[1];
message += 2;
remaining_path -= 2;

After message += 2, message points exactly to the end of the connection path.

But remaining_path -= 2 underflows because remaining_path is an unsigned size_t:

remaining_path: 1 -> SIZE_MAX

The parser then continues:

while(remaining_path > 0) {
  SegmentType segment_type = GetPathSegmentType(message);
  ...
}

The next call to GetPathSegmentType(message) reads one byte from a pointer that has already advanced past the received request buffer.

The crash occurs here:

SegmentType GetPathSegmentType(const CipOctet *const cip_path) {
  const unsigned int kSegmentTypeMask = 0xE0;
  const unsigned int segment_type = *cip_path & kSegmentTypeMask;
  ...
}

At this point, *cip_path is an out-of-bounds read.

Reproduction

Build the unmodified server with ASan/UBSan:

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

Start the server:

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

POC

poc.zip

Run the baseline request:

python3 poc.py baseline 192.168.153.128

Expected result:

general_status=0x00

Run the filler-only control case:

python3 poc.py filler 192.168.153.128

Expected result:

The server returns an error response and remains alive.
The large simple-data filler alone does not crash the server.

Run the crashing case:

python3 poc.py crash 192.168.153.128

Expected result:

The client usually observes response=timeout.
The server aborts with an ASan stack-buffer-overflow report.

ASan/UBSan output

=================================================================
==81949==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffccdbd8cf0 at pc 0x56d8ba5e963d bp 0x7ffccdbd81b0 sp 0x7ffccdbd81a8
READ of size 1 at 0x7ffccdbd8cf0 thread T0
    #0 0x56d8ba5e963c in GetPathSegmentType /home/weichuan/wc/OpENer/source/src/cip/cipepath.c:22:37
    #1 0x56d8ba5d8c87 in ParseConnectionPath /home/weichuan/wc/OpENer/source/src/cip/cipconnectionmanager.c:1624:36
    #2 0x56d8ba5d794f in HandleNonNullNonMatchingForwardOpenRequest /home/weichuan/wc/OpENer/source/src/cip/cipconnectionmanager.c:472:20
    #3 0x56d8ba5da6a7 in ForwardOpenRoutine /home/weichuan/wc/OpENer/source/src/cip/cipconnectionmanager.c:662:10
    #4 0x56d8ba5d49c6 in ForwardOpen /home/weichuan/wc/OpENer/source/src/cip/cipconnectionmanager.c:579:10
    #5 0x56d8ba5bf35e in NotifyClass /home/weichuan/wc/OpENer/source/src/cip/cipcommon.c:127:18
    #6 0x56d8ba5e7362 in NotifyMessageRouter /home/weichuan/wc/OpENer/source/src/cip/cipmessagerouter.c:217:20
    #7 0x56d8ba5f3bc1 in NotifyCommonPacketFormat /home/weichuan/wc/OpENer/source/src/enet_encap/cpf.c:60:24
    #8 0x56d8ba5faf60 in HandleReceivedSendRequestResponseDataCommand /home/weichuan/wc/OpENer/source/src/enet_encap/encap.c:558:22
    #9 0x56d8ba5f9a83 in HandleReceivedExplictTcpData /home/weichuan/wc/OpENer/source/src/enet_encap/encap.c:186:26
    #10 0x56d8ba5ba554 in HandleDataOnTcpSocket /home/weichuan/wc/OpENer/source/src/ports/generic_networkhandler.c:864:30
    #11 0x56d8ba5b89c1 in NetworkHandlerProcessCyclic /home/weichuan/wc/OpENer/source/src/ports/generic_networkhandler.c:497:32
    #12 0x56d8ba5b61a4 in executeEventLoop /home/weichuan/wc/OpENer/source/src/ports/POSIX/main.c:261:24
    #13 0x56d8ba5b61a4 in main /home/weichuan/wc/OpENer/source/src/ports/POSIX/main.c:229:12

ASan identifies the out-of-bounds access as crossing the stack receive buffer in HandleDataOnTcpSocket():

Address 0x7ffccdbd8cf0 is located in stack of thread T0 at offset 560 in frame
    #0 0x56d8ba5ba1ff in HandleDataOnTcpSocket /home/weichuan/wc/OpENer/source/src/ports/generic_networkhandler.c:720

  This frame has 6 object(s):
    [32, 36) 'remaining_bytes' (line 722)
    [48, 560) 'incoming_message' (line 732) <== Memory access at offset 560 overflows this variable
    [624, 632) 'read_buffer' (line 760)
    [656, 672) 'sender_address' (line 850)
    [688, 692) 'fromlen' (line 852)
    [704, 1232) 'outgoing_message' (line 862)

The access occurs exactly at the first byte after the 512-byte incoming_message stack buffer:

incoming_message: [48, 560)
access offset:     560

This matches the source-level failure mechanism: after processing the final 2-byte PIT segment, message points one-past-the-end, but remaining_path has underflowed and the parser continues.

Why this is not just an oversized-path error

The PoC contains separate control modes:

baseline: valid ForwardOpen request, returns general_status=0x00
filler:   large simple-data filler, returns an error response and does not crash
crash:    same path shape with a trailing PIT milliseconds segment, triggers ASan crash

The large simple-data segment is only used to place the final PIT segment near the end of the TCP receive buffer. The filler alone does not crash the server.

The crash depends on the trailing Production Inhibit Time in milliseconds branch incorrectly subtracting 2 words from remaining_path.

Suggested fix

The minimal fix is to correct the word accounting in the trailing PIT milliseconds branch.

Current vulnerable code:

case kNetworkSegmentSubtypeProductionInhibitTimeInMilliseconds:
  if(kConnectionObjectTransportClassTriggerProductionTriggerCyclic !=
     ConnectionObjectGetTransportClassTriggerProductionTrigger(
       connection_object) ) {
    /* only non cyclic connections may have a production inhibit */
    connection_object->production_inhibit_time = message[1];
    message += 2;
    remaining_path -= 2;
  } else {
    *extended_error = connection_path_size - remaining_path;
    /*offset in 16Bit words where within the connection path the error happened*/
    return kCipErrorPathSegmentError;
    /*status code for invalid segment type*/
  }
  break;

Proposed minimal change:

case kNetworkSegmentSubtypeProductionInhibitTimeInMilliseconds:
  if(kConnectionObjectTransportClassTriggerProductionTriggerCyclic !=
     ConnectionObjectGetTransportClassTriggerProductionTrigger(
       connection_object) ) {

    /*
     * The Production Inhibit Time in milliseconds segment occupies
     * 2 bytes, i.e. exactly one 16-bit path word.
     */
    if(remaining_path < 1) {
      *extended_error = connection_path_size - remaining_path;
      return kCipErrorNotEnoughData;
    }

    connection_object->production_inhibit_time = message[1];
    message += 2;
    remaining_path -= 1;
  } else {
    *extended_error = connection_path_size - remaining_path;
    return kCipErrorPathSegmentError;
  }
  break;

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions