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

kqueue_del() latent API hazards: close-before-delete EV_DELETE-ENOENT panics the whole vkernel; delete-vs-dispatch kfree races kqueue_intr()'s in-flight udata dispatch on another vCPU host thread (zero in-tree callers today)

Summary

A landmine for the first future caller: the natural teardown order close(fd); kqueue_del(info) deterministically panics the whole vkernel (the host auto-detaches the knote on last close, kevent(EV_DELETE) returns ENOENT, and every failure is treated as fatal), and kqueue_del's kfree shares no serialization with kqueue_intr's dequeue-copy-and-dispatch on other vCPU host threads - a call-through-freed-M_DEVBUF UAF; kqueue_add also lacks a duplicate-(fd,filter) guard (silent udata rebind + orphaned info). Zero in-tree callers (definition+prototype only; the sole add caller registers once and never deletes) - Low/latent hardening so the first caller (e.g. a future vke detach path) does not trip it. DF-5537's 'benign double-add' claim CONFIRMED from the owner side (ttymalloc caches si_tty under tty_token -> exactly one EV_ADD). tolerate-ENOENT + inuse-fence fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5631 Β· 5 files
FileTypeDescriptionSize
README.md β€” 5.0 KB ↓ raw
VERDICT.md β€” 4.9 KB ↓ raw
fix.diff β€” 2.5 KB view raw
verdict.json β€” 4.5 KB view raw
manifest.json β€” 1.3 KB view raw

DF-5631 β€” kqueue_del() latent hazards: EV_DELETE-ENOENT panic + delete-vs-dispatch UAF

File: sys/platform/vkernel64/platform/kqueue.c (pass 2, GLM 5.3) Severity: Low Β· Bucket: base:hw (vkernel platform) Β· PoC status: untested β€” latent dead-code API hazard, zero in-tree callers; no trigger exists to build.

What the defect is (code-level, deterministic)

kqueue_del() (sys/platform/vkernel64/platform/kqueue.c:157-169) has two hazards that bite the moment any future caller uses the API in the natural way:

  1. ENOENT β†’ panic (deterministic, close-before-delete ordering). The host kqueue auto-detaches an EVFILT_READ knote when the watched fd's file description is closed. A caller that does the natural close(fd); kqueue_del(info); teardown gets ENOENT from kevent() at kqueue.c:165 and hits panic("kevent: failed to delete descriptor %d") at kqueue.c:166 β€” taking down the whole vkernel.

  2. UAF window vs kqueue_intr() (racy, multi-vCPU). kqueue_intr() (kqueue.c:177-196) runs concurrently on any vCPU host thread: the SIGIO handler kqueuesig() calls it directly from signal context (sys/platform/vkernel64/x86_64/exception.c:144-158) and splz() re-enters it via RQF_KQUEUE (sys/platform/vkernel64/platform/machintr.c:154-157); SIGIO is process-directed (init_kqueue, kqueue.c:84-87). If thread A has already copied the event out at kqueue.c:188 with udata = info, and thread B executes kqueue_del(info) β€” EV_DELETE succeeds (the knote still exists; dequeue does not remove it) and kfree(info) at kqueue.c:168 frees the object β€” then thread A's info->func(info->data, frame) at kqueue.c:193 calls through freed memory. Use-after-free of a vkernel-process heap object (M_DEVBUF).

  3. EV_ADD re-registration orphaning (API foot-gun). kqueue_add() (kqueue.c:105-120) performs no duplicate-(ident,filter) guard; a second kqueue_add on the same fd silently rebinds the knote's udata and orphans the old info (leak), after which a kqueue_del of the OLD info EV_DELETEs the knote now owned by the NEW registration.

Why no PoC was built

rg 'kqueue_del' sys/ β€” the only references are the definition (kqueue.c:158) and the prototype (sys/platform/vkernel64/include/md_var.h:133). No code in the entire tree calls it. The single live kqueue_add caller is console.c:116 (fd 0, once, guarded by kqueue_console_info == NULL under tp->t_token), and vcons_close() (console.c:127-139) never deletes β€” so hazard 1 and 2 are unreachable today. Reproducing would require writing a new kernel caller into sys/, which the audit contract forbids. Class: latent API defect / hardening, filed so the first future caller (e.g. a vke detach path) doesn't trip it.

