# DF-5749 VERDICT — l2cap_upper.c pass-2

**Finding:** `l2cap_detach()` / `l2cap_close()` (socket close path, cpu0
netisr protocol port) race `l2cap_rtx()` (softclock) on the same
`l2cap_channel` and its `l2cap_req` with no synchronization anywhere in
netbt → double teardown: double `l2cap_request_free` (zone double-free),
`TAILQ_REMOVE` through a NULL/reused `lr_link`, and a second
`l2cap_close()` body running after the first nulled `lc_link`
(`hci_acl_close(NULL)`).

**Status: reproduced (4 baseline crashes across 3 rig designs), impact
panic / kernel memory corruption.  fix.diff v6: fix_failed (details
below).**

## Root cause (path:line)

* `sys/netbt/l2cap_upper.c:259-284` — `l2cap_detach()`: if the channel
  is not CLOSED it calls `l2cap_close()` and then `kfree(chan)`.  Runs
  as `pru_detach` netmsg on the protocol port
  (`sys/netbt/l2cap_socket.c:248-256` → `so->so_port =
  netisr_cpuport(0)`, `sys/kern/uipc_socket.c:259`).
* `sys/netbt/l2cap_misc.c:184-197` — `l2cap_rtx()`: the response
  timeout callback runs on the **softclock thread**; frees the request
  and closes the channel.  Armed by `l2cap_request_alloc`
  (`l2cap_misc.c:106-142`), 30 s default (`net.bluetooth.l2cap.rtx`).
* `sys/netbt/l2cap_lower.c:63-101` — `l2cap_close()`: the only guard is
  the racy single-word read `if (chan->lc_state == L2CAP_CLOSED)
  return;` (line 69); the body walks `chan->lc_link->hl_txq` (78),
  walks `chan->lc_link->hl_reqs` calling `l2cap_request_free` (86-93),
  then `hci_acl_close(chan->lc_link)` (97) and NULLs `lc_link` (98).
* `l2cap_request_free()` (`l2cap_misc.c:163-174`) — `callout_stop(); if
  (callout_active()) return;` is **dead code on DragonFly**:
  `callout_stop()` clears `CALLOUT_ACTIVE` unconditionally
  (`sys/kern/kern_timeout.c:_callout_cancel_or_stop`), so both contexts
  run the `TAILQ_REMOVE`/`zfree`.  (The req-object face of this is
  DF-0745; this finding is the channel/request teardown *pair* racing
  detach — the l2cap analog of DF-0724/DF-5748 on rfcomm.)
* DF's `callout_stop()` is *synchronizing* (waits for an in-flight
  callback, `kern_timeout.c:1091`).  When detach's `l2cap_close` walk
  calls `l2cap_request_free` while `l2cap_rtx` is executing, the stop
  blocks until rtx completes — and rtx's own path already closed the
  channel (`lc_link == NULL`).  The detach context then resumes the
  close body with a stale/NULL `lc_link`: `hci_acl_close(NULL)` /
  double request free.

## Rig (production-code KLD, the DF-5733..5748 evidence class)

`l2cap_upper.c`, `l2cap_lower.c`, `l2cap_misc.c`, `l2cap_signal.c`
compiled **verbatim** (md5s in `setup.log`); stubs replace only the HCI
layer (`hci_acl_close` mirrors `hci_link.c:153-164` minus the expiry
arm).  `arm_round()` builds exactly the post-`l2cap_connect()` state
(`l2cap_upper.c:139-195`): CID allocated, ACL link with the
`hci_acl_open` reference, `L2CAP_WAIT_RECV_CONNECT_RSP`, one
`L2CAP_CONNECT_REQ` request with the rtx callout armed.

* `race3` — cpu1 kthread fires production `l2cap_rtx()` at the same
  instant the orchestrating thread fires production `l2cap_detach()`
  (models the softclock callback having been entered — what production
  reaches whenever the timeout fires while the socket is closing).
* `race4` — 100% real-callout delivery, armer cpu0 / reaper cpu1.
* `race5` — the pure production pair: a single worker performs ALL
  port-serialized operations (attach/cid/request_alloc/detach, modeling
  the cpu0 netisr-port serialization of connect(2)/close(2)), a cpu0
  helper only re-arms the production callout; detach lands on the
  firing tick.

## Baseline results (unfixed kernel; 4 crashes, signatures)

1. **run 1** (race3=2000, fresh `vm.sh reset`): `Fatal trap 12`,
   fault VA **0x70**, `supervisor WRITE`,
   `Stopped at l2cap_request_free+0x70: movq %rdx,0x70(%r13)` —
   `TAILQ_REMOVE(&req->lr_link->hl_reqs, ...)` on a request whose slot
   was freed by the rtx side and re-armed (fresh `lr_link == NULL`):
   `offsetof(struct hci_link, hl_reqs) == 0x70` (rig facts line).
   → `panic.txt` part 1, `console_panic.txt`.
