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)
PoC verification
Evidence pack
findings/poc/DF-5631 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
-
ENOENT β panic (deterministic, close-before-delete ordering). The host kqueue auto-detaches an
EVFILT_READknote when the watched fd's file description is closed. A caller that does the naturalclose(fd); kqueue_del(info);teardown getsENOENTfromkevent()at kqueue.c:165 and hitspanic("kevent: failed to delete descriptor %d")at kqueue.c:166 β taking down the whole vkernel. -
UAF window vs kqueue_intr() (racy, multi-vCPU).
kqueue_intr()(kqueue.c:177-196) runs concurrently on any vCPU host thread: the SIGIO handlerkqueuesig()calls it directly from signal context (sys/platform/vkernel64/x86_64/exception.c:144-158) andsplz()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 withudata = info, and thread B executeskqueue_del(info)β EV_DELETE succeeds (the knote still exists; dequeue does not remove it) andkfree(info)at kqueue.c:168 frees the object β then thread A'sinfo->func(info->data, frame)at kqueue.c:193 calls through freed memory. Use-after-free of a vkernel-process heap object (M_DEVBUF). -
EV_ADD re-registration orphaning (API foot-gun).
kqueue_add()(kqueue.c:105-120) performs no duplicate-(ident,filter) guard; a secondkqueue_addon the same fd silently rebinds the knote's udata and orphans the oldinfo(leak), after which akqueue_delof 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 undertty_token(sys/kern/tty.c:2841-2855), so concurrentvcons_openraces serialize on the sametp->t_token(console.c:90) and the second opener seeskqueue_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 aftercpu_disable_intr()(init.c:443, blocks SIGIO β machintr.c:192-196) and beforeinit_exceptions()(init.c:494) installskqueuesig; no window where O_ASYNC SIGIO fires unhandled.KQueueFdis never closed in-tree. - kqueue_intr loop β safe.
kevary[8], hostkevent()returns β€ nevents;n == -1skips 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. Concurrentkqueue_intron multiple vCPU threads is safe today because the only dispatch target,vcons_hardintr(console.c:203-209), is a leaf that doessignalintr(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 seestd_critcount != 0and defers viagd_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 uncheckedkqueue()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
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 throughcpu_enable_intr()windows (machintr.c:198+). kqueuesig()(exception.c:144-163) callskqueue_intr(NULL)directly in signal-handler context whentd_critcount == 0.splz()(machintr.c:154-157) defers intokqueue_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_delcaller, hazard 2 requires akqueue_delracingkqueue_intrβ no such code exists anywhere insys/(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_testablefix 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
Confirmed kernel references
- sys/platform/vkernel64/platform/kqueue.c:157
- sys/platform/vkernel64/platform/kqueue.c:164
- sys/platform/vkernel64/platform/kqueue.c:166
- sys/platform/vkernel64/platform/kqueue.c:168
- sys/platform/vkernel64/platform/kqueue.c:188
- sys/platform/vkernel64/platform/kqueue.c:193
- sys/platform/vkernel64/platform/kqueue.c:84
- sys/platform/vkernel64/x86_64/exception.c:144
- sys/platform/vkernel64/x86_64/exception.c:155
- sys/platform/vkernel64/platform/machintr.c:154
- sys/platform/vkernel64/platform/console.c:116
- sys/platform/vkernel64/include/md_var.h:133
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.
No comments yet.