# DF-2755 — Verdict

## Status: REPRODUCED (unprivileged, deterministic-in-seconds, 2/2 baseline runs)
## Impact: kernel memory corruption — vnode refcount underflow → premature vnode
##         destruction → use-after-free on a still-open fd (panic on INVARIANTS
##         kernels demonstrated; silent UAF primitive on production kernels)

## The bug

Controlling-tty reassignment after a successful `TIOCSCTTY` performs an
unsynchronized read-modify-write of `session->s_ttyvp` in TWO copies of the
same "twin" block:

* `vn_ioctl()`  — sys/kern/vfs_vnops.c:1030-1055 — guarded by the **mplock only**
* `devfs_fo_ioctl()` — sys/vfs/devfs/devfs_vnops.c:1595-1611 — **no lock at all**

```c
if (sess->s_ttyvp == vp) { return; }   /* early-out only when identical */
ovp = sess->s_ttyvp;                   /* racy read    */
vref(vp);                              /* widen: contended atomic */
sess->s_ttyvp = vp;                    /* racy store   */
if (ovp) vrele(ovp);                   /* paired release */
```

The mplock serializes nothing here: none of the other s_ttyvp writers hold it —
`ttyclosesession()` (tty.c:328-382) uses `prg->proc_token`, the devfs
half-close (devfs_vnops.c:1153-1156) holds only the closing vnode's lock, and
the fdrevoke scan (kern_descrip.c:2031-2034) holds nothing. Every tty fd
lands on `devfs_dev_fileops` (devfs VOP_OPEN switches `fp->f_ops`,
devfs_vnops.c:1078), so the reachable twin is the unlocked devfs one; the
vn_ioctl copy is the same latent defect one layer up.

ttioctl's gate (tty.c:1181-1204) lets the session leader re-run TIOCSCTTY on
any fd of a tty whose `t_session` is already the leader's session (sticky for
the tty's lifetime). A pty MASTER vnode and its SLAVE vnode are two different
vnodes sharing one tty, so the leader can make the slot ping-pong between
them at full ioctl rate.

## The race (proven on-guest)

Two threads of the session leader whose fds resolve to different vnodes of
the same tty race same-side:

1. slot == vpS (slave). Both master-side twins pass the early-out (`vp !=
   s_ttyvp`) and both read `ovp = vpS`.
2. Both execute `vref(vpM); s_ttyvp = vpM; vrele(vpS)`.
3. The slot accounted exactly ONE reference on vpS; TWO were released →
   v_refcnt underflow by one per hit. (Mirror case for slave-side pairs at
   slot == vpM underflows the master vnode.)

vpS/vpM carry only a handful of references (s_ttyvp slot + the open fd +
namecache). Two hits drive `v_refcnt` to 0 while the fd is still open → the
vnode enters terminate/destroy → the next racer's `vref(vp)` in the twin
dereferences a destroyed vnode.

## Evidence

* run.log / run.2.log + panic1.txt / panic2.txt — stock INVARIANTS kernel,
  uid 1001, fresh `vm.sh reset with-src` guests, runs died with the identical
  signature:

      panic: vref: bad refcnt 00000000 1
      vref() at vref+0x36
      devfs_fo_ioctl() at devfs_fo_ioctl+0x13f   <-- the twin's vref
      mapped_ioctl() at mapped_ioctl+0x5fa
      syscall2() at syscall2+0x11e

  "bad refcnt 00000000 1" = v_refcnt 0 on a vnode still reachable through a
  live fd: the underflow already destroyed it. That is the UAF caught by
  INVARIANTS; the identical interleaving on a production (non-INVARIANTS)
  kernel silently consumes freed vnode memory.
* run.3.log — fix.diff applied in-guest, `make nativekernel` (#1 20:54:26),
  3 runs / 309,517,530 racer iterations: no panic, no anomaly, ctty
  semantics verified intact (`ctty_sanity` exit 0: TIOCSCTTY + TIOCGPGRP +
  /dev/tty open).

## Exploit chain (primitive characterized; uid0 route documented, not completed)

1. Unpriv user opens pty pair, becomes session leader, arms slot with slave
   vnode; N threads race TIOCSCTTY master-vs-slave (this PoC).
2. Each same-side interleave = -1 v_refcnt on the old-slot vnode (slave or
   master) — attacker can underflow a chosen vnode of the pair by exactly k
   (stop racing after k hits is observable on INVARIANTS only; on production,
   count via timing/statistics or just drive to destruction as here).
3. Refcount hits 0 with fd still open → vnode destroyed/recycled while
   `fp->f_data` and the /dev/pts namecache entry still point at it.
4. Vnode-cache recycle → the stale fd now references a resurrected vnode of
   attacker-chosen type (groom: immediately open many files/devices) →
   type confusion via `fo_ioctl`/`fo_read`/`fo_write` on devfs_dev_fileops
   against a non-devfs v_op/v_data, or stale-namecache vget onto a freed
   vnode. Ceiling: arbitrary kernel memory corruption → uid=0.
   (On the stock INVARIANTS guest the assert fires first — by design; the
   DF-2687 precedent documents the same constraint.)

## Why not a duplicate of DF-2687

DF-2687 (tty.c) is the missing t_session dissociation orphaning the OLD
tty's session pointer → struct session UAF. DF-2755 is a different defect in
a different structure: missing synchronization on the s_ttyvp slot itself →
double vrele → struct VNODE refcount underflow → premature vnode destruction.
The PoC here never closes the last fd and never triggers DF-2687's orphan
path; it races twin-vs-twin only.

## Root-cause fix (validated)

Convert every s_ttyvp writer to an atomic ownership transfer
(`atomic_cmpset_ptr`): the cmpset succeeds for exactly one racer per old
value, so the vref(new)/vrele(old) pairing stays exact with no new lock and
no lock-order risk. fix.diff patches all five sites: both twins
(vfs_vnops.c:1048-1057, devfs_vnops.c:1607-1623), the devfs half-close
(devfs_vnops.c:1153-1160), the fdrevoke scan (kern_descrip.c:2031-2035) and
ttyclosesession's two clears (tty.c:365-371, 382-384).
