β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2949

cttyclose() takes its tty-vnode reference with vref() racing the terminal vrele() of concurrent ctty teardowns β€” vref: bad refcnt panic (INVARIANTS) / freelist vnode resurrection (stock)

Field Value
ID DF-2949
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
CWE CWE-362 (race enabling UAF-class refcount corruption)
File sys/kern/tty_tty.c
Lines 153-159 (window bounded by tty.c:379 teardown vrele)
Area kern/tty
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

cttyclose reads cttyvp(p) (:153) and VCTTYISOPEN (:155) with no token and no reference, then calls vref(ttyvp) (:159). vref() contractually requires an already-owned reference / active vnode (KASSERT at vfs_lock.c:269). The only reference protecting ttyvp at that instant is the session's s_ttyvp reference, and three concurrent droppers release it with no lock or token that cttyclose holds: leader-exit ttyclosesession (s_ttyvp=NULL, VOP_CLOSE, vrevoke force-closing every fd on the tty vnode, terminal vrele), the devfs half-close, and fdrevoke's callback. A thread descheduled between :155 and :159 that resumes after the terminal vrele() executes vref() on a vnode with refcount 0 / VS_INACTIVE. The sibling functions use the reclaim-safe API for this exact protocol β€” cttyopen vhold(), ttyclosesession vhold() β€” cttyclose is the odd one out. Trigger: one unprivileged process closing its last /dev/tty fd while the session leader exits. INVARIANTS: deterministic-class panic ('vref: bad refcnt'). Stock: atomic 0β†’1 refcount bump on a vnode owned by the recycler β€” two owners of one vnode; a UAF-class corruption primitive (uid=0 needs the ~1Β΅s deschedule lottery plus vnode-reuse grooming; assessed practical impact is local DoS with memory-corruption potential).

Proof of contest

