Possible out-of-bounds read (heap over-read + disclosure to JS) in DcpIdentifyWorker::BuildHostFromFrame
I found a possible OOB read in DcpIdentifyWorker::BuildHostFromFrame. When a PROFINET DCP
Identify-Response frame is captured off the network, the per-block field usDcpBlockLength
(a 16-bit big-endian length taken directly from the untrusted packet) is used to read the
block payload without ever being bounded against the number of bytes actually captured
(len) or even against the header-declared usDcpDataLength. A malicious/malformed device on
the L2 segment can declare a block length of up to 65535, causing the addon to read tens of
kilobytes past the end of the ~1500-byte pcap capture buffer. For the string-valued blocks
(NameOfStation / Alias / Vendor) the over-read bytes are copied into a std::string and
handed straight back to JavaScript, i.e. it is a heap-disclosure primitive.
File: src/dcp.cc
Function: DcpIdentifyWorker::BuildHostFromFrame (via the PROCESS_STRING_PROP macro)
#define PROCESS_STRING_PROP(SubOption, Key) \
if (pDcpBlock->bSubOption == SubOption && usDcpBlockLength > 2) { \
std::string stringProp(""); \
stringProp.append((char*)(pBlockData + 2), usDcpBlockLength - 2); \
host.Set(Key, stringProp.c_str()); \
}
...
DCP_RESPONSE_BLOCK_HEADER* pDcpBlock = (DCP_RESPONSE_BLOCK_HEADER*)(pDcpHeader + 1);
u_short usDcpBlockLength = ntohs(pDcpBlock->usDcpBlockLength);
for (size_t processed = 0; processed < (size_t)ntohs(pDcpHeader->usDcpDataLength);
processed += (sizeof(DCP_RESPONSE_BLOCK_HEADER) + usDcpBlockLength + (usDcpBlockLength % 2))) {
pDcpBlock = (DCP_RESPONSE_BLOCK_HEADER*)((u_char*)(pDcpHeader + 1) + processed);
usDcpBlockLength = ntohs(pDcpBlock->usDcpBlockLength); // attacker-controlled, unbounded
u_char* pBlockData = (u_char*)(pDcpBlock + 1);
switch (pDcpBlock->bOption) {
...
case OPTION_DEVPROP:
PROCESS_STRING_PROP(DEVPROP_NAMEOFSTATION, "NameOfStation"); // reads usDcpBlockLength-2 bytes
PROCESS_STRING_PROP(DEVPROP_ALIAS, "Alias");
PROCESS_STRING_PROP(DEVPROP_DEVICEVENDOR, "Vendor");
Path, line by line:
OnProgress (caller) only validates the aggregate DCP data region:
len >= ethHeaderSize + sizeof(DCP_RESPONSE_HEADER) + usDcpDataLength. It never validates
the individual block length fields inside that region.
- In
BuildHostFromFrame the loop iterates while processed < usDcpDataLength. On each
iteration it reads usDcpBlockLength = ntohs(pDcpBlock->usDcpBlockLength) straight from the
packet. Nothing constrains this value to usDcpDataLength - processed or to len.
(The constants DCP_MAX_NAME_LENGTH / DCP_MAX_LABEL_LENGTH are defined in dcp_pdu.h but
never used here.)
- For a Device-Properties block whose sub-option is NameOfStation/Alias/DeviceVendor,
PROCESS_STRING_PROP executes stringProp.append((char*)(pBlockData + 2), usDcpBlockLength - 2).
With usDcpBlockLength == 0xFFFF this copies 65533 bytes starting at pBlockData + 2.
- The capture buffer returned by
pcap_next is at most snaplen (1500) bytes, so the
std::string::append reads far past the end of the heap allocation — an out-of-bounds read.
- The result is stored via
host.Set(Key, stringProp.c_str()) and the host object is passed to
JS (Callback().Call({ host }) and resolved in the hosts array), disclosing the
over-read heap contents to the caller.
The DEVPROP_DEVICEOPTIONS branch has the same root cause: it loops
for (i < (usDcpBlockLength - 2)/2) reading pBlockData + 2 + i*2 with the same unbounded
usDcpBlockLength, disclosing the over-read bytes as Option/SubOption numbers.
A minimal trigger is a DCP block that declares a small usDcpDataLength (so the frame length
check passes) but an enormous usDcpBlockLength, e.g. header data-length = 8 with a single
NameOfStation block claiming block-length 0xFFFF.
JS trigger (attacker-side, over the wire):
// Victim runs a normal identify scan:
const dcp = require('node-profinetdcp');
await dcp.identify(iface); // sends the multicast Identify request
// An attacker on the same L2 segment observes the request's Xid and replies with a crafted
// DCP Identify-Response: ServiceType=success, DCPDataLength=0x0008, one DevProp/NameOfStation
// block with BlockLength=0xFFFF and only a couple of payload bytes. The addon then over-reads
// ~64KB of heap and returns it as `host.NameOfStation`.
Suggested fix: before dereferencing/reading each block, clamp against the remaining validated
region. Compute remaining = usDcpDataLength - processed, reject the block if
sizeof(DCP_RESPONSE_BLOCK_HEADER) + usDcpBlockLength > remaining (and also verify
processed + sizeof(DCP_RESPONSE_BLOCK_HEADER) <= usDcpDataLength before reading the block
header), and bound every pBlockData access by usDcpBlockLength and by the captured len.
Break out of the loop on any inconsistency instead of trusting the wire-supplied length.
Possible out-of-bounds read (heap over-read + disclosure to JS) in
DcpIdentifyWorker::BuildHostFromFrameI found a possible OOB read in
DcpIdentifyWorker::BuildHostFromFrame. When a PROFINET DCPIdentify-Response frame is captured off the network, the per-block field
usDcpBlockLength(a 16-bit big-endian length taken directly from the untrusted packet) is used to read the
block payload without ever being bounded against the number of bytes actually captured
(
len) or even against the header-declaredusDcpDataLength. A malicious/malformed device onthe L2 segment can declare a block length of up to 65535, causing the addon to read tens of
kilobytes past the end of the ~1500-byte pcap capture buffer. For the string-valued blocks
(NameOfStation / Alias / Vendor) the over-read bytes are copied into a
std::stringandhanded straight back to JavaScript, i.e. it is a heap-disclosure primitive.
File:
src/dcp.ccFunction:
DcpIdentifyWorker::BuildHostFromFrame(via thePROCESS_STRING_PROPmacro)Path, line by line:
OnProgress(caller) only validates the aggregate DCP data region:len >= ethHeaderSize + sizeof(DCP_RESPONSE_HEADER) + usDcpDataLength. It never validatesthe individual block length fields inside that region.
BuildHostFromFramethe loop iterates whileprocessed < usDcpDataLength. On eachiteration it reads
usDcpBlockLength = ntohs(pDcpBlock->usDcpBlockLength)straight from thepacket. Nothing constrains this value to
usDcpDataLength - processedor tolen.(The constants
DCP_MAX_NAME_LENGTH/DCP_MAX_LABEL_LENGTHare defined indcp_pdu.hbutnever used here.)
PROCESS_STRING_PROPexecutesstringProp.append((char*)(pBlockData + 2), usDcpBlockLength - 2).With
usDcpBlockLength == 0xFFFFthis copies 65533 bytes starting atpBlockData + 2.pcap_nextis at mostsnaplen(1500) bytes, so thestd::string::appendreads far past the end of the heap allocation — an out-of-bounds read.host.Set(Key, stringProp.c_str())and the host object is passed toJS (
Callback().Call({ host })and resolved in thehostsarray), disclosing theover-read heap contents to the caller.
The
DEVPROP_DEVICEOPTIONSbranch has the same root cause: it loopsfor (i < (usDcpBlockLength - 2)/2)readingpBlockData + 2 + i*2with the same unboundedusDcpBlockLength, disclosing the over-read bytes asOption/SubOptionnumbers.A minimal trigger is a DCP block that declares a small
usDcpDataLength(so the frame lengthcheck passes) but an enormous
usDcpBlockLength, e.g. header data-length = 8 with a singleNameOfStation block claiming block-length 0xFFFF.
JS trigger (attacker-side, over the wire):
Suggested fix: before dereferencing/reading each block, clamp against the remaining validated
region. Compute
remaining = usDcpDataLength - processed, reject the block ifsizeof(DCP_RESPONSE_BLOCK_HEADER) + usDcpBlockLength > remaining(and also verifyprocessed + sizeof(DCP_RESPONSE_BLOCK_HEADER) <= usDcpDataLengthbefore reading the blockheader), and bound every
pBlockDataaccess byusDcpBlockLengthand by the capturedlen.Break out of the loop on any inconsistency instead of trusting the wire-supplied length.