# DF-2751 VERDICT — funsetown() KKASSERT stale-load race (DF-2682 sibling)

## Finding

`funsetown()` loads `*sigiop` **before** acquiring `sigio_token`
(kern_descrip.c:1245) and dereferences that stale pointer in
`KKASSERT(sigiop == sigio->sio_myref)` (kern_descrip.c:1247) **after**
acquiring the token. Three unprivileged caller classes reach that
window without the token: `fsetown(pgid==0)` (kern_descrip.c:1298-1301,
i.e. `fcntl(fd, F_SETOWN, 0)`), `pipe_close()` (sys/kern/sys_pipe.c:1104),
and owner-exit `funsetownlst()` (kern_descrip.c:1281-1287).

While such a caller V is parked at `lwkt_gettoken(&sigio_token)`, a
concurrent `fsetown()` on the same sigiop (running under the token)
kfrees the sigio V loaded, and any other thread's `fsetown` kmalloc —
which happens *before* taking the token and uses `M_ZERO` — can recycle
the freed 48-byte chunk for a *different* pipebuf, stamping
`sio_myref = &other_pipebuf.sigio`. When V resumes:

* INVARIANTS kernels — `options INVARIANTS` is in the stock
  X86_64_GENERIC config, so this includes default builds of this tree —
  execute the KKASSERT against freed (and typically recycled) memory:
  assertion failure → panic.
* non-INVARIANTS kernels — KKASSERT is compiled out (expression not
  evaluated); the post-assert reload `sigio = *sigiop` then picks up the
  sigio the winner just installed and V kfrees it, silently reverting a
  completed `F_SETOWN` (functional race; no memory unsafety at this line).

Distinct from DF-2682: different sink (funsetown's own KKASSERT vs
lockless pgsigio() readers), different racers (two funsetown/fsetown
calls on one sigiop vs funsetown vs pgsigio user), different impact
(INVARIANTS-only deref vs always-on UAF read chain). Same family.

## Reproduction (unprivileged, pipes only, guest stock kernel)

Three harness iterations (all under uid 1001 `maxx`):

* v1 `funsetown_race.c` — 4 threads, F_SETOWN(0)/F_SETOWN(pid) churn on
  3 pipes. 300 s: no panic (210M victim iterations). The interleave
  requires the killer to take the token ahead of the parked victim and
  the freed chunk to be recycled before the victim resumes — too tight.
* v2 `funsetown_race2.c` — added O_ASYNC pgrp pipe writers whose
  `pipewakeup()->pgsigio()` (sys_pipe.c:211-215) holds `sigio_token`
  across `pgref+lockmgr+ksignal`, parking victims reliably. 300 s: no
  panic. Diagnosis: the recycler usually re-installs the chunk for the
  **same** pipebuf, so `sio_myref` still matches → assertion passes.
* v3 `funsetown_race3.c` — killers alternate **two pipes** (A, D): the
  `fsetown(D)` kmalloc pops A's freed chunk per-CPU LIFO and stamps
  `sio_myref = &pbD.sigio`, guaranteeing mismatch for a victim parked on
  `&pbA.sigio`. **Panic in ≤ 4 minutes:**

```
panic: assertion "sigiop == sigio->sio_myref" failed in funsetown at /usr/src/sys/kern/kern_descrip.c:1247
cpuid = 5
funsetown() at funsetown+0x159
funsetown() at funsetown+0x159
fsetown() at fsetown+0xdb
pipe_ioctl() at pipe_ioctl+0x11f
kern_fcntl() at kern_fcntl+0x301
sys_fcntl() at sys_fcntl+0x6a
Debugger("panic") → db>
```

The `pipe_ioctl` frame is `sys/kern/sys_pipe.c`'s FIOSETOWN handler —
the pipe file-op path this audit was assigned. Full serial-console
capture in `panic.txt`, run transcript in `run.log`.

Impact classification: local unprivileged kernel panic → **dos** on
INVARIANTS builds (stock config in this tree). Not a write primitive:
the freed read feeds only an assertion comparison; on production
(non-INVARIANTS) builds the line vanishes. No escalation attempted
beyond DoS — and none available from this line alone.

## Fix

`fix.diff` — load `*sigiop` *under* `sigio_token` (hoist the token
acquisition above the initial read). Then the KKASSERT deref cannot
observe a concurrently-freed sigio: any free of the loaded value must
clear `*sigiop` first, under the same token. This also fixes the
production-build silent F_SETOWN reversion (racing funsetowns fully
serialize: first clears+returns, second reloads NULL). No lock-order
change: the p_token/pg_token list-removal section remains outside
`sigio_token`, exactly as before; recursive acquisition from fsetown's
`while (*sigiop) funsetown(sigiop)` loop is supported by LWKT.

Cost: one extra (uncontended, recursive) token acquire per funsetown on
an empty ref — funsetown runs on close/exit/F_SETOWN paths, not hot I/O.

## Fix validation

* Baseline (stock kernel): panic reproduced, above (≤ 4 minutes, v3).
* Patched: `vm.sh reset with-src`, applied fix.diff in guest /usr/src,
  `make -j6 nativekernel` (rc=0) + `make installkernel` (rc=0), rebooted,
  re-ran the identical v3 PoC binary:
  - 20 s smoke: survived (victim=6.06M killer=13.6M writes=16.7M).
  - 360 s decisive: **survived** (victim=98,870,384 killer=237,281,500
    writes=250,070,255), guest remained up (`vm.sh status` → up).
  The previously-observed panic is GONE; fix_status = **fixed**
  (fix_baseline_reproduced=1, fix_patched_reproduced=0). Logs:
  `fix_run.log`, `fix_build.log`.

Guest was reset to the clean-source snapshot (`vm.sh reset with-src`)
after validation, per protocol.