Raced ~35 min / ~60-90k shots with a 3-rig unprivileged hammer: contended interleaving reproduced decisively (84+ 'cttyclose: race avoided' warnings per 600 aligned sweep iterations; victim close() durations 200Β΅s-1.3s = blocked at tty_tty.c:160 through the leader's teardown). The panic lottery (~1e-5..1e-4 per shot: deschedule must begin inside the ~1Β΅s span and outlast the 100Β΅s-1.5ms teardown incl. vrevoke allproc scan) was not won β€” verdict honestly not_reproduced with full window math in findings/poc/DF-2949/VERDICT.md. Fix: vhold/vdrop instead of vref/vrele (matches siblings).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of tty_tty.c (GLM 5.3); contended window proven, panic not won; TIOCSCTTY-reassignment variant REFUTED on guest (REFUTED.md).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2949 Β· 11 files
FileTypeDescriptionSize
README.md β€” 1.7 KB ↓ raw
VERDICT.md β€” 5.8 KB ↓ raw
ctty_race_hammer.c β€” 3.1 KB view raw
ctty_race_sweep.c β€” 2.7 KB view raw
diag_timing.c β€” 1.9 KB view raw
build.sh β€” 222 B view raw
run.sh β€” 429 B view raw
fix.diff β€” 731 B view raw
hashes.txt β€” 324 B view raw
run.fresh.diag.log β€” 61 B view raw
run.fresh.hlogs.txt β€” 21 B view raw

DF-2949 β€” cttyclose() vref() races terminal vrele() of concurrent ctty teardown (refcount corruption / KASSERT panic / UAF on stock)

Build

On the DragonFly guest (any user; unprivileged):

cc -O2 -o /tmp/ctty_race_hammer /tmp/ctty_race_hammer.c
cc -O2 -o /tmp/ctty_race_sweep  /tmp/ctty_race_sweep.c   # alignment sweep / oracle
cc -O2 -o /tmp/diag_timing     /tmp/diag_timing.c        # latency diagnostics

Run

Two rigs in parallel (unprivileged):

/tmp/ctty_race_hammer 100000000 700 > /tmp/h1.log 2>&1 &
/tmp/ctty_race_hammer 100000000 800 > /tmp/h2.log 2>&1 &

Alignment/oracle sweep (produces the benign-overlap evidence):

/tmp/ctty_race_sweep 600 100 900
# then: dmesg | grep -c "cttyclose: race avoided"
#       awk long-duration closes in /tmp/diag.log (dur=... > 200us)

Expected

  • Benign overlap (always, within a few hundred iterations): Warning: cttyclose: race avoided on console/dmesg, and victim close() durations of 200-1500 us in /tmp/diag.log (blocked at tty_tty.c:160 through the leader-exit ttyclosesession teardown).
  • Panic (the finding): panic: vref: bad refcnt 00000000 ... from vfs_lock.c:269-270 when a victim is descheduled between the unlocked VCTTYISOPEN read (tty_tty.c:155) and the vref (tty_tty.c:159) and resumes after the leader's terminal vrele() (tty.c:379). Guest goes down into DDB; dfbsd-qemu/vm.sh status => down.

The panic window is nanosecond-scale (two adjacent instructions, stretched to ~1 us only by cache-coherence traffic on the vnode's v_flag/v_refcnt lines): the hammer reproduces the contended interleaving deterministically, the preempt-in-window hit is a lottery (see VERDICT.md for the measured rates and honest status).

VERDICT.md
↓ download raw

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:

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:

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).

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

Fix build not performed: the bug itself did not reproduce (no panic to observe disappearing), so a patched-kernel A/B has no observable delta within budget. fix.diff is inspection-validated: vhold/vdrop is the same pattern the sibling functions already use for the identical lock-across-teardown protocol.

[]
↓ fix.diffper-fix-DF-2949

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-2949/VERDICT.md (full narrative + race-window math)', 'findings/poc/DF-2949/run.fresh.diag.log / run.fresh.hlogs.txt (fresh-guest hammer output)', "guest observations recorded in VERDICT.md: 84x 'cttyclose: race avoided' in dmesg after 600-iteration sweep; /tmp/diag.log long closes dur=340.9..1301080.9us at deltas d=0.000615..0.000948", 'findings/poc/DF-2949/ctty_race_sweep.c (alignment oracle) and ctty_race_hammer.c (panic rig)']

PoC changes

Three full redesigns: (1) sibling-thread close burst was void because ctty_ops lacks D_TRACKCLOSE so devfs dispatches cttyclose only on the LAST /dev/tty fd (devfs_vnops.c:1146-1171) β€” no two cttyclose bodies can race; (2) leader-exit pairing needed victims to survive the exit-time SIGHUP (pgsignal at kern_exit.c:458) and to open /dev/tty BEFORE closing the direct slave fd (otherwise the devfs half-close NULLs s_ttyvp and cttyopen returns ENXIO); (3) usleep jitter missed the teardown entirely (victims closed 2-4ms after GO vs the leader's 0.6-0.9ms exit) β€” replaced with spin-wait alignment swept to the measured GO+600-950us window.

Verified recommended fix

In cttyclose replace vref()/vrele() with vhold()/vdrop() (matching cttyopen tty_tty.c:108 and ttyclosesession tty.c:349) so the vnode reference survives a concurrent terminal vrele().

Verdict

The vref()-without-owned-reference defect in cttyclose (tty_tty.c:159) is code-certain: the unsynchronized cttyvp/VCTTYISOPEN reads (:153/:155) precede a vref() whose only protecting reference is the session's s_ttyvp ref, which leader-exit ttyclosesession (tty.c:367-379), the devfs half-close (devfs_vnops.c:1153-1155) and fdrevoke (kern_descrip.c:2031-2035) all drop with no lock or token held by cttyclose; a thread descheduled between :155 and :159 across the terminal vrele() executes vref() with v_refcnt==0/v_state==VS_INACTIVE -> 'vref: bad refcnt' panic (vfs_lock.c:269, INVARIANTS verified compiled into the guest kernel) or a resurrected freelist vnode on stock kernels. On the guest the CONTENDED interleaving itself reproduced decisively: 84+ 'cttyclose: race avoided' console warnings per ~600 aligned sweep iterations and victim close() durations of 200us-1.3s (blocked at tty_tty.c:160 through the leader's teardown), with the teardown window empirically located at GO+600-950us. ~10-15k sessions / ~60-90k victim shots across 4 hammer campaigns (2/8/3-rig, spin-aligned, 6 victims each) produced no panic: the loss is the probability of a deschedule beginning inside the ~1us [155..159] span and outlasting the 100us-1.5ms teardown, of order 1e-5..1e-4 per shot. The sibling APIs (cttyopen tty_tty.c:108, ttyclosesession tty.c:349) both use vhold() for exactly this protocol; the two-line fix.diff swaps vref/vrele -> vhold/vdrop.