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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5753 Β· 17 files
FileTypeDescriptionSize
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 with M_SETCTX(m, pcb->sp_link); the controller completes it via hci_complete_sco() (hci_unit.c:538-547) β†’ hci_scodone + netisr.
  • hci_intr()'s scodone branch (hci_unit.c:395-400) walks unit->hci_links and calls hci_sco_complete(link, 1) when the ctx matches β€” after releasing hci_devlock and 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 NULLs link->hl_sco before hci_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'd hl_sco β€” a deterministic NULL write at hl_sco + 0x1c in netisr context. The same window extended past the TAILQ_REMOVE+kfree is a use-after-free write (sp_pending-- on freed memory, or a wild sp_proto->complete call 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).

VERDICT.md
↓ download raw

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied 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)
↓ fix.diffper-fix-DF-5753

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.