hci_memo_find/hci_memo_new/hci_memo_free operate on unit->hci_memos with zero synchronization while concurrently reachable from unprivileged user context, netisr-0 and softclock β double-free and write-after-free of 48-byte hci_memo records
Summary
The lazy-expiry reclamation inside hci_memo_find frees expired entries with NO lock while hci_memo_new/free mutate the same list - and three context families reach it: unprivileged L2CAP connect (no privilege gate anywhere on the path), netisr-0 events (devlock dropped before dispatch), softclock link teardown. Two walkers observing the same expired memo both kfree it (48-byte struct in a GLOBAL per-size slab zone shared by every 41-48-byte kmalloc kernel-wide), the found-path microtime() is a 16-byte write-after-free clobbering the freelist link, stale next-pointers walk freed blocks. Full production-stack KLD rig: plain unprivileged program (6 threads socket+connect+close) panicked the guest twice from fresh resets ('kfree usage counter negative' double-free assert + zone-metadata assert with softclock frames). uid0 blocked by always-on slab asserts (documented); devlock-serialization fix validated (0 panics over 6 burst cycles + 12.4M-connect stress).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5780 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness2.c | β | 17.2 KB | view raw | |
| Makefile | β | 1.1 KB | β download | |
| build.sh | β | 140 B | view raw | |
| race.c | β | 3.0 KB | view raw | |
| run.sh | β | 157 B | view raw | |
| run2.sh | β | 1.5 KB | view raw | |
| run3.sh | β | 1.2 KB | view raw | |
| build.log | β | 22.1 KB | view raw | |
| env_setup_md5.txt | β | 1.1 KB | view raw | |
| env.txt | β | 686 B | view raw | |
| panic.txt | β | 1.3 KB | view raw | |
| panic_run2.txt | β | 1.2 KB | view raw | |
| boot_run2_panic.log | β | 492.3 KB | β download | |
| fix.diff | β | 4.0 KB | view raw | |
| fix_run.log | β | 2.2 KB | view raw | |
| VERDICT.md | β | 6.5 KB | β raw | |
| verdict.json | β | 4.8 KB | view raw | |
| manifest.json | β | 1.5 KB | view raw | |
| xfer.sh | β | 822 B | view raw |
DF-5780 VERDICT β reproduced (kernel memory corruption β panic), unprivileged trigger
Bottom line
The unsynchronized hci_memos lifecycle in sys/netbt/hci_misc.c is a real,
guest-verified kernel memory-corruption bug reachable in its decisive step by
an unprivileged local user. Two independent fresh-boot runs of the same
PoC produced two different slab-allocator panics, both caused by the memo
race; a third run with the fix applied produced zero panics under 6x heavier
load. Impact on this kernel: reliable local DoS (panic) with an underlying
double-free / write-after-free primitive whose silent exploitation is blocked
by the kernel's allocator assertions (details below).
Root cause (path:line)
hci_memo_find()walks and mutatesunit->hci_memoswith no lock: sys/netbt/hci_misc.c:120-134 (expiry free at :124-128).hci_memo_new()allocates/inserts and timestamps with no lock: hci_misc.c:149-172 (UAF-write candidatemicrotime(&memo->time)at :171).hci_memo_free()unlinks+frees with no lock: hci_misc.c:175-181.- Concurrent contexts (verified on the guest):
- unprivileged user: L2CAP
connect()βl2cap_sconnect(l2cap_socket.c:351) βl2cap_connect(l2cap_upper.c:140) βhci_route_lookup(hci_misc.c:55) βhci_memo_find. No privilege gate anywhere on this path (the onlycaps_priv_check_selfcalls in netbt are hci_socket.c:644 and hci_ioctl.c:224-287 β not on this path). - netisr-0:
hci_input_eventβhci_intrβ which releases hci_devlock before callinghci_event(hci_unit.c:340β349) β β inquiry/RSSI handlers (hci_event.c:463,500) βhci_memo_newβhci_memo_find. - softclock:
hci_link_freeβhci_memo_new(hci_link.c:1016) β observed live in panic run 1's backtrace (slotimer/softclock frames). - The three loops of
hci_route_lookup(hci_misc.c:65/82/95) and thehci_unit_listmutationshci_attach/hci_detach(hci_unit.c:113-115, 123-127, crit_enter only) are additionally racy β that wider hole is filed separately as DF-5781 (Low).
Evidence
Baseline (unfixed), run 1 β panic.txt:
panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed
in chunk_mark_free at /usr/src/sys/kern/kern_slaballoc.c:1675, trace includes
chunk_mark_free β kfree β hci_memo_* with softclock/timer frames; CPU0
panicked, 5 CPUs stopped.
Baseline (unfixed), run 2 after vm.sh reset with-src (clean state) β
panic_run2.txt / boot_run2_panic.log last lines:
hci_memo_find: memo 0xfffff801188e77e8 too old (expiring) panic: assertion "kuhp < 0" failed ... /usr/src/sys/kern/kern_slaballoc.c
The usage-counter assertion (kuhp < 0 in kfree) fires only when a chunk
is freed more times than it was allocated β i.e., the double free happened in
hci_memo_find's expiry path on the very print line above the panic.
Trigger: su -m maxx -c "/tmp/race 6 30" (uid 1001) during the burst window;
time-to-panic β 5β35 s per run.
Primitive characterization:
* object: struct hci_memo, sizeof == 48 (x86_64), zone class 41β48 bytes,
global per-size-class zone shared by every 41β48-byte kmalloc
kernel-wide (kern_slaballoc.c:638 zoneindex() β no type parameter).
* double kfree() of the same 48-byte chunk (silent freelist dup on kernels
without allocator asserts; hcir_check_dup detects duplicate live
addresses).
* write-after-free: 16-byte microtime write at offset 0 of a freed chunk
(hci_misc.c:171) β clobbers the slab freelist link (c_Next at offset 0).
* list self-link: an aliased block double-LIST_INSERT_HEADed yields
memo->next == memo β observed as multi-second hci_memo_find stalls
(watchdog stuck=1/5 in baseline run 2) before the fatal double free.
Why not uid=0 (hard blocker, documented)
Primary objective push: the primitive is a zone-restricted double-free plus a
semi-controlled (wall-clock valued) 16-byte write-after-free at a fixed offset
of a freed 48-byte chunk. On this guest kernel (and any DragonFly kernel built
with the stock slab allocator), kfree() carries live assertions β
chunk_mark_free zone/bitmap checks (kern_slaballoc.c:1675-1681) and the
kmemusage counter check β that convert every duplicate free into an
immediate panic before the freelist dup can be allocated through. That closes
the classic dup-allocation β victim-aliasing β pointer-control route. The
remaining route (UAF-write at :171 turning a freed chunk's c_Next into a
timestamp-valued fake chunk pointer) offers no attacker-chosen value
(tv_sec/tv_usec are wall clock), no address control, and no leak dependency
relief sufficient to forge a credible victim object; a reliable uid=0 chain
was therefore not achievable within this verification window and is documented
as blocked by allocator self-checks on the target kernel. (No KASLR/SMAP/SMEP
on this guest β those are NOT the blockers; the allocator asserts are.)
Fix validation
fix.diff (hci_misc.c + hci.h + hci_unit.c): serialize memo ops on the
unit's existing hci_devlock (locked/unlocked variants; hci_memo_free
gains a unit argument; hci_memo_new performs find+alloc+insert+timestamp
under one lock β also closing the :171 UAF-write for the found path).
hci_intr drops devlock before hci_event (hci_unit.c:340) so no recursion;
hci_disable purge updated.
- Baseline: 2/2 runs panic (both slab asserts above).
- Patched (
patch -p1 < fix.diffin guest /usr/src, module rebuilt from the fixed production sources): 6/6 burst cycles + one 12.4M-connect debug-off stress with live watchdog: 0 panics, dup=0, stuck=0, guest up (fix_run.loglast lines). TheWALK STUCKentries recorded duringbluetooth_debug=1windows are print-bound devlock hold times (~5000 expiry prints Γ ~4ms β the measured 19.9 s), not corruption; with debug off (production default):watchdog totals: dup=0 stuck=0.
Residual (documented, not reproduced): callers that write fields of the memo
returned by hci_memo_new/hci_memo_find after the lock is dropped
(hci_event.c:466-470, hci_link.c:101-103, hci_link.c:1017-1018) still have a
narrow window against a concurrent expiry free at end-of-life; the fix note
recommends moving those writes under hci_devlock in a follow-up.
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
fixedfix.diff applied to guest /usr/src (patch -p1), module rebuilt from fixed production sources, exact PoC re-run: baseline 2/2 panics -> patched 0 panics over 6 burst cycles + 12.4M-connect debug-off stress; watchdog dup=0 stuck=0; guest stayed up (fix_run.log)
findings/poc/DF-5780/fix_run.log (patched-run totals), fix.diff, VERDICT.md 'Fix validation'
Confirmed kernel references
- sys/netbt/hci_misc.c:113-138
- sys/netbt/hci_misc.c:145-173
- sys/netbt/hci_misc.c:175-181
- sys/netbt/l2cap_socket.c:351-367
- sys/netbt/l2cap_upper.c:140-172
- sys/netbt/hci_event.c:463
- sys/netbt/hci_event.c:500
- sys/netbt/hci_link.c:99-104
- sys/netbt/hci_link.c:1016
- sys/netbt/hci_unit.c:340-349
- sys/netbt/hci_unit.c:247-248
Detail
Exploit chain
unprivileged user: socket(AF_BLUETOOTH,SOCK_SEQPACKET,BTPROTO_L2CAP)+connect loop (each connect walks+expiry-frees the whole memo list) racing the netisr inquiry-result walker over an all-expired list -> both walkers free the same struct hci_memo (48B, global size-class zone) -> double kfree + stale LIST_REMOVE + 16B microtime write-after-free at hci_misc.c:171 (clobbers slab freelist c_Next) + aliased double-insert self-links the list (multi-second walk stalls observed). uid=0 chain blocked on this kernel: kfree's always-on slab asserts (chunk_mark_free zone/bitmap checks, kmemusage counter) convert every duplicate free into an immediate panic before the freelist dup can be allocated through; the remaining UAF-write offers only wall-clock-valued data (no chosen value/address).
Evidence (decisive lines)
panic_run2.txt + boot_run2_panic.log last lines: 'hci_memo_find: memo 0xfffff801188e77e8 too old (expiring)' immediately followed by panic 'assertion "kuhp < 0" failed ... kern_slaballoc.c'; panic.txt: chunk_mark_free zone assert (run 1, timer-context trace); fix_run.log tail: patched run 'watchdog totals: dup=0 stuck=0', guest up
PoC changes
rewrote the seeded PoC sketch into (a) a full-production-stack KLD rig (all sys/netbt/*.c compiled verbatim, real PF_BLUETOOTH domain so the real unprivileged connect() path is exercised; fake controller only plays the hardware role; fabricated struct bsd_device so BLUETOOTH_DEBUG DPRINTFs are dereference-safe -- the first crash turned out to be that artifact and was fixed), (b) burst-mode race scheduling (freeze list, expire all, then release 6 user threads + netisr burst simultaneously -- steady-state churn keeps only 1-2 expired-unreaped memos alive and does not collide), (c) repeated-bdaddr flood to drive the found/update path, (d) a live watchdog kthread (walk-latency probe + canary'd 48B batch dup detection) after post-hoc checks missed transient dups
Verified recommended fix
serialize all hci_memos list operations on unit->hci_devlock (locked/unlocked function split; see fix.diff)
Verdict
Unsynchronized hci_memos lifecycle in sys/netbt/hci_misc.c: hci_memo_find() expiry-frees memos with no lock while reachable concurrently from unprivileged user context (L2CAP/SCO connect -> hci_route_lookup), netisr-0 (hci_event -> hci_memo_new, hci_devlock dropped at hci_unit.c:340) and softclock (hci_link_free -> hci_memo_new). Two fresh-boot PoC runs panicked in the slab allocator: 'kuhp < 0' usage-counter assert (double free) with the hci_memo_find '(expiring)' print on the panic line itself, and a chunk_mark_free zone-metadata assert with timer-context walker frames. Unprivileged uid-1001 racer, ~5-35s to panic. Fix (hci_devlock serialization) validated: 0 panics across 6 burst cycles + 12.4M-connect debug-off stress, dup=0 stuck=0.
No comments yet.