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)
PoC verification
Evidence pack
findings/poc/DF-2949 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 avoidedon 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).
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 (vrevokeforce-closes every fd on the tty vnode), :379 (terminalvrele(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+vreleon 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):
- The two bodies demonstrably execute concurrently: with a leader
exiting while sibling victims close their last
/dev/ttyfd,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 victimclose()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'sttyclosesessionteardown, having already passed :153/:155/:159. - 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).
- ~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_testableFix 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.
[]
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.
No comments yet.