This repository contains the artifact for the paper titled `BACnet or ``BADnet''? On the (In)Security of Implicitly Reserved Fields in BACnet,' accepted for publication at NDSS 2026.
BACsFuzz is a BACnet MS/TP protocol fuzzer that integrates two open-source BACnet protocol stacks:
- bacpypes: A Python-based stack offering high flexibility for prototyping and application development. It supports BACnet/IP but not MS/TP. We utilize bacpypes for implementing application and network layer functionalities.
- bacnet-stack: A complete BACnet protocol stack written in C, supporting BACnet over MS/TP and other media. Its low-level control over protocol behaviors makes it suitable for precise MS/TP handling, which we use for data link and physical layers.
We build upon Misty, an open-source bridge between the two stacks:
- A
MSTPSimpleApplication-based bacpypes application communicates with an MSTP Agent. - The MSTP Agent transmits BACnet packets via a Serial Port Driver.
- Responses from the target device are routed back through the Serial Port Driver → MSTP Agent → the bacpypes application.
Each MSTPSimpleApplication instance is bound to a physical serial interface. Internally, the MSTP Agent uses the dlmstp_* functions from bacnet-stack v0.8.4 to:
- send and receive MSTP frames,
- configure serial port parameters (e.g.,
baud rate,max_info_frames, etc).
We release the full source code and detailed instructions to support reproducibility and encourage community contributions. However, we do not release the exact test cases used in the paper, as each corresponds to a real vulnerability discovered on commercial BACnet devices.
In adherence to vendor vulnerability disclosure guidelines, we avoid distributing proof-of-concept (PoC) payloads that may exploit unpatched devices. Instead, we encourage researchers to generate their own test cases using our framework and to follow responsible disclosure protocols when identifying new issues.
We are committed to maintaining and evolving the codebase to facilitate future research and improve compatibility across diverse BACnet deployments.
- Python version: Python 3 is recommended. We tested with
Python 3.8.10. - Install bacpypes:
$ pip install bacpypes==0.18.7
- Clone the MSTP Agent:
$ git clone https://github.com/riptideio/misty.git
Note: Since this is an anonymous submission, we reference the publicly available misty repository. After acceptance, users will be able to directly clone the full BACsFuzz repository with integrated and maintained code. Alternatively, download the anonymized BACsFuzz source and compile locally.
- File Locations After Installation:
- The
bacpypespackage will be installed at:
/home/***/.local/lib/python3.8/site-packages/bacpype
- The
mstplib/mistymodule will reside at:
/home/***/.local/lib/python3.8/site-packages/misty
- The cloned source directory for
mistywill be located at:
/home/***/misty-master
- Build the MSTP Agent:
$ cd misty/mstplib
$ make clean_build
$ cp libmstp_agent_linux.so ~/.local/lib/python3.8/site-packages/misty/mstplib/
- Connect to the MS/TP Network
Use a USB-to-EIA-485 adapter to convert the host machine’s USB output to EIA-485 signals, enabling physical-layer MS/TP communication. - Configure the INI File
Edit the bac_fuzz.ini file located in the misty/samples directory to configure the following values:
- MS/TP address: The unique address of the fuzzer on the MS/TP network (ranging from 0 to 255); since only addresses 0–127 are master nodes that can initiate communication, please choose a value within this range.
- interface: The device path of the USB-to-EIA-485 adapter after being attached to the Linux system, typically something like
/dev/ttyUSB0. - baudrate: The baud rate used by MS/TP devices for communication. A common value is
19200. - max info: The maximum number of MS/TP frames (ranging from 0 to 255) that a single node is allowed to send in one token pass.
objectName: BACsFuzz
address: 55
interface:/dev/ttyUSB0
max_masters: 127
baudrate: 19200
maxinfo:255
objectIdentifier: 899
maxApduLengthAccepted: 1024
segmentationSupported: segmentedBoth
vendorIdentifier: 332
foreignPort: 0
foreignBBMD: 128.253.109.254
foreignTTL: 30
- Launch the BACnet Client Start the bacnet client program present in the misty/samples directory:
$ export PYTHONPATH=$PWD
$ python misty/samples/bac_fuzz.py --ini misty/samples/bac_fuzz.ini
- Start Fuzzing the Target Device(e.g., for MS/TP address
0x01)
fuzz 0x01
BACnet PDUs contain fields that are either explicitly or implicitly reserved. We utilize LLMs to extract and identify such fields, applying targeted mutations at the APDU and NPDU levels.
Step 1: Identify Implicitly Reserved Fields using LLMs
We identify implicitly reserved fields through a multi-stage LLM-assisted pipeline:
-
Document Pre-processing: Convert specification PDFs to filtered plain-text using merge_pdf.py and pdf_to_txt.py.
-
Field Matching: We apply
Prompt 1from prompt_templates.txt to extract and match field names, layers, and descriptions from the text content. -
Structure Resolution: Using
Prompt 2andPrompt 3, we instruct the LLM to resolve hierarchical protocol structures, linking fields to their corresponding message structures. -
Implicitly Reserved Fields Identification:
Prompt 4andPrompt 5are used to flag fields marked as Fully Defined Fields, Explicitly Reserved Fields or Implicitly Reserved Fields.
Step 2: Configure Fuzzing Parameters
- Number of Iterations
To control how many fuzzing iterations are executed, modify the do_fuzz method in the BacnetClientConsoleCmd class within bacpypes/bac_fuzz.py. For example, to run 1000 iterations:
def do_fuzz(self, args):
"""fuzz <addr> """
if _debug: print("DEBUG:mstp.sample.BacnetClientConsoleCmd: %r" % inspect.stack()[0][3])
args = args.split()
addr = args[0]
timeout_count = 0
try:
while (bacpypes.share_index.share_index1 < 1000):
bacpypes.share_index.MSTPSend__successed = 0
response_event.clear()
start_time = time.time()
-
Time Interval Between Iterations
Still within the do_fuzz method in bacpypes/bac_fuzz.py, you can set the time interval between two iterations. For example, to wait 0.5 seconds between runs:
iocb.set_timeout(0.5)
- Target Device Address
In bacpypes/share_variable.py, set the fuzz_addr variable to the MS/TP address of the device you want to fuzz. For example, to target device 0x01:
fuzz_addr = int(0x01)
This configuration is crucial because MS/TP is a broadcast-based protocol—every device on the bus can receive all transmitted frames. To ensure that the fuzzer processes only responses from the intended device, the fuzz_addr is used to filter and isolate relevant traffic.
- Logging Paths
To collect logs, set the appropriate file paths in both bacpypes/share_variable.py and the C source files of the MSTP stack (bacnet-stack/src/mstp.c, mstplib/mstp_agent.c).
- file_path in Python: logs from bacpypes
- LOG_FILE_PATH in C: logs from bacnet-stack (this is the main one)
file_path = '/home/Log/fuzz_log0728_verify1.txt'
#define LOG_FILE_PATH "/home/Log/fuzz_log0728_verify2.txt"
Step 3: Setup Mutation Policy
- Constructing BACnet APDU Messages
In bacpypes/bac_fuzz.py, we construct various types of BACnet application-layer request messages and randomly assign parameters. For example:
request0 = AcknowledgeAlarmRequest(acknowledgingProcessIdentifier=value_Unsigned,
eventObjectIdentifier=value_ObjectIdentifier,
eventStateAcknowledged=value_EventState,
timeStamp=value_TimeStamp,
acknowledgmentSource=value_CharacterString,
timeOfAcknowledgment=value_TimeStamp)
-
Performing Parameter Mutation
In bacpypes/constructeddate.py, we apply a second stage of randomization to the fields of the constructed message. The mutation logic varies depending on the data type (e.g., assigning NULL, 0, maximu/minim values). You can toggle the mutation logic by setting _my_mutation = 0 or _my_mutation = 1 at the top of the file. For example:
if _my_mutation:
Probability_modified_context_tag = random.random()
if Probability_modified_context_tag < 0.5:
modified_context_tag = copy.copy(tag)
Probability_modified_tagNumber = random.random()
if Probability_modified_tagNumber < 0.1:
modified_context_tag.tagNumber = random.randint(0,12)
if modified_context_tag.tagNumber == 0: # 0 = Null
modified_context_tag.tagLVT = 0
modified_context_tag.tagData = b''
elif modified_context_tag.tagNumber == 1: #1 = Boolean
boolean_choices = [Boolean(False), Boolean(True)]
value_Boolean = random.choice(boolean_choices)
if _my_debug: print(f"value_Boolean = {value_Boolean}")
my_tag = Tag()
value_Boolean.encode(my_tag)
modified_context_tag.tagLVT = my_tag.tagLVT
modified_context_tag.tagData = my_tag.tagData
-
Injecting Reserved Field Mutations
In files such as bacpypes/apdu.py, bacpypes/appservice.py, bacpypes/netservice.py, bacpypes/npdu.py, and bacpypes/primitivedata.py, the APDU and NPDU layers are constructed with various control fields. We identify and mutate the reserved fields in these layers, as discovered by the LLM analysis. Each field can be mutated with a configurable probability. Mutation can also be enabled or disabled globally in each file using _my_mutation = 0 or _my_mutation = 1. For example:
if _my_mutation:
Probability_npduNetMessage = random.random()
if Probability_npduNetMessage < 0.1:
Probability_npduNetMessageType = random.random()
if Probability_npduNetMessageType < 0.2:
self.npduNetMessage = random.randint(20,127)
else:
self.npduNetMessage = random.randint(0,20)
BACnet MS/TP uses token-passing among master nodes. To bypass idle wait times, we modify the Master Node Finite State Machine (MNFSM). (Mainly modify the MSTP_Master_Node_FSM function in bacnet-stack/src/mstp.c)
Step 1: Simplifying the State Machine
The original MNFSM comprises nine states:
MSTP_MASTER_STATE_INITIALIZE
MSTP_MASTER_STATE_IDLE
MSTP_MASTER_STATE_USE_TOKEN
MSTP_MASTER_STATE_WAIT_FOR_REPLY
MSTP_MASTER_STATE_DONE_WITH_TOKEN
MSTP_MASTER_STATE_PASS_TOKEN
MSTP_MASTER_STATE_NO_TOKEN
MSTP_MASTER_STATE_POLL_FOR_MASTER
MSTP_MASTER_STATE_ANSWER_DATA_REQUEST
To reduce complexity and ensure persistent token ownership, we retain only four essential states that support aggressive and uninterrupted data transmission:
MSTP_MASTER_STATE_INITIALIZE
MSTP_MASTER_STATE_USE_TOKEN
MSTP_MASTER_STATE_WAIT_FOR_REPLY
MSTP_MASTER_STATE_DONE_WITH_TOKEN
These are sufficient to initialize the node, transmit data, optionally wait for a reply, and prepare for the next transmission cycle—without engaging in token polling or idle waiting.
Step 2: Adjusting State Transitions
With five states removed, the corresponding transition logic must be restructured. The key adjustments are as follows:
-
Direct Transition from MSTP_MASTER_STATE_INITIALIZE to MSTP_MASTER_STATE_USE_TOKEN:
Instead of entering the IDLE state, the node directly transitions from MSTP_MASTER_STATE_INITIALIZE to MSTP_MASTER_STATE_USE_TOKEN, accelerating the start of fuzzing activity by bypassing unnecessary delays.
-
Looping Within MSTP_MASTER_STATE_USE_TOKEN and MSTP_MASTER_STATE_WAIT_FOR_REPLY:
In the MSTP_MASTER_STATE_USE_TOKEN state, the node sends a fuzzed packet. If a reply is expected, it transitions to MSTP_MASTER_STATE_WAIT_FOR_REPLY; once the response is received or timeout occurs, it loops back to MSTP_MASTER_STATE_USE_TOKEN. This tightly-coupled loop ensures that the fuzzer maintains control of the bus.
-
Skips Token Handoff:
The node never enters MSTP_MASTER_STATE_DONE_WITH_TOKEN for the purpose of handing off the token—instead, it recycles the token to itself, ensuring continuous and exclusive medium access.
This modified state machine breaks the conventional token circulation cycle, allowing the fuzzer to dominate the bus and achieve maximum throughput without interference from other devices.
Certain fields in BACnet response packets—such as sequence numbers—must either match the corresponding request or fall within well-defined ranges specified by the protocol. To ensure protocol compliance, we define byte-level expectations for these critical fields based on the BACnet specification and inspect each response against these constraints. Any deviation, such as mismatched values or out-of-range bytes, is flagged as a potential implementation bug or standards violation.
For implementation, we define a Response_Monitor function in bacnet-stack/port/linux/dlmstp_linux.c that validates key fields in each received response. If any violation is detected, the function logs a detailed error message to the bacnet-stack log file. Similar validation mechanisms are implemented in other modules as well.
Below is an excerpt of the Response_Monitor implementation:
void Response_Monitor(uint8_t *buffer) {
if (buffer[0] != 1) {
log_message("Error<npduVersion>.");
return;
}
// bit6 != 0
if ((buffer[1] & 0x40) != 0) {
log_message("Error<npduBit6>.");
return;
}
}
