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

usb_filter_detach() error path leaves the destroyed knote linked in the live usb_fifo klist β€” KNOTE() then walks freed memory and dispatches kn->kn_fop->f_event through attacker-reclaimed heap (unprivileged kernel UAF with controlled indirect call)

Summary

usb_filter_detach returns early when usb_ref_device fails - trivially arrangeable by saturating the fifo refcount at USB_FIFO_REF_MAX with 254 readers blocked in read() - without knote_remove: the caller kfrees the knote immediately, leaving it linked in the LIVE fifo klist. The next KNOTE (every usb_fifo_wakeup from every transfer completion, or fifo_close) executes kn->kn_fop->f_event(kn,hint) - an indirect call through fully attacker-reclaimable heap (AF_UNIX bind spray forges the 136-byte knote verbatim). REPRODUCED 5x AS UNPRIV USER maxx on /dev/usb/0.2.1: kn_fop=0x4141414141414141 -> Fatal trap 9 at filter_event+0x4 (3x); kn_fop=&kprintf-24 -> indirect call executed, trap moved to filter_event+0x1d ret (2x). On the no-SMAP/SMEP/KASLR guest uid=0 is one fake-filterops/ROP step away (documented, not completed). Unlink-on-failure fix validated: rebuilt kernel survives identical PoC (baseline panicked 3x/trapped 2x).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5308 Β· 18 files
FileTypeDescriptionSize
df5308.c β€” 9.5 KB view raw
diag.c β€” 1.6 KB view raw
build.sh β€” 85 B view raw
run.sh β€” 158 B view raw
run.log β€” 1.2 KB view raw
run.2.log β€” 601 B view raw
panic.txt β€” 606 B view raw
panic.2.txt β€” 590 B view raw
panic.fault.txt β€” 591 B view raw
panic.call.txt β€” 576 B view raw
panic.call.1.txt β€” 665 B view raw
env.txt β€” 1.3 KB view raw
fix.diff β€” 2.4 KB view raw
fix_build.log β€” 825 B view raw
fix_validation.md β€” 1.6 KB ↓ raw
README.md β€” 5.4 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
verdict.json β€” 6.3 KB view raw

DF-5308 β€” usb_filter_detach() error path leaves the dropped knote linked in the live USB fifo's klist β†’ freed-knote walk + attacker-controlled f_event dispatch (use-after-free)

What

usb_filter_detach() (sys/bus/u4b/usb_dev.c:1222) is the knote f_detach callback the USB fifo layer installs from usb_kqfilter(). It begins by taking a device reference:

err = usb_ref_device(cpd, &refs, 0 /* no uref */ );
if (err) {
        return;                     /* <-- BUG: knote stays linked */
}
...
klist = &f->selinfo.ki_note;
knote_remove(klist, kn);            /* only reached on success */

usb_ref_device() fails for several routine reasons while the fifo is still alive β€” most simply because the FIFO refcount is saturated (f->refcount == USB_FIFO_REF_MAX, usb_dev.c:251), or the device is in USB_STATE_DETACHED (usb_dev.c:211), or the fifo slot was just delinked (usb_dev.c:251). When it fails, the knote is not removed from f->selinfo.ki_note β€” but the caller, knote_detach_and_drop() (sys/kern/kern_event.c:1764), destroys the knote immediately after f_detach returns. The freed knote remains linked in the fifo's klist.

The next KNOTE(&f->selinfo.ki_note, 0) β€” issued from usb_fifo_close() (usb_dev.c:792, gated only on f->flag_isselect, which the early-return fails to clear) or from usb_fifo_wakeup() (usb_dev.c:1784, reachable from every USB transfer completion via usb_fifo_put_data*) β€” walks the list with knote() (kern_event.c:1810): reads kn->kn_kq, kn->kn_status, and calls filter_event(kn, hint) β†’ kn->kn_fop->f_event(kn, hint) β€” a function pointer load and indirect call through freed, attacker-reclaimable heap memory, with rdi pointing at the (attacker-forged) knote.

