Summary
I found a remotely reachable server-side out-of-bounds read in the current master branch of OpENer.
The issue is reachable through the real unconnected explicit-message path:
RegisterSession -> SendRRData -> CPF -> Message Router -> SetAttributeSingle -> reachable attribute decoder -> GetUintFromMessage / GetUsintFromMessage
This is not the same crashing condition as the previously reported DecodePaddedEPath() parser-side OOB read. In this case, the request successfully passes path decoding, reaches a real settable attribute in the default build, and the final out-of-bounds read occurs inside the attribute decoder because request-data length is not validated before attribute->decode(...) is invoked.
I reproduced this against the default POSIX server build on TCP/44818 using a Python PoC that first performs RegisterSession and then sends a crafted SendRRData request. The crash is ASan-confirmed as a stack-buffer-overflow read.
Affected branch
- OpENer v2.3 / master branch up to commit
76b95cf
Security impact
- Reachability: remote, unauthenticated network attacker able to connect to TCP/44818 and perform
RegisterSession
- Path: real unconnected explicit-message (
SendRRData) path
- Impact currently confirmed:
remote process crash / denial of service
- Bug class:
out-of-bounds read (stack-buffer-overflow read)
- Crash sink: real
SetAttributeSingle attribute decoder in the default build, not just the EPath parser
At present I have confirmed reliable ASan crashes and server termination. I have not proven an information disclosure primitive or a write primitive, so the demonstrated impact should be treated as remote DoS caused by OOB read.
Why this is distinct
This issue is related to the broader Padded EPath handling problem family, but the final crash here is not in DecodePaddedEPath() itself.
Instead:
- A crafted but decodable padded EPath is used to push
message_router_request->data to the end of the incoming request buffer while still resolving to a valid target object/instance/attribute.
SetAttributeSingle() then calls the target attribute decoder without enforcing a minimum input length.
- The decoder performs unchecked reads from
message_router_request->data, causing the actual crash.
So the parser behavior is the enabler, but the final memory safety failure is in the reachable business-logic decoder path.
Root cause analysis
1. Real network entry: TCP explicit messaging
The request enters through the normal server event loop and TCP receive path:
main()
executeEventLoop()
NetworkHandlerProcessCyclic()
HandleDataOnTcpSocket()
HandleDataOnTcpSocket() receives the packet into a stack buffer:
CipOctet incoming_message[PC_OPENER_ETHERNET_BUFFER_SIZE] = { 0 };
That same request buffer is then passed into the TCP encapsulation and CPF handling logic.
2. Unconnected explicit-message path is used
The PoC uses a normal RegisterSession, then sends SendRRData.
In HandleReceivedExplictTcpData(), kEncapsulationCommandSendRequestReplyData is dispatched to:
HandleReceivedSendRequestResponseDataCommand(...)
That function skips the interface handle and timeout fields, validates the registered session, and then forwards the request into:
NotifyCommonPacketFormat(...)
If CPF contains a NullAddress and an UnconnectedDataItem, the payload is routed into:
This proves the bug is on the real unconnected explicit-message path, not a unit-test-only or parser-only path.
3. CreateMessageRouterRequestStructure() lets path parsing advance data
CreateMessageRouterRequestStructure() first reads the service byte, then immediately calls:
DecodePaddedEPath(&(message_router_request->request_path),
&data,
&number_of_decoded_bytes);
Only after the decoder returns does it compute:
message_router_request->data = data;
message_router_request->request_data_size = data_length - number_of_decoded_bytes;
So the decoder is allowed to advance the data pointer first, and the remaining request data is derived afterward.
4. DecodePaddedEPath() accepts Member ID segments that do not contribute to attribute semantics
DecodePaddedEPath() stores only:
class_id
instance_number
attribute_number
But it also accepts Member ID logical segments and simply advances the path parser over them:
case ... MEMBER_ID + ... EIGHT_BIT:
message_runner += 2;
break;
case ... MEMBER_ID + ... SIXTEEN_BIT:
message_runner += 2;
number_of_decoded_elements++;
break;
These segments do not become meaningful attribute-service input, but they do move message_runner forward. This lets an attacker construct a long but syntactically decodable padded EPath that still ends on a valid class/instance/attribute while pushing message_router_request->data to the end of the incoming request buffer.
In other words, the padded EPath acts as a request-data pointer shifter.
5. SetAttributeSingle() does not enforce minimum request-data length before calling attribute->decode
The message router eventually resolves the request to a real instance and real service. NotifyClass() dispatches the matched service and reaches:
Inside SetAttributeSingle(), after resolving the target attribute, the implementation directly executes:
attribute->decode(attribute->data,
message_router_request,
message_router_response);
There is no generic pre-check like:
request_data_size >= 1 for USINT
request_data_size >= 2 for UINT
- etc.
This is important because CipMessageRouterRequest already contains request_data_size, but it is not used here as a safety boundary before the decode callback is invoked.
6. Default-build reachable sink: TCP/IP Interface attribute 13
The default build registers TCP/IP Interface instance attribute 13 as settable:
InsertAttribute(instance,
13,
kCipUint,
EncodeCipUint,
DecodeCipTcpIpInterfaceEncapsulationInactivityTimeout,
&g_tcpip.encapsulation_inactivity_timeout,
kSetAndGetAble | kNvDataFunc);
And the TCP/IP Interface class also registers:
InsertService(tcp_ip_class, kSetAttributeSingle, &SetAttributeSingle, ...);
Therefore, the sink used by the PoC is present in the default build and is reachable through a real protocol service.
7. The decoder blindly reads from message_router_request->data
DecodeCipTcpIpInterfaceEncapsulationInactivityTimeout() immediately performs:
CipUint inactivity_timeout_received =
GetUintFromMessage(&(message_router_request->data));
But it does not verify:
message_router_request->request_data_size >= 2
And the lower-level helper GetUintFromMessage() itself is just:
EipUint16 data = buffer[0] | buffer[1] << 8;
*buffer_address += 2;
It does not know the valid remaining length and therefore cannot stop an out-of-bounds read.
As a result, once the padded path has pushed message_router_request->data to the end of the stack-backed request buffer, this decoder reads beyond the end of incoming_message.
ASan result
I reproduced the issue with the following confirmed stack trace:
==68203==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffcd4e17070
READ of size 1 at 0x7ffcd4e17070 thread T0
#0 GetUintFromMessage .../source/src/enet_encap/endianconv.c:75
#1 DecodeCipTcpIpInterfaceEncapsulationInactivityTimeout .../source/src/cip/ciptcpipinterface.c:519
#2 SetAttributeSingle .../source/src/cip/cipcommon.c:823
#3 NotifyClass .../source/src/cip/cipcommon.c:127
#4 NotifyMessageRouter .../source/src/cip/cipmessagerouter.c:217
#5 NotifyCommonPacketFormat .../source/src/enet_encap/cpf.c:60
#6 HandleReceivedSendRequestResponseDataCommand .../source/src/enet_encap/encap.c:558
#7 HandleReceivedExplictTcpData .../source/src/enet_encap/encap.c:186
#8 HandleDataOnTcpSocket .../source/src/ports/generic_networkhandler.c:864
ASan also shows that the invalid read crosses the boundary of the stack buffer allocated in HandleDataOnTcpSocket():
[48, 560) 'incoming_message' (line 732) <== Memory access at offset 560 overflows this variable
This directly matches the code-level explanation above: the decoder is reading past the end of the stack-backed TCP request buffer.
Reproduction
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/src/ports/POSIX/OpENer lo
Run PoC (confirmed tcpip13 variant)
POC:
poc.zip
In another terminal:
The PoC automatically performs RegisterSession and then sends the crafted SendRRData request.
Suggested fix direction
A robust fix should address both layers of the bug.
Fix layer 1: tighten DecodePaddedEPath() usage for attribute services
For SetAttributeSingle / GetAttributeSingle style requests, only the logical segments needed for a valid attribute path should be accepted:
- Class ID
- Instance ID
- Attribute ID
Unexpected extra Member ID segments should be rejected for these services instead of being silently consumed as padding.
Possible direction:
/* Pseudocode */
if(service_is_attribute_service(request_service)) {
reject_member_id_segments_after_attribute_path();
}
This prevents attackers from using padded EPath as a request-data pointer shifter.
Fix layer 2: enforce input-length validation before attribute->decode(...)
SetAttributeSingle() should not invoke the decoder blindly.
At minimum, each decoder must verify that message_router_request->request_data_size is large enough before reading from message_router_request->data.
For example, the TCP/IP Interface attribute 13 decoder should reject insufficient input before reading 2 bytes:
int DecodeCipTcpIpInterfaceEncapsulationInactivityTimeout(
void *const data,
CipMessageRouterRequest *const message_router_request,
CipMessageRouterResponse *const message_router_response) {
if(message_router_request->request_data_size < 2) {
message_router_response->general_status = kCipErrorNotEnoughData;
return -1;
}
CipUint inactivity_timeout_received =
GetUintFromMessage(&(message_router_request->data));
...
}
Likewise, DecodeCipQoSAttribute() should validate request_data_size >= 1 before calling GetUsintFromMessage().
Suggested CWE
- CWE-125: Out-of-bounds Read
- CWE-20: Improper Input Validation
Summary
I found a remotely reachable server-side out-of-bounds read in the current
masterbranch of OpENer.The issue is reachable through the real unconnected explicit-message path:
RegisterSession -> SendRRData -> CPF -> Message Router -> SetAttributeSingle -> reachable attribute decoder -> GetUintFromMessage / GetUsintFromMessageThis is not the same crashing condition as the previously reported
DecodePaddedEPath()parser-side OOB read. In this case, the request successfully passes path decoding, reaches a real settable attribute in the default build, and the final out-of-bounds read occurs inside the attribute decoder because request-data length is not validated beforeattribute->decode(...)is invoked.I reproduced this against the default
POSIX serverbuild on TCP/44818 using a Python PoC that first performsRegisterSessionand then sends a craftedSendRRDatarequest. The crash is ASan-confirmed as astack-buffer-overflowread.Affected branch
76b95cfSecurity impact
RegisterSessionSendRRData) pathremote process crash/denial of serviceout-of-bounds read(stack-buffer-overflowread)SetAttributeSingleattribute decoder in the default build, not just the EPath parserAt present I have confirmed reliable ASan crashes and server termination. I have not proven an information disclosure primitive or a write primitive, so the demonstrated impact should be treated as remote
DoScaused byOOB read.Why this is distinct
This issue is related to the broader Padded EPath handling problem family, but the final crash here is not in
DecodePaddedEPath()itself.Instead:
message_router_request->datato the end of the incoming request buffer while still resolving to a valid target object/instance/attribute.SetAttributeSingle()then calls the target attribute decoder without enforcing a minimum input length.message_router_request->data, causing the actual crash.So the parser behavior is the enabler, but the final memory safety failure is in the reachable business-logic decoder path.
Root cause analysis
1. Real network entry: TCP explicit messaging
The request enters through the normal server event loop and TCP receive path:
main()executeEventLoop()NetworkHandlerProcessCyclic()HandleDataOnTcpSocket()HandleDataOnTcpSocket()receives the packet into a stack buffer:That same request buffer is then passed into the TCP encapsulation and CPF handling logic.
2. Unconnected explicit-message path is used
The PoC uses a normal
RegisterSession, then sendsSendRRData.In
HandleReceivedExplictTcpData(),kEncapsulationCommandSendRequestReplyDatais dispatched to:HandleReceivedSendRequestResponseDataCommand(...)That function skips the interface handle and timeout fields, validates the registered session, and then forwards the request into:
NotifyCommonPacketFormat(...)If CPF contains a
NullAddressand anUnconnectedDataItem, the payload is routed into:NotifyMessageRouter(...)This proves the bug is on the real unconnected explicit-message path, not a unit-test-only or parser-only path.
3.
CreateMessageRouterRequestStructure()lets path parsing advancedataCreateMessageRouterRequestStructure()first reads the service byte, then immediately calls:Only after the decoder returns does it compute:
So the decoder is allowed to advance the
datapointer first, and the remaining request data is derived afterward.4.
DecodePaddedEPath()acceptsMember IDsegments that do not contribute to attribute semanticsDecodePaddedEPath()stores only:class_idinstance_numberattribute_numberBut it also accepts
Member IDlogical segments and simply advances the path parser over them:These segments do not become meaningful attribute-service input, but they do move
message_runnerforward. This lets an attacker construct a long but syntactically decodable padded EPath that still ends on a valid class/instance/attribute while pushingmessage_router_request->datato the end of the incoming request buffer.In other words, the padded EPath acts as a request-data pointer shifter.
5.
SetAttributeSingle()does not enforce minimum request-data length before callingattribute->decodeThe message router eventually resolves the request to a real instance and real service.
NotifyClass()dispatches the matched service and reaches:SetAttributeSingle(...)Inside
SetAttributeSingle(), after resolving the target attribute, the implementation directly executes:There is no generic pre-check like:
request_data_size >= 1forUSINTrequest_data_size >= 2forUINTThis is important because
CipMessageRouterRequestalready containsrequest_data_size, but it is not used here as a safety boundary before the decode callback is invoked.6. Default-build reachable sink: TCP/IP Interface attribute 13
The default build registers TCP/IP Interface instance attribute 13 as settable:
And the TCP/IP Interface class also registers:
Therefore, the sink used by the PoC is present in the default build and is reachable through a real protocol service.
7. The decoder blindly reads from
message_router_request->dataDecodeCipTcpIpInterfaceEncapsulationInactivityTimeout()immediately performs:But it does not verify:
And the lower-level helper
GetUintFromMessage()itself is just:It does not know the valid remaining length and therefore cannot stop an out-of-bounds read.
As a result, once the padded path has pushed
message_router_request->datato the end of the stack-backed request buffer, this decoder reads beyond the end ofincoming_message.ASan result
I reproduced the issue with the following confirmed stack trace:
==68203==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffcd4e17070 READ of size 1 at 0x7ffcd4e17070 thread T0 #0 GetUintFromMessage .../source/src/enet_encap/endianconv.c:75 #1 DecodeCipTcpIpInterfaceEncapsulationInactivityTimeout .../source/src/cip/ciptcpipinterface.c:519 #2 SetAttributeSingle .../source/src/cip/cipcommon.c:823 #3 NotifyClass .../source/src/cip/cipcommon.c:127 #4 NotifyMessageRouter .../source/src/cip/cipmessagerouter.c:217 #5 NotifyCommonPacketFormat .../source/src/enet_encap/cpf.c:60 #6 HandleReceivedSendRequestResponseDataCommand .../source/src/enet_encap/encap.c:558 #7 HandleReceivedExplictTcpData .../source/src/enet_encap/encap.c:186 #8 HandleDataOnTcpSocket .../source/src/ports/generic_networkhandler.c:864ASan also shows that the invalid read crosses the boundary of the stack buffer allocated in
HandleDataOnTcpSocket():This directly matches the code-level explanation above: the decoder is reading past the end of the stack-backed TCP request buffer.
Reproduction
Build
Run server
Run PoC (confirmed
tcpip13variant)POC:
poc.zip
In another terminal:
The PoC automatically performs
RegisterSessionand then sends the craftedSendRRDatarequest.Suggested fix direction
A robust fix should address both layers of the bug.
Fix layer 1: tighten
DecodePaddedEPath()usage for attribute servicesFor
SetAttributeSingle/GetAttributeSinglestyle requests, only the logical segments needed for a valid attribute path should be accepted:Unexpected extra
Member IDsegments should be rejected for these services instead of being silently consumed as padding.Possible direction:
This prevents attackers from using padded EPath as a request-data pointer shifter.
Fix layer 2: enforce input-length validation before
attribute->decode(...)SetAttributeSingle()should not invoke the decoder blindly.At minimum, each decoder must verify that
message_router_request->request_data_sizeis large enough before reading frommessage_router_request->data.For example, the TCP/IP Interface attribute 13 decoder should reject insufficient input before reading 2 bytes:
Likewise,
DecodeCipQoSAttribute()should validaterequest_data_size >= 1before callingGetUsintFromMessage().Suggested CWE