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)
PoC verification
Evidence pack
findings/poc/DF-5749 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 harnesshw.l2uh_setup=1β rig facts (sizes/offsets/hz)hw.l2uh_once=1β CONTROL: one unraced round, must be cleanhw.l2uh_race3=Nβ BASELINE TRIGGER (unfixed kernels): a cpu1 kthread fires productionl2cap_rtx()the instant the orchestrator fires productionl2cap_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 insetup.log), builds the KLD.run.shβ baseline run: load, control round,race3=2000.fixbuild.shβ appliesfix.diffto the guest's/usr/srcand rebuilds (never touches the hostsys/tree).fix.diffβ v6 fix (globall2cap_lock, exactly-once request free, single-execution close claim +L2CAP_CLOSINGcompletion 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:
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, ...)withlr_link == NULLon a freed/re-initialized request.panic: zone: freeing free entry,zfree() <- l2cap_rtx()β double free of thel2cap_req.- 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).
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 callsl2cap_close()and thenkfree(chan). Runs aspru_detachnetmsg 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 byl2cap_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 readif (chan->lc_state == L2CAP_CLOSED) return;(line 69); the body walkschan->lc_link->hl_txq(78), walkschan->lc_link->hl_reqscallingl2cap_request_free(86-93), thenhci_acl_close(chan->lc_link)(97) and NULLslc_link(98).l2cap_request_free()(l2cap_misc.c:163-174) βcallout_stop(); if (callout_active()) return;is dead code on DragonFly:callout_stop()clearsCALLOUT_ACTIVEunconditionally (sys/kern/kern_timeout.c:_callout_cancel_or_stop), so both contexts run theTAILQ_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'sl2cap_closewalk callsl2cap_request_freewhilel2cap_rtxis 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/NULLlc_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 productionl2cap_rtx()at the same instant the orchestrating thread fires productionl2cap_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)
- 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 (freshlr_link == NULL):offsetof(struct hci_link, hl_reqs) == 0x70(rig facts line). βpanic.txtpart 1,console_panic.txt. - run 2 (race3=2000, fresh reset): hard freeze at round 0 (guest wedged, no trap) β wild-write collapse.
- run 3 (race3=2000, pristine reset, md5-verified verbatim
sources):
panic: zone: freeing free entry, tracezfree() <- l2cap_rtx() <- r3_rtx_thread()β the unfixedl2cap_rtx()double-freeing its request. βpanic.txtpart 2,run.log/console_baseline_final.txt. - 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_deadguard):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_closeand the direct double-body, butrace4-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_CLOSINGcompletion flag so losers wait): control clean; butrace5(pure production pair) still panics at ~500β5000 rounds:l2cap_rtx+0x9: cmpw $0,0x8(%rdi)with fault VA 0x12342380 β0x12342378isZENTRY_FREE(sys/vm/vm_zone.c:76, stamped at chunk offset 8 =lr_chanon zfree) β i.e. a callout-deliveredl2cap_rtx()executed on a request afterl2cap_request_free's synchronizingcallout_stopreturned andzfreeran. 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.
Fix verification
fix_failedv6 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)
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.
No comments yet.