Trigger (fully unprivileged; user maxx, member of operator)

  1. fd = open("/dev/usb/0.2.1", O_RDONLY) β€” ugen interrupt-IN fifo of the QEMU usb-mouse on EHCI (the same rig DF-5287/DF-5288 used).
  2. Register EVFILT_READ knotes on fd from several kqueues (knote fanout).
  3. Fill the per-CPU knote cache (KNOTE_CACHE_MAX = 64) so later freed knotes are kfree()d to the slab allocator and become reclaimable by any same-size kmalloc.
  4. fork() 254 children that block in read(fd) β€” each blocked reader holds one f->refcount (usb_dev.c:1426/285), saturating it at USB_FIFO_REF_MAX (0xFF). Marker: any further ioctl on the fd fails with ENXIO (usb_ioctl β†’ usb_ref_device, usb_dev.c:1094).
  5. close() the kqueues β†’ kqueue_terminate() β†’ knote_detach_and_drop() β†’ usb_filter_detach() β†’ usb_ref_device() fails (saturated refcount) β†’ early return; knotes freed while still linked.
  6. Reclaim the freed knote slots with fully controlled 136-byte blocks: bind() AF_UNIX sockets with sa_len = 136 == sizeof(struct knote); dup_sockaddr() (kern/uipc_usrreq.c:1165 β†’ kern ker) kmallocs sa_len bytes and copies the user buffer verbatim. DragonFly slab zones are shared per-size across malloc types, so the M_SONAME block lands on the freed M_KQUEUE knote. Forged fields: kn_next = NULL, kn_kq = 0x4343..., kn_status = 0, kn_fop = TARGET.
  7. Kill the readers (refcount drains), then close(fd) β†’ usb_cdevpriv_dtor β†’ usb_fifo_close() β†’ flag_isselect still set β†’ KNOTE() β†’ knote() walks the forged knote β†’ filter_event().

Observed results (with-src guest kernel, usb_dev.c md5-identical to the

audited tree)

  • fault rig (kn_fop = 0x4141414141414141): Fatal trap 9: general protection fault, Stopped at filter_event+0x4: testb $0x2,(%rax) β€” the instruction is the kn->kn_fop->f_flags & FILTEROP_MPSAFE load; %rax is our chosen non-canonical kn_fop. Reproduced 3Γ— (panic.txt, panic.2.txt, panic.fault.txt).
  • call rig (kn_fop = &kprintf - 24, so f_event == kprintf): Stopped at filter_event+0x1d: ret β€” the indirect f_event call through the forged kn_fop executed (the trap moved from the f_flags load to the post-call ret exactly as the forged pointer dictates) and control flow was corrupted. Reproduced 2Γ— (panic.call.txt, panic.call.1.txt).
  • the run marker shows the mechanism end-to-end: while saturated: ioctl = -1 ENXIO (refcount saturated β†’ the same failure usb_filter_detach hits) then after drain: errno = ENOTTY (refs released, dispatch reachable again).

Primitive: arbitrary kernel function call with rdi = 136-byte attacker-controlled heap block (and an 8-byte kn_next/status-controlled walk). On this guest (no SMAP/SMEP/KASLR) this is a standard fake-filterops/ROP step away from uid=0 (point kn_fop at a controlled kernel-address structure, pivot on rdi). The chain was not completed this session; the controlled dispatch and controlled fault address are proven.

Fix

fix.diff: on the usb_ref_device() failure path, best-effort unlink the knote β€” re-derive the fifo pointer under usb_ref_lock (same lookup usb_ref_device() performs) and knote_remove() it if the fifo slot is still populated; if the slot is NULL the fifo (and its klist) is already gone and there is nothing to unlink. Validated by rebuild + re-run (fix_validation.md).

Files

  • df5308.c β€” the trigger (fault/call modes)
  • diag.c β€” refcount-saturation diagnostic
  • build.sh, run.sh
  • run.log, run.2.log, panic*.txt β€” decisive runs
  • env.txt β€” guest environment
  • fix.diff, fix_build.log, fix_validation.md
VERDICT.md
↓ download raw

VERDICT β€” DF-5308 (usb_filter_detach knote use-after-free)