2. **run 2** (race3=2000, fresh reset): hard freeze at round 0 (guest
   wedged, no trap) — wild-write collapse.
3. **run 3** (race3=2000, pristine reset, md5-verified verbatim
   sources): `panic: zone: freeing free entry`, trace
   `zfree() <- l2cap_rtx() <- r3_rtx_thread()` — the unfixed
   `l2cap_rtx()` double-freeing its request. → `panic.txt` part 2,
   `run.log`/`console_baseline_final.txt`.
4. **race4** (production callouts): hard wedge (arm counters frozen,
   guest dead) on the unfixed kernel.

Single-CPU phase-alignment note: a same-thread burst rig
(`hw.l2uh_race`) ran 100 000 clean rounds — the LWKT scheduler never
interleaves the syscall thread *mid-body* with the softclock batch on
one cpu.  The production reality is cross-cpu (softclock vs protocol
port), which race3/race4/race5 model.

## Fix validation — fix_status: fix_failed (honest record)

`fix.diff` (v6) adds: global `l2cap_lock` spinlock; `l2cap_request_free`
claiming ownership (test-and-clear `lr_chan`) **before** the
synchronizing `callout_stop`, removing+freeing exclusively;
`l2cap_close` claiming single-execution (state=CLOSED under the lock,
`L2CAP_CLOSING` flag, losers wait for body completion so
`l2cap_detach`'s `kfree` is ordered); request insertion under the lock.

Iterations (all on verbatim-source rigs):

* v1 (in-object `lr_dead` guard): `panic: zone: freeing free entry` —
  the guard is read from freed zone memory whose freelist linkage
  overlays the field.
* v2/v3 (lock + post-drain re-check / claim-before-walk): eliminated
  the NULL `hci_acl_close` and the direct double-body, but
  `race4`-stress wedged: the post-drain sentinel read still touches a
  freed+reused slot.
* v4 (sentinel claim before drain): fixed the walk ordering, but the
  claim-*winner*'s body still ran concurrently with the loser-detach's
  `kfree(chan)` → UAF writes onto a reused 176-byte zone slot → later
  garbage-pointer reads.
* v6 (adds `L2CAP_CLOSING` completion flag so losers wait): **control
  clean; but `race5` (pure production pair) still panics at ~500–5000
  rounds**: `l2cap_rtx+0x9: cmpw $0,0x8(%rdi)` with fault VA
  **0x12342380** — `0x12342378` is `ZENTRY_FREE`
  (`sys/vm/vm_zone.c:76`, stamped at chunk offset 8 = `lr_chan` on
  zfree) — i.e. a callout-delivered `l2cap_rtx()` executed on a
  request *after* `l2cap_request_free`'s synchronizing `callout_stop`
  returned and `zfree` ran.  The stop-vs-dispatch interleave on a
  foreign cpu's softclock queue is not closed by an l2cap-level stop.

**Conclusion:** the demonstrated DF-5749 interleavings are narrowed but
not eliminated; a complete fix requires either (a) deferred request
reclamation (do not return the request to the zone until its callback
generation is provably dead — e.g. a per-request completion the
dispatcher acknowledges), or (b) the upstream NetBSD model: one global
l2cap lock held across rtx/close/request lifecycle with a
`callout_halt(&req->lr_rtx, &lock)`-style primitive that releases the
lock while draining (DF would need the callout subsystem to expose the
equivalent).  v6 is attached as the best minimal single-cluster diff
and a stepping stone; it must not be merged as-is.

## Impact

Unprivileged local user with a Bluetooth controller present
(`l2cap_connect()` needs an hci_unit; no peer response is required to
arm the 30 s rtx) can panic/corrupt the kernel by racing close(2)
against the response timeout in a loop.  Netbt is KLD-only
(`kldload netbt` / auto-load on AF_BLUETOOTH socket).  CVSS
3.1: AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (5.5), with the memory-
corruption faces (double free, UAF writes) rated in the memcorrupt
bucket.

## Residuals / related (not re-reported here)

* DF-0745 (req-object double-free face), DF-0746 (rtx UAF read).
* DF-5737/5739 (hci-side teardown races), DF-5740 (hci socket bufs).
* Pre-existing lockless list walks (`l2cap_active_list`,
  `l2cap_listen_list`) across btintr/netisr-port on multi-cpu — noted,
  production-serialized on single-port guests.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