Cross-checks performed from the owner side (pass 2, checklist a–d)

  • DF-5537 "concurrent-first-open double-add is benign" β€” CONFIRMED. ttymalloc(&dev->si_tty) caches the tty in the cdev under tty_token (sys/kern/tty.c:2841-2855), so concurrent vcons_open races serialize on the same tp->t_token (console.c:90) and the second opener sees kqueue_console_info != NULL (console.c:114-117). Single EV_ADD; no re-registration; no orphaned info.
  • kqueue fd lifecycle / ordering β€” sound. init_kqueue() (init.c:449) runs after cpu_disable_intr() (init.c:443, blocks SIGIO β€” machintr.c:192-196) and before init_exceptions() (init.c:494) installs kqueuesig; no window where O_ASYNC SIGIO fires unhandled. KQueueFd is never closed in-tree.
  • kqueue_intr loop β€” safe. kevary[8], host kevent() returns ≀ nevents; n == -1 skips the dispatch loop and exits the do-while (kqueue.c:187-195); spurious wakeups (event already drained by another vCPU) yield n==0 and exit. Concurrent kqueue_intr on multiple vCPU threads is safe today because the only dispatch target, vcons_hardintr (console.c:203-209), is a leaf that does signalintr(4) β†’ atomic bit-set deferral (machintr.c:169-186).
  • signalintr deferral β€” sound. Both entry paths hold a critical section (kqueuesig: crit_enter_quick at exception.c:152; splz: machintr.c:130), so signalintr() always sees td_critcount != 0 and defers via gd_fpending + RQF_INTPEND + umtx_wakeup (machintr.c:174-177), drained at machintr.c:144-148.
  • Not re-filed (family findings): SIGIO deliverability on %gs=0 cothreads is the DF-5587/DF-5607 chain (cothread mask discipline), owned by cothread.c/exception.c. init_kqueue's unchecked kqueue() return (kqueue.c:83, -1 under RLIMIT_NOFILE exhaustion β†’ misleading panic at kqueue.c:85) fails loudly at boot and is recorded in negative notes only.

Fix sketch

See fix.diff β€” tolerate ENOENT on EV_DELETE (knote already gone), and close the UAF window by fencing deletes against in-flight dispatch.

Reproduce

Nothing to run: latent dead-code hazard (no caller). Static verification:

rg -n 'kqueue_del' sys/          # definition + prototype only
sed -n '157,196p' sys/platform/vkernel64/platform/kqueue.c
VERDICT.md
↓ download raw

DF-5631 β€” VERDICT

Status: untested (legitimately untriggerable: the defective API has zero in-tree callers; building a trigger would require modifying sys/, which the audit forbids). Impact: none today; latent vkernel-panic / vkernel-process heap UAF for the first future caller.

The defect, traced line by line

1. Deterministic panic on natural teardown order (kqueue.c:157-169)