status: reproduced / impact: panic (controlled function-pointer dispatch through reclaimed kernel heap β€” memcorrupt; uid0 chain one fake-filterops step away on this no-SMAP/SMEP/KASLR guest) / confidence: certain

Root cause (line-accurate)

  • sys/bus/u4b/usb_dev.c:1239-1242 β€” usb_filter_detach() calls usb_ref_device(cpd, &refs, 0); on failure it returns without knote_remove(), leaving the knote linked in the live fifo's f->selinfo.ki_note and leaving f->flag_isselect = 1 (usb_dev.c:1264 is the only other clear site).
  • sys/kern/kern_event.c:1764-1775 β€” knote_detach_and_drop() calls f_detach() then knote_drop() β†’ knote_free() β†’ per-CPU cache or kfree(kn, M_KQUEUE) (kern_event.c:2096). The knote is destroyed even though the device-side klist still references it.
  • sys/bus/u4b/usb_dev.c:791-796 β€” usb_fifo_close() (also reached from usb_fifo_free() at :622 and from usb_cdevpriv_dtor at :969) fires KNOTE(&f->selinfo.ki_note, 0) whenever flag_isselect is set; usb_fifo_wakeup() (:1784) fires the same from every transfer completion (usb_fifo_put_data* / usb_fifo_get_data*).
  • sys/kern/kern_event.c:1810-1880 β€” knote() walks the list, reads kn->kn_kq, kn->kn_status, and dispatches filter_event(kn, hint) β†’ kn->kn_fop->f_event(kn, hint) (kern_event.c:1789-1799) β€” an indirect call through the freed knote. Un-reclaimed stale entries are skipped because the dropped note still has KN_PROCESSING|KN_DELETING set, so the crash requires (and proves) reclamation with attacker data.
  • Failure modes of usb_ref_device() reachable while the fifo is alive: refcount saturation f->refcount == USB_FIFO_REF_MAX (usb_dev.c:251 β€” demonstrated), USB_STATE_DETACHED (usb_dev.c:211), delinked fifo slot (usb_dev.c:251), curr_cpd mismatch (usb_dev.c:253).

How it was reproduced (unpriv maxx, /dev/usb/0.2.1, with-src kernel)

df5308.c implements: 96-kqueue knote fanout β†’ per-CPU knote-cache fill β†’ 254 forked readers blocking in read() to saturate f->refcount == 0xFF (proved by ioctl(unknown-cmd) = ENXIO while they sleep and ENOTTY after they are killed) β†’ close the kqueues (all knotes dropped with the early-return) β†’ immediate AF_UNIX bind() spray of 136-byte (== sizeof(struct knote)) sockaddrs whose bytes 2..135 are fully user-chosen (dup_sockaddr() kmallocs sa_len bytes verbatim; DragonFly slab zones are per-size, shared across malloc types) β†’ kill the readers β†’ close(fd) β†’ usb_fifo_close() β†’ KNOTE() β†’ forged dispatch.

  • fault rig (kn_fop = 0x4141414141414141): 3/3 panics Fatal trap 9 ... Stopped at filter_event+0x4: testb $0x2,(%rax) (panic.txt, panic.2.txt, panic.fault.txt) β€” %rax is exactly the forged kn_fop; the trap is the f_flags MPSAFE load at the address we chose. A non-USB path cannot produce this: filter_event is kqueue glue, and only the USB klist walk reaches it with our forged note.
  • call rig (kn_fop = &kprintf-24 β†’ f_event == kprintf): 2/2 traps Stopped at filter_event+0x1d: ret (panic.call.txt, panic.call.1.txt) β€” the trap site moved from the f_flags load to the instruction after the indirect call, exactly as the forged pointer dictates: the callee executed and control flow was corrupted at the return. (The visible-console variant of the kprintf marker was not captured; the two trap-site signatures across the two rigs are the control proof.)
  • A wedged-guest manifestation (kernel hang, no panic text) also occurred twice when the walk hit junk-reclaimed slots (garbage kn_kq pool-token wait / lock deadlock) β€” same root cause, DoS flavor.

Exploit chain (status)

  1. βœ… unprivileged dangling-knote creation (refcount saturation)
  2. βœ… deterministic-ish same-slot reclamation with fully controlled 136-byte content (AF_UNIX bind spray; knote offsets kn_next@24, kn_kq@48, kn_status@88, kn_fop@120, kn_hook@128 all inside sun_path)
  3. βœ… controlled indirect call kn->kn_fop->f_event(kn, 0) (rdi = forged block; controlled fault address proven; call-execution proven)
  4. ⬜ uid=0: needs a known-address fake struct filterops (no KASLR: static kernel addresses known; point kn_fop at controlled memory or a static address whose +24 holds a stack-pivot gadget; ROP from rdi). Not completed this session β€” no hard blocker, pure exploit-dev time.

Why this is not DF-5287/DF-5288/DF-1015/DF-1016/DF-2346

  • DF-1015 is a refcount leak on EOPNOTSUPP in usb_kqfilter (usb_dev.c:1191/1203) β€” different function, no memory corruption.
  • DF-1016/DF-2346 are raw-struct proc SIGIO pointer issues β€” unrelated.
  • DF-5287/DF-5288 live in usb_generic.c (ugen_transfer_setup, USB_FS_ ioctl races) β€” this finding is the usb_dev.c detach error path leaving a kqueue* object dangling; the sinks and the objects differ (knote vs usb_xfer/fs_xfer).

Fix

fix.diff (validated, see fix_validation.md): unlink the knote on the usb_ref_device() failure path via a best-effort lookup under usb_ref_lock; no-op when the fifo slot is already NULL (klist died with the fifo).

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_validation.md
↓ download raw

Fix validation - DF-5308

Baseline (unpatched with-src kernel, usb_dev.c md5 8307905e7ec38912ff7e2883279a187b

== audited tree)

rig result
./df5308 fault 2 (kn_fop=0x4141414141414141) panic x3: Fatal trap 9 ... Stopped at filter_event+0x4: testb $0x2,(%rax) (panic.txt, panic.2.txt, panic.fault.txt)
DF5308_FOP=<kprintf-24> ./df5308 call (kn_fop=&kprintf-24) trap x2: Stopped at filter_event+0x1d: ret (panic.call.txt, panic.call.1.txt)
markers saturated: ioctl = -1 ENXIO; drained: ENOTTY - the exact usb_ref_device() failure path usb_filter_detach hits

Build: cd /usr/src && make -j 6 nativekernel KERNCONF=X86_64_GENERIC -> BUILD_RC=0; make installkernel -> INSTALL_RC=0; kernel #1: Fri Oct 2 13:50:14 UTC 2026.

check result
./df5308 fault 2 (identical rig) no crash: both attempts run the full choreography (saturation ENXIO marker present -> the failing usb_ref_device path is exercised), close(fd) returns, process exits 0 (run.patched.log)
guest stability vm.sh status: up, serial log clean, no wedge, no delayed trap
kqueue subsystem sanity 96+64 knote register/drop cycles per attempt complete without error

The fix removes the knote from the fifo klist on the error path, so the KNOTE() walk at usb_fifo_close() no longer visits freed memory; the usb_ref_device() failure itself (saturation) still occurs exactly as before, isolating the fix to the dangling-link consequence.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (usb_filter_detach_unlink on the error path) applied to guest /usr/src, kernel rebuilt (nativekernel BUILD_RC=0, INSTALL_RC=0, #1: Fri Oct 2 13:50:14 UTC 2026), identical PoC re-run: both attempts complete the full choreography (saturation ENXIO marker present - the usb_ref_device failure path is exercised) and close(fd) returns; guest stays up, serial log clean. Baseline on the same rig panicked 3x and trapped 2x.

fix_validation.md, run.patched.log (= fix_run.log), fix_build.log, env.txt (patched kernel uname)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Oct 2 13:50:14 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

1) open /dev/usb/0.2.1 (ugen interrupt-IN, unpriv) -> cpd+rxfifo; 2) register 96 EVFILT_READ knotes via 96 kqueues (fanout, flag_isselect=1); 3) fill per-CPU knote cache (64) so later drops kfree to the slab; 4) fork 254 readers blocked in read() -> f->refcount saturates at USB_FIFO_REF_MAX(0xFF) (verified: any ioctl = ENXIO); 5) close the 96 kqueues -> kqueue_terminate -> usb_filter_detach -> usb_ref_device fails -> EARLY RETURN, knote freed while still linked in the live fifo klist; 6) reclaim the freed knote slots with forged 136-byte knotes via AF_UNIX bind spray (kn_next=NULL, kn_status=0, kn_kq=0x4343.., kn_fop=TARGET); 7) kill readers (refcount drains), close(fd) -> usb_cdevpriv_dtor -> usb_fifo_close -> KNOTE(&f->selinfo.ki_note,0) -> knote() walks the forged note -> filter_event -> kn->kn_fop->f_event(kn,0) = arbitrary call with rdi=controlled block. Step 8 (uid=0): point kn_fop at a known-address fake struct filterops (no KASLR) whose f_event is a stack-pivot gadget, ROP from the controlled block - mechanical, not completed this session.

