# DF-2953 — VERDICT

**Status: REPRODUCED (impact: panic / local kernel DoS). Fix validated on
guest (baseline panics <1 s; patched kernel survives a 300 s race soak).**

## What was audited and found

Pass-2 audit of `sys/kern/subr_log.c` (/dev/klog driver, 321 lines). Beyond
the four known findings (DF-0189/0190/0191/0192 — not re-reported), one new
memory-safety race was found and proven:

`logtimeout()` — the periodic softclock callout — reads
`logsoftc.sc_sigio` and calls `pgsigio(logsoftc.sc_sigio, SIGIO, 0)`
at `sys/kern/subr_log.c:247-248` **without `sigio_token` and without a
reference**, while

- `logioctl` FIOSETOWN / TIOCSPGRP (`sys/kern/subr_log.c:292-301`) →
  `fsetown()` (`sys/kern/kern_descrip.c:1296`) frees the old sigio
  under the token (`funsetown()`, `kern_descrip.c:1239-1272`:
  `sio_pgrp = NULL`, `sio_ucred = NULL`, `kfree(M_SIGIO)`), and
- `logopen()`'s `fsetown()` (`sys/kern/subr_log.c:102`) does the same on
  the DF-0189 double-open path.

`pgsigio()` (`sys/kern/kern_sig.c:2639-2662`) then dereferences the freed
chunk: `sigio->sio_pgid`, `pgref(sigio->sio_pgrp)` with `sio_pgrp == NULL`,
and `CANSIGIO(sigio->sio_ruid, sigio->sio_ucred, p)` whose first evaluation
is `(uc)->cr_uid` (`kern_sig.c:99-104`) on the NULLed ucred.

Every other accessor of a `sigio *` pointer in the klog path
(`fsetown`/`funsetown`/`fgetown`) holds `sigio_token`
(`kern_descrip.c:1246,1368,1391`); `logtimeout` is the only unlocked
reader. The `logclose()` → `funsetown()` combination is *not* a racer:
`callout_terminate()` synchronously waits for an in-progress callback
(`sys/kern/kern_timeout.c:1104-1126`, wait loop at `:926-941`). FreeBSD
fixed this class years ago by passing `struct sigio **` to `pgsigio` with
internal locking; DragonFly still passes the raw pointer.

## Reproduction (run 1 — baseline)

Guest: DragonFly 6.5-DEVELOPMENT, stock INVARIANTS kernel
`#0 Thu Jul 2 06:02:54 UTC 2026`. PoC `klog_sigio_race.c` (root):
opens `/dev/klog` (after stopping syslogd), sets `FIOASYNC`, becomes a
process-group leader with 16 members (widens `pgsigio()`'s member loop to
a microsecond-scale window), one child hammers `kill(-pgrp, 0)` (pg_lock
contention stretches `pgsigio()`), one child writes `/dev/console` in a
loop (`log_console()` sets `msgbuftrigger = 1`, `subr_prf.c:296`), and
three processes hammer `ioctl(FIOSETOWN, -pgid)` / `ioctl(FIOSETOWN, 0)`
(install pgrp-owned sigio / free it).

Result: **panic in under one second** —

```
Fatal trap 12: page fault while in kernel mode
cpuid = 3; lapic id = 3
fault virtual address   = 0x40          <- (NULL ucred/pgrp) + field offset
instruction pointer     = 0x8:0xffffffff8066212d  (pgsigio+0xcd)
current process         = Idle          <- softclock kernel thread
current thread          = pri 12
Stopped at      pgsigio+0xcd:   movl    0x40(%rdx),%edx
```

The faulting context (Idle/softclock, inside `pgsigio`, constant small
fault address) matches the source-level prediction exactly: dereference of
`funsetown()`-NULLed fields in a freed `struct sigio`.

## Exploitability ceiling

Honest classification: **panic (local DoS)**. Triggering requires an open
fd on `/dev/klog` (mode 0600 root:wheel, `subr_log.c:317`), i.e. host root
— or jailed root where a devfs ruleset exposes klog (the DF-0190 jail-check
gap), which turns this into a host-kernel panic from inside a jail. The
read-side UAF on the M_SIGIO chunk is not straightforwardly convertible to
a write primitive: the zone is dedicated to `struct sigio`, and the
post-free field values are NULLed pointers (hence the deterministic NULL
deref). A recycled chunk yields at most a spurious SIGIO to a stale
target. uid0 escalation was not pursued further because the trigger is
already privileged.

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