157 kqueue_del(struct kqueue_info *info)
158 {
160     struct timespec ts = { 0, 0 };
163     KKASSERT(info->fd >= 0);
164     EV_SET(&kev, info->fd, EVFILT_READ, EV_DELETE, 0, 0, NULL);
165     if (kevent(KQueueFd, &kev, 1, NULL, 0, &ts) < 0)
166         panic("kevent: failed to delete descriptor %d", info->fd);
167     info->fd = -1;
168     kfree(info, M_DEVBUF);

Host-kqueue semantics (the vkernel calls the HOST kqueue via libc β€” kqueue.c:83, 117, 165, 188): an EVFILT_READ knote is detached when the watched file description is last-closed. Any caller doing close(fd) before kqueue_del() β€” the natural detach order β€” makes kevent(EV_DELETE) return ENOENT, and kqueue.c:166 panics the entire vkernel. The return of kevent() is -1 on any error including ENOENT; the code treats every failure as fatal.

2. UAF window: delete vs in-flight dispatch (kqueue.c:187-195 vs 168)

kqueue_intr() runs on ANY vkernel host thread:

  • SIGIO is process-directed (fcntl(KQueueFd, F_SETOWN, getpid()) + O_ASYNC, kqueue.c:84-87), so the host may deliver SIGIO to any thread with SIGIO unblocked β€” each vCPU host thread as it passes through cpu_enable_intr() windows (machintr.c:198+).
  • kqueuesig() (exception.c:144-163) calls kqueue_intr(NULL) directly in signal-handler context when td_critcount == 0.
  • splz() (machintr.c:154-157) defers into kqueue_intr() via RQF_KQUEUE on whatever CPU observed the flag.

There is no lock shared by kqueue_intr() and kqueue_del(). Timeline:

Thread A (vCPU0, SIGIO ctx) Thread B (vCPU1)
kqueue.c:188 kevent() copies out event {ident=fd, udata=info}
kqueue.c:164 EV_DELETE succeeds (knote exists; dequeue β‰  removal)
kqueue.c:168 kfree(info)
kqueue.c:193 info->func(info->data, frame) β€” call through freed M_DEVBUF object

Because the vkernel disables host hardening for its own image but is a plain userspace process to the host, this is a userspace heap UAF of the kernel process β€” i.e., guest-controlled corruption of the vkernel itself, same class the family tracks as memcorrupt/base:hw, but only reachable once a caller of kqueue_del exists β€” none does today:

$ rg -n 'kqueue_del' sys/
sys/platform/vkernel64/include/md_var.h:133: void kqueue_del(struct kqueue_info *);
sys/platform/vkernel64/platform/kqueue.c:158: kqueue_del(struct kqueue_info *info)

The single live kqueue_add caller is console.c:116 (fd 0, once β€” the kqueue_console_info == NULL guard is under tp->t_token, and tp is the stable cached dev->si_tty from ttymalloc, sys/kern/tty.c:2841-2855), and vcons_close() (console.c:127-139) never deletes. The knote and kqueue_console_info live for the whole vkernel process β€” udata is always valid on the live path, confirming (from the owner side) that DF-5537's "concurrent-first-open double-add is benign" assessment holds.

3. EV_ADD re-registration orphaning (kqueue.c:105-120)

No duplicate-(fd,EVFILT_READ) guard: a second kqueue_add on the same fd rebinds udata and leaks the old info; a subsequent kqueue_del(old_info) would delete the NEW registration's knote. Latent API foot-gun only.

Why untested, honestly

  • No in-tree trigger: hazard 1 requires a close(fd)-then-kqueue_del caller, hazard 2 requires a kqueue_del racing kqueue_intr β€” no such code exists anywhere in sys/ (verified by full-tree rg).
  • A vkernel boot on the guest cannot exercise the path without patching sys/, which the audit contract forbids (read-only tree). Static line trace above is the evidence.

Fix

fix.diff in this directory: tolerate ENOENT (fd already closed β†’ knote already auto-deleted), and serialize delete against in-flight dispatch with a simple in-use counter drained before kfree (the vkernel process is cooperatively threaded; a crit-section-free atomic counter suffices because the dispatcher's kevent copy-out and the delete both run on vkernel host threads).

Cross-references

  • DF-5537 (console.c) β€” double-add benign claim: confirmed from owner side.
  • DF-5586/5587, DF-5607-5612 (exception/cothread/machintr) β€” SIGIO deliverability on %gs=0 cothreads belongs to the DF-5607 mask-discipline finding, not re-filed here.

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

not_testable
baseline no→ patch + rebuild →patched clean

fix validation not performed: the hazard has no in-tree trigger (kqueue_del has zero callers), so no baseline misbehavior can be reproduced without patching sys/; the fix.diff ENOENT-tolerance path is nonetheless deterministic-by-construction against host kqueue semantics

↓ fix.diffper-fix-DF-5631

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-5631/VERDICT.md (line-by-line trace of both hazards + the concurrency map); 'rg -n kqueue_del sys/' output showing zero callers (VERDICT.md); README.md cross-checks confirming DF-5537's double-add-benign claim from the owner side (console.c:114-117 guard under tp->t_token; ttymalloc caching at sys/kern/tty.c:2841-2855)

PoC changes

none β€” no PoC built: the defective API has no callers anywhere in sys/, so there is no in-tree trigger; building one would require patching the read-only sys/ tree

Verified recommended fix

Tolerate ENOENT in kqueue_del (fd already closed => knote auto-detached) instead of panicking, and fence the kfree against in-flight kqueue_intr dispatch; see findings/poc/DF-5631/fix.diff

Verdict

Latent dead-code API hazard, verified by full-tree static trace: kqueue_del() (sys/platform/vkernel64/platform/kqueue.c:157-169) has zero in-tree callers (only the definition and the md_var.h:133 prototype exist), so neither hazard is reachable today. If ever called, (1) the natural close(fd)-before-kqueue_del() teardown order deterministically panics the whole vkernel: the host auto-detaches the EVFILT_READ knote on last close, kevent(EV_DELETE) returns ENOENT, and kqueue.c:166 treats it as fatal; (2) kqueue_del's kfree at kqueue.c:168 races kqueue_intr's in-flight udata dispatch at kqueue.c:193 β€” kqueue_intr runs concurrently on any vCPU host thread (SIGIO process-directed via F_SETOWN/O_ASYNC at kqueue.c:84-87, direct call from the kqueuesig signal handler at exception.c:144-158 and from splz() at machintr.c:154-157), with no lock shared with kqueue_del, yielding a call through freed M_DEVBUF memory. Filed as Low/hardening so the first future caller does not trip it; no PoC can exist without modifying the read-only sys/ tree.