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:
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;
Summary
A crafted EtherNet/IP
ForwardOpenrequest can trigger a stack-based out-of-bounds read in OpENer's server-side connection-establishment path.The issue is in
ParseConnectionPath()insource/src/cip/cipconnectionmanager.c. The connection path length is tracked in 16-bit words, but the trailingProduction Inhibit Time in millisecondsnetwork 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_pathunderflows from1to a very largesize_tvalue. The parser then continues thewhile (remaining_path > 0)loop and callsGetPathSegmentType()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 1inGetPathSegmentType().Impact
A remote unauthenticated client can trigger this issue through the normal EtherNet/IP TCP/44818
ForwardOpenpath 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:
Affected component
ForwardOpenSendRRData-> Message Router -> Connection Managersource/src/ports/POSIX/main.csource/src/ports/generic_networkhandler.csource/src/enet_encap/encap.csource/src/enet_encap/cpf.csource/src/cip/cipconnectionmanager.csource/src/cip/cipepath.cVersion
76b95cfReachability
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:
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:The length check also treats
remaining_pathas a number of 16-bit words:Therefore, every 2 bytes consumed from the connection path should decrement
remaining_pathby 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:
However, the trailing
Production Inhibit Time in millisecondsnetwork segment branch contains an inconsistent decrement:The
Production Inhibit Time in millisecondssegment only occupies 2 bytes:cipepath.calso treats the milliseconds PIT value as the second byte of the segment:So the correct accounting should be:
The current code instead performs:
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 millisecondsnetwork segment.Before processing the segment:
The vulnerable branch executes:
After
message += 2,messagepoints exactly to the end of the connection path.But
remaining_path -= 2underflows becauseremaining_pathis an unsignedsize_t:remaining_path: 1 -> SIZE_MAXThe parser then continues:
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:
At this point,
*cip_pathis an out-of-bounds read.Reproduction
Build the unmodified server with ASan/UBSan:
Start the server:
POC
poc.zip
Run the baseline request:
Expected result:
Run the filler-only control case:
Expected result:
Run the crashing case:
Expected result:
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:12ASan identifies the out-of-bounds access as crossing the stack receive buffer in
HandleDataOnTcpSocket():The access occurs exactly at the first byte after the 512-byte
incoming_messagestack buffer:This matches the source-level failure mechanism: after processing the final 2-byte PIT segment,
messagepoints one-past-the-end, butremaining_pathhas underflowed and the parser continues.Why this is not just an oversized-path error
The PoC contains separate control modes:
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 millisecondsbranch incorrectly subtracting 2 words fromremaining_path.Suggested fix
The minimal fix is to correct the word accounting in the trailing PIT milliseconds branch.
Current vulnerable code:
Proposed minimal change: