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)
PoC verification
Evidence pack
findings/poc/DF-5308 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
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).- Register EVFILT_READ knotes on
fdfrom several kqueues (knote fanout). - 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-sizekmalloc. fork()254 children that block inread(fd)β each blocked reader holds onef->refcount(usb_dev.c:1426/285), saturating it atUSB_FIFO_REF_MAX(0xFF). Marker: any further ioctl on the fd fails with ENXIO (usb_ioctlβusb_ref_device, usb_dev.c:1094).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.- Reclaim the freed knote slots with fully controlled 136-byte blocks:
bind()AF_UNIX sockets withsa_len = 136 == sizeof(struct knote);dup_sockaddr()(kern/uipc_usrreq.c:1165 β kern ker)kmallocssa_lenbytes 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. - Kill the readers (refcount drains), then
close(fd)βusb_cdevpriv_dtorβusb_fifo_close()βflag_isselectstill 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 thekn->kn_fop->f_flags & FILTEROP_MPSAFEload;%raxis our chosen non-canonicalkn_fop. Reproduced 3Γ (panic.txt, panic.2.txt, panic.fault.txt). - call rig (
kn_fop = &kprintf - 24, sof_event == kprintf):Stopped at filter_event+0x1d: retβ the indirectf_eventcall through the forgedkn_fopexecuted (the trap moved from thef_flagsload to the post-callretexactly 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 failureusb_filter_detachhits) thenafter 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 diagnosticbuild.sh,run.shrun.log,run.2.log,panic*.txtβ decisive runsenv.txtβ guest environmentfix.diff,fix_build.log,fix_validation.md
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()callsusb_ref_device(cpd, &refs, 0); on failure it returns withoutknote_remove(), leaving the knote linked in the live fifo'sf->selinfo.ki_noteand leavingf->flag_isselect = 1(usb_dev.c:1264 is the only other clear site).sys/kern/kern_event.c:1764-1775βknote_detach_and_drop()callsf_detach()thenknote_drop()βknote_free()β per-CPU cache orkfree(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 fromusb_fifo_free()at :622 and fromusb_cdevpriv_dtorat :969) firesKNOTE(&f->selinfo.ki_note, 0)wheneverflag_isselectis 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, readskn->kn_kq,kn->kn_status, and dispatchesfilter_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 hasKN_PROCESSING|KN_DELETINGset, so the crash requires (and proves) reclamation with attacker data.- Failure modes of
usb_ref_device()reachable while the fifo is alive: refcount saturationf->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_cpdmismatch (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 panicsFatal trap 9 ... Stopped at filter_event+0x4: testb $0x2,(%rax)(panic.txt,panic.2.txt,panic.fault.txt) β%raxis exactly the forgedkn_fop; the trap is thef_flagsMPSAFE load at the address we chose. A non-USB path cannot produce this:filter_eventis 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 trapsStopped at filter_event+0x1d: ret(panic.call.txt,panic.call.1.txt) β the trap site moved from thef_flagsload 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_kqpool-token wait / lock deadlock) β same root cause, DoS flavor.
Exploit chain (status)
- β unprivileged dangling-knote creation (refcount saturation)
- β 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)
- β
controlled indirect call
kn->kn_fop->f_event(kn, 0)(rdi = forged block; controlled fault address proven; call-execution proven) - β¬ uid=0: needs a known-address fake
struct filterops(no KASLR: static kernel addresses known; pointkn_fopat controlled memory or a static address whose +24 holds a stack-pivot gadget; ROP fromrdi). 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 procSIGIO 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 - 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 |
Patched kernel (fix.diff: usb_filter_detach_unlink() on the error path)
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
fixedfix.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)
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.
No comments yet.