hci_intr()'s scodone completion dispatch (membership walk + hci_sco_complete) is unlocked and unreferenced against syscall-context SCO teardown: sco_disconnect NULLs hl_sco then frees the link while still listed -> NULL-pointer kernel write (sp_pending--) / UAF in netisr context
Summary
The scodone branch releases hci_devlock then walks the link list matching M_GETCTX to call hci_sco_complete - no lock, no reference. The unprivileged close path NULLs hl_sco BEFORE hci_link_free removes the link (removal is the last step of ~40 lines): a completion in that window executes hl_sco->sp_pending-- on NULL - reproduced as a supervisor WRITE at exactly offsetof(sp_pending) on the netisr thread; past the kfree it is a UAF write plus a call through a freed proto pointer. The DF-5737 counterpart (opposite direction). devlock serialization fix validated over 1500 rounds/~28k completions.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5753 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | β | 22.3 KB | view raw | |
| hci_stub.c | β | 2.4 KB | view raw | |
| Makefile | β | 624 B | β download | |
| build.sh | β | 84 B | view raw | |
| run.sh | β | 161 B | view raw | |
| xfer.sh | β | 801 B | view raw | |
| build.log | β | 13.0 KB | view raw | |
| setup.log | β | 871 B | view raw | |
| run.log | β | 1.4 KB | view raw | |
| fix_run.log | β | 4.5 KB | view raw | |
| panic.txt | β | 583 B | view raw | |
| fix.diff | β | 1.4 KB | view raw | |
| README.md | β | 2.4 KB | β raw | |
| VERDICT.md | β | 4.9 KB | β raw | |
| manifest.json | β | 1.5 KB | view raw | |
| verdict.json | β | 4.6 KB | view raw | |
| env.txt | β | 206 B | view raw |
DF-5753 β scodone completion dispatch races syscall-context SCO link teardown: NULL/UAF write in hci_sco_complete
File: sys/netbt/hci_unit.c (pass 2) β hci_intr() scodone branch
Trigger surface: hci_complete_sco + the hci_scodone membership walk
Build (inside the guest, as root)
cd /root/hciuu sh build.sh # verbatim production netbt sources (md5s recorded)
Run (inside the guest, as root)
kldload /root/hciuu/hci_unit_harness.ko sysctl hw.hciu_setup=1 # attach fake unit (production hci_attach) sysctl hw.hcius_fire=1500 # 1500 rounds of [200 SCO sends + sco_disconnect(pcb,0)]
Expected
Within the first rounds (baseline observed the crash immediately after round 0):
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x1c fault code = supervisor write data, page not present Stopped at hci_sco_complete+0x7: subl $0x1,0x1c(%rax)
i.e. link->hl_sco->sp_pending-- (hci_link.c:884-892) with
hl_sco == NULL on the netisr thread (current process = Idle).
Mechanism
sco_send()(sco_upper.c:310) tags each TX mbuf withM_SETCTX(m, pcb->sp_link); the controller completes it viahci_complete_sco()(hci_unit.c:538-547) βhci_scodone+ netisr.hci_intr()'s scodone branch (hci_unit.c:395-400) walksunit->hci_linksand callshci_sco_complete(link, 1)when the ctx matches β after releasinghci_devlockand with no reference on the link.- The socket-close path
sco_sabort()βsco_disconnect(pcb, 0)(sco_socket.c:253, sco_upper.c:213-217) runs on the syscall thread: it NULLslink->hl_scobeforehci_link_free()removes the link from the list (hci_link.c:1019-1023). Any scodone mbuf dispatched in that window matches the still-listed link and dereferences the NULL'dhl_scoβ a deterministic NULL write athl_sco + 0x1cin netisr context. The same window extended past theTAILQ_REMOVE+kfreeis a use-after-free write (sp_pending--on freed memory, or a wildsp_proto->completecall when the chunk is reused).
Fix check
Apply fix.diff (scodone dispatch under hci_devlock in hci_intr;
sco_disconnect teardown under the same lock β unit pointer saved before
hci_link_free, which frees the link), rebuild, reboot, re-run
hcius_fire=1500: all 1500 rounds complete, no panic (fix_run.log;
~28k completions processed through the locked dispatch).
DF-5753 β VERDICT
Status: reproduced (NULL-pointer kernel write in netisr context, race window deterministic under load). Impact: panic (write-capable race; UAF extension documented). Confidence: certain.
Root cause
hci_intr()'s SCO-completion dispatch (sys/netbt/hci_unit.c:387-406):
IF_DEQUEUE(&unit->hci_scodone, m);
if (m != NULL) {
struct hci_link *link;
lockmgr(&unit->hci_devlock, LK_RELEASE); /* <-- unlocked */
TAILQ_FOREACH(link, &unit->hci_links, hl_next) {
if (link == M_GETCTX(m, struct hci_link *)) {
hci_sco_complete(link, 1); /* <-- no ref, no lock */
break;
}
}
The mbufs on hci_scodone carry the SCO link pointer as context
(M_SETCTX(m, pcb->sp_link), sco_upper.c:310). The membership walk is
the only liveness "check", and it runs with no lock and no reference β
it is a TOCTOU against syscall-context teardown:
sco_sabort() (socket close/abort, unprivileged owner of the socket,
sco_socket.c:253) β sco_disconnect(pcb, 0) (sco_upper.c:213-217):
sco->hl_sco = NULL; /* pcb unhooked first */
pcb->sp_link = NULL;
hci_link_free(sco, err); /* ... then ~40 lines before the
TAILQ_REMOVE + kfree */
hci_link_free() (hci_link.c:931-1024) does a large amount of work
(l2cap_active_list walk, request/PDU teardown, ACL-anchor close, memo
kmalloc) before the final TAILQ_REMOVE + kfree β the link stays on
unit->hci_links with hl_sco == NULL for that whole window. Any
scodone mbuf the netisr thread dispatches in the window matches the
still-listed link and executes hci_sco_complete():
hci_sco_complete(struct hci_link *link, int num) /* hci_link.c:884 */
{
link->hl_sco->sp_pending--; /* hl_sco == NULL ! */
(*link->hl_sco->sp_proto->complete)(link->hl_sco->sp_upper, num);
Reproduction
The rig (production sco_attach/hci_link_alloc/sco_send/
hci_complete_sco/sco_disconnect verbatim; the fake controller
completes TX instantly the way ubt's USB completion does) runs 1500
rounds of [200 sp_mtu-sized sends β 200 scodone entries β
sco_disconnect(pcb, 0)]. The crash landed within the first rounds:
Fatal trap 12: page fault while in kernel mode cpuid = 0 fault virtual address = 0x1c fault code = supervisor write data, page not present current process = Idle <-- netisr thread Stopped at hci_sco_complete+0x7: subl $0x1,0x1c(%rax)
0x1c is exactly offsetof(struct sco_pcb, sp_pending); rax == NULL
proves hl_sco was read as NULL while the link was still on the list β
only possible inside the sco_disconnect window above (an in-list link
cannot yet have been freed or reused), so the race attribution is exact.
Note: the baseline run.log also shows one DF-5752 rig marker
("mid-drain re-injection") β the harness of that build had the DF-5752
re-inject enabled by default; it is unrelated to the SCO path and the
faulting instruction is decisively hci_sco_complete.
UAF extension
The same dispatch window extends past hci_link_free()'s kfree
(hci_link.c:1024): a scodone mbuf dequeued a few microseconds later walks
a list whose entries were freed, and (if the freed chunk is reused as a
new hci_link, same M_BLUETOOTH zone) falsely matches a fresh link and
credits the wrong connection β or matches a link mid-setup whose
hl_sco is still NULL (same NULL write). The write primitive
(*(hl_sco + 0x1c) -= 1, then a call through hl_sco->sp_proto) is
write-capable memory corruption whenever hl_sco can be made to hold
reused memory.
Threat
Any local user who owns an SCO socket (bluetooth audio path) racing
close(2)/abort against in-flight TX completions; no privilege needed
beyond the socket. Requires a controller driver on netbt (as with the
whole netbt family). This is the completion-dispatch counterpart of
DF-5737 (event-context teardown vs syscall TX) β same missing-lock
family, opposite direction, different file and sink.
Fix validation
fix.diff: (a) the scodone walk + hci_sco_complete() run under
hci_devlock (re-acquired around the dispatch in hci_intr);
(b) sco_disconnect() performs the hl_sco = NULL + hci_link_free()
teardown under the same lock (unit pointer saved before the free β
the first fix iteration released the lock through the freed link and
crashed in lockmgr_release; that was a rig-visible fix bug, corrected
in the shipped diff).
Post-fix: hcius_fire=1500 β all 1500 rounds, ~28k completions through
the locked dispatch, no panic (fix_run.log), guest 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
fixedApplied fix.diff (devlock around the scodone dispatch in hci_intr + devlock around the sco_disconnect teardown with the unit pointer saved before hci_link_free), rebuilt the verbatim-source module, rebooted, re-ran hcius_fire=1500: all 1500 rounds completed with ~28k hci_sco_complete dispatches and no panic; guest stayed up.
findings/poc/DF-5753/fix_run.log: rounds through 1475 with completes=28613 and final 'all rounds done without a hit'; contrast panic.txt (crash at round <=25 pre-fix)
Confirmed kernel references
Detail
Exploit chain
local unprivileged owner of an SCO socket sends sp_mtu-sized packets (in-flight TX completions pile up in hci_scodone via hci_complete_sco), then closes/aborts the socket: sco_disconnect NULLs hl_sco then spends ~40 lines in hci_link_free before the TAILQ_REMOVE; the netisr thread's scodone membership walk matches the still-listed link in that window and writes *(NULL + 0x1c) -= 1 -> fatal page fault (kernel write). Extended window (past kfree): sp_pending-- on freed memory and a call through freed/reused hl_sco->sp_proto; with same-zone chunk reuse as a fresh hci_link the wrong link is credited (accounting corruption) or another NULL hl_sco is hit
Evidence (decisive lines)
panic.txt / run.log: 'Fatal trap 12 ... fault virtual address = 0x1c, supervisor write data ... current process = Idle ... Stopped at hci_sco_complete+0x7: subl $0x1,0x1c(%rax)' after round 0; fix_run.log: post-fix 'all rounds done without a hit' at round 1500 with ~28k completions (completes=28613) and guest up
PoC changes
rig wires a production sco_attach() pcb to a production hci_link_alloc()d SCO link (sp_mtu=32) and uses production sco_send/sco_disconnect verbatim; the fake controller's output_sco calls production hci_complete_sco() instantly (what ubt's USB completion interrupt does); 200 sends per round widen the netisr drain against the teardown
Verified recommended fix
Run the scodone membership walk + hci_sco_complete() under hci_devlock, and perform sco_disconnect()'s hl_sco=NULL + hci_link_free() teardown under the same lock (unit pointer saved before the free)
Verdict
Reproduced as a deterministic NULL-pointer kernel WRITE in netisr context: hci_intr()'s scodone branch (hci_unit.c:387-406) walks unit->hci_links and calls hci_sco_complete(link,1) after releasing hci_devlock and with no reference on the matched link, while the unprivileged socket-close path sco_sabort() -> sco_disconnect(pcb,0) NULLs link->hl_sco (sco_upper.c:215) and runs hci_link_free() on the syscall thread, with the link staying on the list until hci_link.c:1019. hci_sco_complete (hci_link.c:884-892) unconditionally executes link->hl_sco->sp_pending--; the rig hit the window within the first rounds of 1500x[200 sends + disconnect], faulting at 0x1c (offsetof sco_pcb.sp_pending) with rax=NULL, in hci_sco_complete+0x7 on the netisr thread. The same window past the kfree (hci_link.c:1024) is a UAF write / wild sp_proto->complete call when the chunk is reused. Fix validated: devlock-guarded dispatch + devlock-guarded teardown (unit pointer saved before the free) -> 1500 rounds / ~28k completions with no panic.
No comments yet.