- **Attempt 1** — hold `sigio_token` across `pgsigio()` in `logtimeout`:
  rebuilt, same PoC, **panicked identically**. Root cause: lwkt tokens are
  *soft* — "If the thread blocks all tokens are released, then reacquired
  when the thread resumes" (`sys/kern/lwkt_token.c:40-42`) — and
  `pgsigio()` blocks in `lockmgr(&pg->pg_lock)`. Note the tree already
  uses this broken pattern at `sys/kern/sys_pipe.c:211-214` (related
  latent instance, out of this file's scope — recommend the orchestrator
  file it against sys_pipe.c).
- **Attempt 2** — systemic `pgsigio(struct sigio **)` rewrite (snapshot
  pgid/ruid/ucred/proc/pgrp + `crhold`/`PHOLD`/`pgref` under shared
  `sigio_token`, 13 files): rebuilt, same PoC, **panicked** with
  `assertion "count > 0" failed in sess_rele` from `pgrel` inside the new
  `pgsigio` — the snapshotted pgrp still hit a destroy/destroy race
  (suspected lwkt shared/exclusive exclusion subtleties; root cause not
  fully determined). Kept as
  `fix.attempt2-systemic-sigio-ref.diff.unvalidated`; **not shipped**.
- **Final fix (shipped as `fix.diff`)** — driver-local: add a real
  `struct lock sc_lock` to `logsoftc`, `lockinit()` in `log_drvinit`, and
  take it `LK_EXCLUSIVE` around every klog-path access of `sc_sigio`
  (`logopen` fsetown, `logclose` funsetown, `logtimeout` pgsigio,
  FIOSETOWN/TIOCSPGRP ioctls). `lockmgr` locks are held across blocking,
  which is exactly the property the token approaches lacked. Lock order is
  strictly `sc_lock → {sigio_token, pg_token, p_token}`; `sc_lock` appears
  nowhere else, so no inversion is possible.
- **Run 4 (validated)**: kernel `#1 Fri Sep 4 03:14:52 UTC 2026` (clean
  build, `build.log`), identical PoC under `timeout 300`: **no panic**;
  guest stayed up, PoC exited normally at the timeout, load average 5.04
  during the soak confirms the race machinery was fully exercised.
  Baseline died in ≤1 s on the same workload — the behavioral delta is
  decisive.

## Negative results (classes hunted and killed in this pass)

- `logread` OOB: impossible — every access is bounded by
  `lindex % msg_size` and `n = min(msg_size - lindex_modulo,
  xindex - lindex, uio_resid)` (`subr_log.c:171-182`); wrap/torn reads are
  the DF-0192 data-integrity family only.
- `msg_size < 2048` corner in the wrap-correction (`subr_log.c:163-164`):
  unreachable at runtime — `msg_size` is fixed at boot from compile-time
  `MSGBUF_SIZE` (1 MB default; `machdep.c:2557`, `msgbufinit`), never
  resized.
- `FIONREAD` truncation: `n ≤ msg_size - 1024` uncorrected or
  `≤ msg_size - 2048` corrected — fits `int` (`subr_log.c:277-282`).
- `logtimeout` re-arm after `callout_terminate`: closed — terminate waits
  for INPROG (`kern_timeout.c:926-941`); stray trailing `callout_reset`
  can only occur before termination completes, and the `!log_open` guard
  makes any post-close firing benign (`subr_log.c:238-239`).
- `hz / log_wakeups_per_second` negative/huge values: root-only sysctl;
  callout clamps to ≥1 tick (`kern_timeout.c:788-791`) — worst case is CPU
  burn by root; the 0 case is DF-0191 (not re-reported).
- `sysctl_kern_msgbuf` (`subr_prf.c:1153-1198`) branch 3
  (`n - rindex_modulo`): modular arithmetic makes the branch unreachable
  except in the corrected corner (`xindex_modulo == 0`, `rindex_modulo ==
  2048`) where the copy stays in-bounds (truncating only). No OOB.
- `msgbuf_clear` racing `logread`: cursor may regress and the reader may
  see bzero'd bytes — data integrity only, all accesses still
  modulo-bounded; root-only CTLFLAG_SECURE sysctl. DF-0192 family.
- Missed wakeup in `logread`'s sleep loop: self-heals on the next kprintf
  (msgbuftrigger re-arms), PCATCH allows signal interruption. Liveness
  nit only.
- `sc_state` plain store in `logclose` racing atomic bit ops: hint bits
  only, benign.
- `kqfilter`/knote lifecycle: `logsoftc` is static, knotes reference no
  per-open data; fd-close detaches via `logfiltdetach`. No lifetime bug.
- `logopen` `callout_init_mp` on an armed callout: only reachable via the
  DF-0189 double-open (same root cause, amplifier noted there).
- `TIOCSPGRP` privilege: `fsetown` enforces same-session policy
  (`kern_descrip.c:1326,1341`) — no cross-session signaling.
