# DF-2691 — VERDICT

## Bottom line

**REPRODUCED.** An unprivileged local user can leave an `EVFILT_SIGNAL`
knote linked into a `struct proc` that is subsequently freed by `wait()`,
and the later destruction of the kqueue performs `SLIST_REMOVE` on the
freed memory.  Demonstrated twice on fresh boots of the stock
INVARIANTS kernel as a deterministic-enough kernel panic
(`knote_remove+0x33: movq 0x18(%rdx),%rax`, fault address 0x18), by an
unprivileged `close(2)`.

## How it was reproduced (step by step)

1. `kq = kqueue()` in the attacking process P.
2. `c = rfork(RFPROC)` — **without** RFFDG, so the child shares P's fd
   table (`fdshare`, kern_fork.c:557).  This is the only way to reach a
   kqueue from a process other than its creator, because `fdcopy()`
   strips kqueue fds from forked children (kern_descrip.c:2573-2576) and
   SCM_RIGHTS refuses them (uipc_usrreq.c:1799-1802).
3. Child `c` registers `EV_ADD, EVFILT_SIGNAL, ident=j` on `kq`;
   `filt_sigattach()` (kern_sig.c:2667-2679) links the knote into **c's**
   `p_klist` with no reference on c.
4. Child exits; P reaps it: `kern_wait()` → `kfree(p, M_PROC)`
   (kern_exit.c:1336).  `exit1()`'s `KNOTE(&p->p_klist, NOTE_EXIT)`
   (kern_exit.c:601) does **not** detach signal knotes (`filt_signal()`
   only reacts to the NOTE_SIGNAL bit — kern_sig.c:2698), unlike
   `filt_proc()` which detaches proc knotes on NOTE_EXIT
   (kern_event.c:382-391).
5. Repeat 64 times for 64 dangling knotes; spray `exec` of `/bin/sleep`
   with padded argv so `p_args` allocations (same 1280-byte slab class as
   `struct proc`, 1208 bytes) recycle the freed chunks.
6. `close(kq)` → kqueue drain → `filt_sigdetach()` (kern_sig.c:2681-2687)
   → `knote_remove(&kn->kn_ptr.p_proc->p_klist, kn)` on freed memory →
   `SLIST_REMOVE` walks `curelm` = recycled `p_klist` head → fault.

Observed (run 1, cpuid 1 / run 2, cpuid 3):

```
Fatal user address access from kernel mode from kqsig_uaf at ffffffff8063c973
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x18
current process = 4104 / 872
Stopped at knote_remove+0x33: movq 0x18(%rdx),%rax
```

0x18 == `offsetof(struct knote, kn_next)`; the faulting instruction is the
`while (SLIST_NEXT(curelm, kn_next) != elm)` walk with `curelm == NULL`
(the recycled chunk was a fresh `M_ZERO` process, kern_fork.c:444).

## Why the panic is only the read side (primitive characterization)

* **UAF write**: when the freed chunk is *not* recycled,
  `SLIST_REMOVE_HEAD` executes `head->slh_first = elm->kn_next` — the
  kernel writes NULL (or a knote pointer) into freed kernel memory at
  offset 496 of a 1280-class slab chunk.  Silent corruption of whatever
  later reuses that chunk.
* **Controlled-pointer walk**: when recycled, the kernel loads
  `SLIST_FIRST` from recycled content and dereferences it.  The PoC's
  `p_args` spray demonstrates user-controlled bytes landing at exactly
  that offset (needs only the `kern.ps_arg_cache_limit` debug knob to be
  raised; without any knob, other same-class kernel allocations decide
  the value).  A recycled head `X` produces reads at `X+24` and, when the
  walk terminates on `elm`, a write of `elm->kn_next` through the forged
  link.

## Exploit chain (toward uid=0) — analysis, not completed

The write primitive is "NULL or stale-knote-pointer into freed/reused
1280-class chunk at +496" plus "kernel walks a recycled pointer".  A full
chain would need to (a) groom the freed chunk with a forged SLIST whose
`kn_next` chain terminates exactly on the (unknown-address) knote being
removed, converting the final `SLIST_REMOVE_AFTER` store into an
arbitrary-address NULL-write (e.g. over a `struct ucred`'s
`cr_uid/cr_ruid`), and (b) defeat the unknown knote address — no KASLR on
this guest, so a heap-address leak or deterministic slab layout would be
the remaining work.  Not completed within this verification run; the
deterministic unprivileged panic plus the silent freed-memory write are
already sufficient for High severity.

## Fix validation (mandatory for memory-corruption findings)

1. Authored `fix.diff`: `filt_signal()` detaches the knote on NOTE_EXIT
   exactly like `filt_proc()` (PHOLD / knote_remove / KN_DETACHED /
   `kn_ptr.p_proc = NULL` / PRELE), and `filt_sigdetach()` honors
   KN_DETACHED.
2. Applied to the guest's `/usr/src` (patch(1) hunks #1 @2681, #2 @2708),
   rebuilt with `make nativekernel KERNCONF=X86_64_GENERIC` and
   `make installkernel` (build+install completed — guest
   `/tmp/kbuild.log` marker `BUILD_INSTALL_DONE`; log lost to the
   snapshot reset, excerpt in fix_build_excerpt.txt), rebooted into
   `DragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 21:53:26 UTC 2026`.
3. **Baseline**: stock kernel panicked on the first run both times
   (run.log, run.2.log).
4. **Patched**: the exact same PoC ran 5/5 times to "SURVIVED: no panic"
   (fix_validation.log).
5. **Functionality**: EVFILT_SIGNAL still delivers on the patched kernel
   (func.c: `kevent n=1 ident=30 data=1`, `FUNC_OK`).

fix_status: **fixed**.

## Notes

* Guest was returned to the clean-source snapshot after validation.
* The `kern.ps_arg_cache_limit=8192` sysctl (debug knob) is only needed
  for the content-controlled-recycle demonstration; the panic reproduced
  without any dependence on its value.