Evidence (decisive lines)

panic.txt/panic.2.txt/panic.fault.txt: 'Fatal trap 9 ... Stopped at filter_event+0x4: testb $0x2,(%rax)' with rax = forged kn_fop 0x4141414141414141 (3 runs); panic.call.txt/panic.call.1.txt: 'Stopped at filter_event+0x1d: ret' with kn_fop=&kprintf-24 (2 runs, trap moved past the f_flags load exactly as forged); run.log/run.2.log: full choreography with saturation markers (ENXIO while 254 readers blocked, ENOTTY after drain) proving the usb_ref_device failure path; diag.c output in VERDICT.md; fix_validation.md + run.patched.log: identical rig on fix.diff-patched kernel survives cleanly.

PoC changes

None needed structurally; tuned for determinism: NKQ raised 8->96 so freed knotes overflow the per-CPU knote cache (KNOTE_CACHE_MAX=64) into the reclaimable slab; spray moved before reader-kill to precede allocator churn; spray fanned over 6 forked children to cover per-CPU free lists; saturation marker changed from ioctl(FIODTYPE) (intercepted by the generic devfs ioctl layer - never reached usb_ioctl and falsely reported 'not saturated') to an unknown ioctl cmd which returns ENXIO while usb_ref_device fails and ENOTTY once refs drain.

Verified recommended fix

On usb_ref_device() failure in usb_filter_detach(), best-effort unlink the knote: re-derive the fifo under usb_ref_lock and knote_remove() it if the slot is still populated (see fix.diff: usb_filter_detach_unlink); validated by kernel rebuild - identical PoC survives cleanly.

Verdict

usb_filter_detach() (sys/bus/u4b/usb_dev.c:1239-1242) returns early when usb_ref_device() fails without removing the knote from the fifo's selinfo.ki_note, but the caller (knote_detach_and_drop, kern_event.c:1764) frees the knote immediately afterwards - leaving a dangling entry that the next KNOTE() from usb_fifo_close() (usb_dev.c:792) or usb_fifo_wakeup() (usb_dev.c:1784) walks and dispatches kn->kn_fop->f_event through. Reproduced 5 times as an unprivileged user (maxx) on the with-src guest kernel (usb_dev.c md5-identical to the audited tree): with kn_fop forged to 0x4141414141414141 the kernel panics 3x at 'filter_event+0x4: testb $0x2,(%rax)' (the f_flags load at our chosen non-canonical address); with kn_fop forged to &kprintf-24 the trap moves 2x to 'filter_event+0x1d: ret' (after the indirect call executed) - the trap site follows the forged pointer, proving attacker-controlled dispatch. The freed 136-byte knote is reclaimed with fully controlled bytes via AF_UNIX bind() with sa_len=136 (dup_sockaddr kmallocs user bytes verbatim; DragonFly slab zones are shared per-size across malloc types). Primitive: arbitrary kernel function call with rdi = fully attacker-controlled 136-byte heap block; on this no-SMAP/SMEP/KASLR guest the remaining step to uid=0 is a fake-filterops/ROP chain (documented, not completed this session). The saturation markers (ioctl=ENXIO with 254 blocked readers, ENOTTY after drain) prove the usb_ref_device-failure path fired on every run, including the patched ones.