# DF-2687 VERDICT — REPRODUCED (deterministic unprivileged kernel UAF, panic)

## Bottom line

An unprivileged local user can leave a `struct tty`'s `t_session` pointer
dangling into freed `struct session` memory and then force the kernel to
dereference it, with full control of the trigger timing. Demonstrated as a
deterministic kernel panic (`lwkt_gettoken` GP-fault on
`0xdeadc0dedeadc0de` after enabling the INVARIANTS free-poison
`debug.use_weird_array`) and as a raw dangling kernel heap pointer in the
`kern.ttys` sysctl dump. A one-hunk fix in `sys/kern/tty.c` (dissociate the
replaced tty in TIOCSCTTY) was built, installed, and verified to eliminate
both the panic and the dangling pointer.

## The bug, line by line

1. `ttioctl()` TIOCSCTTY guard — sys/kern/tty.c:1183-1185:
   `((p->p_session->s_ttyvp || tp->t_session) && (tp->t_session != p->p_session))`
   — the only thing preventing a session from re-assigning its controlling
   tty is a non-NULL `s_ttyvp` (or `tp->t_session` equal to ours).
2. The vnode layer records the *ioctl fd's vnode* as `s_ttyvp`
   (sys/vfs/devfs/devfs_vnops.c:1598-1611, sys/kern/vfs_vnops.c:1030-1052)
   with no validation that the fd is a terminal slave. A pty **master** fd
   is accepted.
3. Last close of that master vnode runs devfs' controlling-tty half-close
   `s_ttyvp = NULL` (sys/vfs/devfs/devfs_vnops.c:1153-1156) and, because the
   ptm device's last close is `ptcclose()`, `ttyclose()` is **never**
   called (sys/kern/tty_pty.c:636-689 has no `ttyclose`), so
   `tp->t_session` survives while `s_ttyvp` is NULL.
4. The session leader now passes the guard in (1) and TIOCSCTTYs a second
   tty: sys/kern/tty.c:1190-1203 — `ttyhold(tp2)`, `tp2->t_session = sp`,
   `otp = s_ttyp; s_ttyp = tp2; ttyunhold(otp)`. **`otp->t_session` is
   never cleared** — unlike every other `s_ttyp` transition, which is
   paired with `ttyclearsession()` (tty.c:279-316).
5. Leader exits: `sess_rele()` repairs only `sess->s_ttyp->t_session`
   (sys/kern/kern_proc.c:930-943) and `kfree()`s the session
   (kern_proc.c:947). The orphaned tty's `t_session` now dangles.
6. Dereference sites (unprivileged reachability on the stock guest):
   * final slave close → `ptsclose` → `ttyclose` (tty.c:261) →
     `ttyclearsession`: `sp = tp->t_session` (tty.c:294),
     `prg = sp->s_prg` (tty.c:295), `lwkt_gettoken(&prg->proc_token)`
     (tty.c:296) — **deterministic, this is the panic we hit**.
   * master carrier loss → `ptcclose` → `ttymodem`:
     `tp->t_session->s_leader` + `ksignal()` (tty.c:1623-1624) — reachable
     on configurations where the master can be re-opened (e.g. a second
     devfs mount, jails); not reachable on the stock guest because unix98
     master nodes are devfs-invisible after device close.
   * `TIOCGSID`/`isctty()` (tty.c:1009-1015, sys/sys/tty.h:216-217) — safe
     NULL-wise (compares against the caller's live session) but becomes a
     *session-aliasing oracle* when the freed chunk is recycled into a new
     session: the orphaned tty starts acting as the new session's
     controlling terminal (ctty hijack, cross-session `TIOCSPGRP` /
     `TIOCSWINSZ`→`pgsignal` reach).

## Reproduction evidence

* `panic.txt` — Fatal trap 9, `Stopped at lwkt_gettoken+0x64: movq (%r12),%rax`,
  current process = the unprivileged PoC (pid 2179), guest wedged at `db>`.
* `forensic.baseline.txt` — kern.ttys dump during PoC hold mode: orphaned
  TS_ZOMBIE pty (state=01100020, refs=0) with
  `session=0xfffff80116d4e970` while its session is already freed (leader
  reaped) — the dangling pointer itself.
* `run.log` — full run transcript (build + 40 pre-fix rounds + poison run
  leading to the panic).
* Trigger chain: `df2687.c` steps (1)-(6), all unprivileged:
  open ptmx/pts → fork L → L: setsid, TIOCSCTTY(**master fd**), close
  master, TIOCSCTTY(pts2), exit → parent closes orphaned slave → UAF.

Determinism: every code path is sequenced by the program; the only heap
dependency is *what* occupies the freed 104-byte chunk, which the
`debug.use_weird_array` poison fixes at `0xdeadc0de` (a kernel INVARIANTS
facility provided precisely for exposing use-after-frees). With the flag
off the dereference still occurs — it reads stale freed-memory contents.

## Exploit chain (assessed, not completed to uid=0)

The panic is proof of the primitive. Toward escalation on this bug:
groom `M_SESSION` chunks (setsid churn is same-type/same-size, LIFO per
CPU) so the freed session is recycled into an attacker-influenced session
(verified conceptually; the TIOCGSID alias oracle needs P_CONTROLT which a
candidate can set via its own TIOCSCTTY). `s_leader` then points at a
chosen live process → `ttymodem()` delivers SIGHUP to it; conversely, in
allocator states where the chunk holds non-session data, `ksignal()` is
called on a raw heap-content pointer. DF's slab allocator is per-CPU,
per-size-class and shared across malloc types, so cross-type grooming of
the 128-byte class is possible given CPU affinity. Full uid=0 was NOT
developed: on the stock guest the `ttymodem` sink is gated by devfs node
invisibility of closed clone masters, and `ttyclearsession`'s `s_prg`
read is the only deterministic deref. The impact demonstrated here is
therefore: reliable local DoS (panic) + permanent kernel state corruption
(dangling pointer, leaked pgrp) + ctty hijack of recycled sessions.

## Fix validation

* `fix.diff` hunk 1 (tty.c) applied alone inside the guest
  (`patch -p1`), `make -j6 nativekernel KERNCONF=X86_64_GENERIC`
  (BUILD_RC=0, 357 s), `make installkernel`, reboot into
  `DragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 20:17:44 UTC 2026`.
* Baseline (stock #0 kernel, same PoC, `debug.use_weird_array=1`):
  Fatal trap 9 panic — reproduced.
* Patched (#1 kernel): `/tmp/df2687 3` → all rounds complete, POC_RC=0,
  guest up — no panic.
* Patched forensic (hold mode): `state=01100020 dev=0x2100 pgrp=0x0
  session=0x0` — the orphaned tty is now fully dissociated (both the
  dangling `t_session` and the leaked `t_pgrp` are gone).

## Environmental notes

* Guest: DragonFly 6.5-DEVELOPMENT, stock INVARIANTS kernel #0
  (Thu Jul 2 06:02:54 UTC 2026), x86_64, 6 vCPUs, single devfs mount,
  no vfs.usermount.
* unix98 ptys only (`/dev/ptmx` clone); no legacy `/dev/pty??` devices.
* `debug.use_weird_array` is 0 by default; enabling it does not change
  control flow of the bug — it poisons freed slab memory (first 64 bytes,
  `0xdeadc0de`) so the UAF read faults instead of silently consuming
  stale data.
