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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5780 Β· 19 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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 mutates unit->hci_memos with 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 candidate microtime(&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 only caps_priv_check_self calls 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 calling hci_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 the hci_unit_list mutations hci_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.diff in 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.log last lines). The WALK STUCK entries recorded during bluetooth_debug=1 windows 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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'
↓ fix.diffper-fix-DF-5780

Confirmed kernel references

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.