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:
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.
Summary
I found a remotely reachable server-side
out-of-bounds readin the currentmasterbranch of OpENer.The bug is triggered in the real TCP explicit-message path (
44818/RegisterSession/SendRRData) whenSetAttributeListtargets a real, default-registered, non-settablekCipShortStringattribute: 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 callsGetCipDataTypeLength(attribute->type, message_router_request->data)on attacker-controlled request data without any remaining-length validation. ForkCipShortString, this immediately performs an unbounded 1-byte read of the string length header throughGetSintFromMessage().By crafting a long padded EPath so that
message_router_request->datais positioned at the end of the TCP stack receive buffer, the length-header read crosses the right boundary ofincoming_message[512]and ASan reports astack-buffer-overflow(OOB read).At minimum, this is a remote unauthenticated denial of service in the default build.
Version
76b95cfAffected path
Real request handling path:
Root cause
1)
SetAttributeList()reads attribute IDs, then enters the "attribute exists but is not settable" branchThe 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 forkCipShortStringThis function receives only a pointer, not a remaining-length argument.
3)
GetSintFromMessage()is an unchecked 1-byte readSo once
message_router_request->datais at the end of the TCP receive buffer, this immediately becomes an OOB read.4)
CreateMessageRouterRequestStructure()stores the post-path pointer as request dataA long padded EPath can therefore push
message_router_request->datavery close to the end of the TCP input buffer.5) The TCP explicit-message receive buffer is stack-backed and 512 bytes by default
Default build configuration:
Trigger condition
A crafted
SendRRDatarequest uses a long padded EPath to placemessage_router_request->datanear the end ofincoming_message[512].Then
SetAttributeList()consumes:attribute_count = 1attribute_id = 7At that point, the not-settable branch tries to compute the length of the attacker-supplied
kCipShortStringvalue by reading its 1-byte length header:That length-header read crosses the right boundary of
incoming_message[512].Reproduction
POC
poc.zip
Build
Run server
Baseline packet
Expected baseline response:
This shows that the default build really reaches
Identity/SetAttributeList/ attribute 7, and that attribute 7 is present but not settable.Crash packet
Typical client-side result:
Server-side ASan result:
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:864ASan identifies the overflown object as the TCP receive stack buffer:
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 readonstack-backedrequest data.Although the demonstrated primitive is a read, the code also treats the adjacent out-of-bounds byte as a
kCipShortStringlength header and uses it to advance the request pointer, so the bug should be treated as a realmemory-safetyissue rather than a benign parse failure.Why this is distinct from other recently reported issues
This issue is not the same root cause as:
GetAttributeList()response assembly overflowsDecodePaddedEPathSetAttributeSingle()cases where an empty value is passed to an attribute decoderHere, the vulnerability is specifically:
SetAttributeList()GetCipDataTypeLength(kCipShortString, attacker_controlled_pointer)##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:
2) Replace
GetCipDataTypeLength()with a checked variant that takes remaining lengthExample direction:
3) In the "attribute not settable" branch, fail if the full value is not present
Instead of blindly doing:
do:
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.