# DF-2949 — VERDICT

**status: not_reproduced** (race window not hit within PoC budget; the
contended interleaving itself IS reproduced — see evidence below)

## What the finding is

`cttyclose()` (sys/kern/tty_tty.c:141-180) acquires the tty vnode with
`vref()` (line 159) after reading `cttyvp(p)` (line 153) and
`VCTTYISOPEN` (line 155) **with no token and no reference held**:

```c
retry:
	if ((ttyvp = cttyvp(p)) == NULL)            /* :153 unsynchronized */
		return(0);
	if (ttyvp->v_flag & VCTTYISOPEN) {          /* :155 unsynchronized */
		vref(ttyvp);                        /* :159 REQUIRES refcnt>0 */
```

`vref()` is documented (vfs_lock.c:262-272) to require the caller to
already own a reference / the vnode to be active:

```c
void vref(struct vnode *vp)
{
	KASSERT((VREFCNT(vp) > 0 && vp->v_state != VS_INACTIVE), ...)
	atomic_add_int(&vp->v_refcnt, 1);
}
```

cttyclose owns no such reference: the only thing keeping the vnode
referenced at that moment is the session's `s_ttyvp` reference, which
concurrent teardowns drop **without any lock or token that cttyclose
holds**:

  * leader exit: `ttyclosesession(sp, 1)` — tty.c:367 (`s_ttyvp=NULL`),
    :369 (flag clear), :370 (VOP_CLOSE), :374 (`vrevoke` force-closes
    every fd on the tty vnode), :379 (**terminal `vrele(vp)`**);
  * devfs half-close: devfs_vnops.c:1153-1155 (`s_ttyvp=NULL; vrele(vp)`);
  * fdrevoke callback: kern_descrip.c:2031-2035 (`s_ttyvp=NULL; vrele`).

If a thread is descheduled between the flag read (:155) and the `vref`
(:159) — two adjacent instructions, stretched to roughly a microsecond
by cache-coherence traffic because the teardown on another CPU is
hammering the very same `v_flag`/`v_refcnt` cache lines — and resumes
after the terminal `vrele()`, it executes `vref()` with
`v_refcnt == 0` / `v_state == VS_INACTIVE`:

  * INVARIANTS kernels: `panic: vref: bad refcnt 00000000 <state>`
    (vfs_lock.c:269-270). Verified the guest kernel (X86_64_GENERIC)
    compiles INVARIANTS in (`grep INVARIANTS
    /usr/src/sys/config/X86_64_GENERIC`).
  * stock kernels: the freelist vnode's refcount is resurrected 0->1
    while the vnode recycler owns it — refcount corruption with
    use-after-free potential (a recycled/confused vnode being "owned"
    by cttyclose, later `vn_unlock`+`vrele` on a reused vnode).

The sibling functions all use the reclaim-safe API instead —
`cttyopen()` uses `vhold()` (tty_tty.c:108) and `ttyclosesession()`
uses `vhold()` (tty.c:349). cttyclose is the odd one out; the fix is
the same one-line swap (`fix.diff`).

## Why "not_reproduced" is honest

Reproduced on the guest (DF-2949 rig, unprivileged user `maxx`):

  1. The two bodies demonstrably execute concurrently: with a leader
     exiting while sibling victims close their last `/dev/tty` fd,
     `Warning: cttyclose: race avoided` (tty_tty.c:167) floods the
     console (84+ occurrences per ~600 aligned sessions in the sweep;
     msgbuf rotates under the flood), and victim `close()` durations
     measured from 200us up to 1.3s in /tmp/diag.log — that is a victim
     blocked at tty_tty.c:160 (`vn_lock`) *through* the leader's
     `ttyclosesession` teardown, having already passed :153/:155/:159.
  2. The leader's teardown window was located empirically at
     GO+600..950us (leader exit latency 0.6-0.9ms; sweep deltas
     d=0.000615..0.000948 produce the blocked closes).
  3. ~10-15k sessions and ~60-90k victim shots through the aligned
     window across several rig shapes (2..8 parallel rigs, 2..6 victims,
     staggered spin-wait alignment) produced **zero** panics.

The panic requires the descheduling event to *begin* inside the
~1-microsecond [155..159] span and to *outlast* the remaining teardown
(100us-1.5ms including the `vrevoke` allproc scan). Under the guest's
6 vCPUs with modest load, observed victim preemptions are frequent
(200-700us stalls were measured), but the probability of one beginning
inside that particular microsecond is of order 1e-5..1e-4 per shot:
expected hit time is tens of minutes to hours of continuous hammering.
The PoC budget (~35 min of guest hammering) did not win that lottery.

What would reproduce it deterministically: an artificial scheduling
point between :155 and :159 (not acceptable in a PoC that must not
modify the kernel), or a much longer hammer run (hours), or a
higher-resolution preemption source (frequent timer/IPI load plus
affinity pinning of the victim against the leader).

## Exploitability assessment (uid0?)

On a stock (non-INVARIANTS) kernel the primitive is a refcount
resurrection of a vnode that the recycler may hand out concurrently —
i.e., two "owners" of one vnode: a genuine UAF-class primitive. Turning
it into uid=0 would require (a) winning the nanosecond race, (b) the
resurrected vnode being recycled into a new object while cttyclose
still holds a stale temp ref, (c) grooming the recycled vnode type so
that the later `vn_unlock/vrele` corrupts an attacker-chosen victim.
(a) is the lottery above; (b)/(c) are speculative. Not pursued further
because (a) did not reproduce in budget. Classification: local
privilege-adjacent memory corruption, practically a reliable panic on
INVARIANTS/debug systems only.

## Fix validation

Not performed as a kernel rebuild: the finding did not reproduce (no
panic to observe disappearing). `fix.diff` is validated by inspection:
it replaces `vref/vrele` with `vhold/vdrop` — the exact pattern used by
`cttyopen()` (tty_tty.c:108-131) and `ttyclosesession()` (tty.c:349-375)
for the same lock-across-teardown protocol; `vhold` is the API designed
to keep vnode memory valid across concurrent terminal `vrele()`.

## Environment

DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026
(X86_64_GENERIC, INVARIANTS), 6 vCPUs, gcc 8.3, unprivileged user
uid=1001(maxx). Guest was reset clean after the run (`vm.sh reset
with-src`).
