β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-5749

Unsynchronized softclock l2cap_rtx() vs netisr-port l2cap_detach()/l2cap_close(): double channel/request teardown β€” kernel page fault (supervisor WRITE), zone double-free, hard freeze (l2cap analog of DF-0724/5748, co-firing DF-0745's sink)

Summary

Both teardown contexts execute with no lock anywhere in netbt: the only guards are a racy single-word state read and a callout_active() deferral that is DEAD CODE (callout_stop clears ACTIVE unconditionally) - worse, callout_stop SYNCHRONIZES, deterministically ordering the second close body to resume after the rtx path freed the request and NULLed lc_link: TAILQ_REMOVE through NULL/reused lr_link, hci_acl_close(NULL), double-zfree. 4 baseline crashes captured across pristine-reset guests (supervisor WRITE at 0x70 in request_free, 'zone: freeing free entry', hard freezes); unpriv reach needs only connect()+close() timed against the 30s rtx (no peer response needed). Six fix iterations narrowed but did not close (residual post-zfree callout-delivered rtx, fault VA ZENTRY_FREE+8) - honestly fix_failed, upstream global-lock/callout_halt model recommended.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5749 Β· 21 files
FileTypeDescriptionSize
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 8.3 KB ↓ raw
harness.c β€” 22.3 KB view raw
stub.c β€” 4.5 KB view raw
l2uh.h β€” 481 B view raw
Makefile β€” 528 B ↓ download
build.sh β€” 93 B view raw
run.sh β€” 156 B view raw
fixbuild.sh β€” 719 B view raw
fix.diff β€” 7.5 KB view raw
build.log β€” 10.5 KB view raw
setup.log β€” 516 B view raw
run.log β€” 1.4 KB view raw
panic.txt β€” 1.3 KB view raw
console_panic.txt β€” 917 B view raw
console_baseline_final.txt β€” 1.4 KB view raw
fix_build.log β€” 5.2 KB view raw
fix_run.log β€” 1.3 KB view raw
env.txt β€” 303 B view raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 5.2 KB view raw

DF-5749 β€” Unsynchronized l2cap_rtx() vs l2cap_detach()/l2cap_close() double-teardown: kernel panic (page fault / zone double-free), sys/netbt/l2cap_upper.c

l2cap_detach() (the close(2) path, running as a netmsg on the cpu0 netisr protocol port) and the l2cap_rtx() response-timeout callback (running on the softclock thread) both tear down the same l2cap_channel with zero synchronization between the two threads. The evidence rig compiles the production l2cap_upper.c / l2cap_lower.c / l2cap_misc.c / l2cap_signal.c verbatim into a KLD and drives exactly those two production contexts.

Contents

  • harness.c, stub.c, l2uh.h, Makefile β€” KLD harness
  • hw.l2uh_setup=1 β€” rig facts (sizes/offsets/hz)
  • hw.l2uh_once=1 β€” CONTROL: one unraced round, must be clean
  • hw.l2uh_race3=N β€” BASELINE TRIGGER (unfixed kernels): a cpu1 kthread fires production l2cap_rtx() the instant the orchestrator fires production l2cap_detach() on a freshly armed channel (models "softclock callback already entered").
  • hw.l2uh_race4=N β€” production-callout-delivery stress (armer cpu0, reaper cpu1). NOTE: its armer also races active-list ops that production serializes on the protocol port β€” rig artifact, see VERDICT.md.
  • hw.l2uh_race5=N β€” the pure production pair only: one worker does every port-serialized op; a cpu0 helper only re-arms the rtx callout; detach lands on the firing tick.
  • hw.l2uh_state=1 β€” counters.
  • build.sh β€” copies the verbatim production sources from /usr/src/sys/netbt (md5s recorded in setup.log), builds the KLD.
  • run.sh β€” baseline run: load, control round, race3=2000.
  • fixbuild.sh β€” applies fix.diff to the guest's /usr/src and rebuilds (never touches the host sys/ tree).
  • fix.diff β€” v6 fix (global l2cap_lock, exactly-once request free, single-execution close claim + L2CAP_CLOSING completion flag).
  • Logs: build.log, setup.log, run.log, panic.txt, console_baseline_final.txt, console_panic.txt (baseline); fix_build.log, fix_run.log (patched kernel).

Reproduce (baseline panic)

# guest, as root:
kldload /root/l2uh/l2uh_harness.ko
sysctl hw.l2uh_setup=1     # facts
sysctl hw.l2uh_once=1      # control: clean
sysctl hw.l2uh_race3=2000  # EXPECT: kernel panic within seconds

Expected result (vulnerable kernel): panic. Observed signatures across 4 baseline runs:

  1. Fatal trap 12: page fault while in kernel mode, fault virtual address = 0x70, supervisor write data, Stopped at l2cap_request_free+0x70: movq %rdx,0x70(%r13) β€” TAILQ_REMOVE(&req->lr_link->hl_reqs, ...) with lr_link == NULL on a freed/re-initialized request.
  2. panic: zone: freeing free entry, zfree() <- l2cap_rtx() β€” double free of the l2cap_req.
  3. Hard freeze (no trap output) under sustained churn.

Patched kernel (fix.diff v6): the control round and the burst rig pass, but the pure production-pair stress (race5) still reaches a post-zfree l2cap_rtx() invocation (fault address 0x12342380 = ZENTRY_FREE+8: req->lr_chan of a freed zone entry) after ~500–5000 rounds β€” the callout_stop() drain does not close the stop-vs-dispatch window on a foreign cpu's softclock queue. fix_status: fix_failed; a complete fix needs deferred request reclamation or the upstream NetBSD model (global l2cap mutex + callout_halt-with-lock); see VERDICT.md.

Environment

DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC, 6 vCPU, cc 8.3 [DragonFly], netbt not compiled into GENERIC (KLD).

VERDICT.md
↓ download raw

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.

  • 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.

Fix verification

fix_failed
baseline reproduced→ patch + rebuild →patched reproduced

v6 fix.diff applied to guest /usr/src, module rebuilt from patched verbatim sources (md5-verified), guest rebooted: control round and burst rig clean, but the pure production-pair race5 still panics after ~500-5000 rounds (l2cap_rtx on a post-zfree request: fault VA 0x12342380 = ZENTRY_FREE+8). The synchronizing callout_stop() in l2cap_request_free does not close the stop-vs-dispatch window on the foreign cpu's softclock queue; complete fix requires deferred reclamation or the upstream global-lock + callout_halt-with-lock model.

fix_build.log; fix_run.log (patched-kernel panic at l2cap_rtx+0x9); VERDICT.md 'Fix validation' section (v1->v6 iteration record)
↓ fix.diffper-fix-DF-5749

Confirmed kernel references

Detail

Exploit chain

unprivileged local user (system with a Bluetooth controller; netbt KLD) -> connect(2) arms the 30s l2cap rtx (no peer response needed) -> close(2) timed against the timeout -> double l2cap_close()/l2cap_request_free -> kernel page fault / zone double-free / heap corruption (panic class; memory-corruption primitive demonstrated via the double free and freed-slot writes, no uid0 chain developed -- local DoS with corruption potential)

Evidence (decisive lines)

panic.txt (both trap signatures); run.log / console_baseline_final.txt (pristine-reset baseline: 'zone: freeing free entry' zfree<-l2cap_rtx); console_panic.txt (Fatal trap 12 write at 0x70 in l2cap_request_free+0x70); fix_run.log (v6-patched kernel still panicking at l2cap_rtx+0x9, fault 0x12342380=ZENTRY_FREE+8); setup.log (verbatim-source md5s); VERDICT.md (full interleaving analysis + fix iteration record)

PoC changes

rig written fresh (no seed): KLD compiles production l2cap_upper/lower/misc/signal.c verbatim with faithful hci stubs (hci_acl_close mirrors hci_link.c:153-164); three race drivers developed (race3 direct-callback-fire, race4 real-callout churn, race5 pure production pair) after the single-threaded burst mode proved unable to interleave mid-body on one cpu (100k clean rounds documented); fix.diff iterated v1->v6 during validation

Verified recommended fix

Serialize the L2CAP channel/request teardown end-to-end: upstream-model global l2cap lock held across rtx/close/request lifecycle plus a callout-halt-with-released-lock primitive (or defer l2cap_req zone reclamation until its callback generation is provably dead). v6 fix.diff attached as partial stepping stone only.

Verdict

REPRODUCED (4 baseline crashes, 3 rig generations). l2cap_detach() on the cpu0 netisr protocol port (close(2)) races the softclock l2cap_rtx() timeout with zero synchronization: both tear down the same l2cap_channel/l2cap_req. Observed: Fatal trap 12 supervisor WRITE at 0x70 (TAILQ_REMOVE through NULL/reused lr_link in l2cap_request_free), 'panic: zone: freeing free entry' from zfree() <- l2cap_rtx() (request double-free), and hard freezes. Production-code KLD rig compiles l2cap_upper/lower/misc/signal.c verbatim (md5-verified) and drives only production entry points; the pure production-pair rig (single port-serialized worker + real 1-tick callout delivery on cpu0 softclock vs detach) also kills the unfixed kernel. fix.diff v6 (global l2cap_lock + exactly-once request free + single-execution close claim + L2CAP_CLOSING completion flag) survives the control and burst rigs but NOT the production-pair stress: a callout-delivered l2cap_rtx() still executes after l2cap_request_free()'s synchronizing callout_stop() returned and zfree ran (fault VA 0x12342380 = ZENTRY_FREE+8, i.e. req->lr_chan of a freed zone entry) -> fix_status fix_failed; a complete fix needs deferred request reclamation or the upstream NetBSD global-lock + callout_halt-with-lock model.