TIOCSCTTY reassignment performs unsynchronized read-modify-write of session->s_ttyvp in vn_ioctl/devfs_fo_ioctl: racing twins double-vrele() the old controlling-tty vnode β v_refcnt underflow β premature vnode destruction β use-after-free
| Field | Value |
|---|---|
| ID | DF-2755 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
| CWE | CWE-362 (βCWE-416) |
| File | sys/kern/vfs_vnops.c |
| Lines | 1049-1053 (twin devfs_vnops.c:1606-1610; clears tty.c:365/382, devfs half-close 1153-1156, fdrevoke kern_descrip.c:2031-2034) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
After a successful VOP_IOCTL(TIOCSCTTY), the vnode layer re-assigns the session's controlling-tty vnode with a plain read-modify-write: vn_ioctl under the mplock only, and its reachable twin devfs_fo_ioctl (every tty fd lands on devfs_dev_fileops because devfs VOP_OPEN switches f_ops) under no lock at all. The mplock excludes no other s_ttyvp writer: ttyclosesession uses prg->proc_token, the devfs half-close holds only the closing vnode's lock, the fdrevoke scan holds nothing session-wise. ttioctl's gate lets the session leader re-run TIOCSCTTY at full rate on any fd of a tty whose t_session is already the leader's session β and a pty master vnode and slave vnode are two distinct vnodes over one tty, so the slot can ping-pong between them. Two threads that both read the same ovp before either stores both vrele() it β releasing the single slot reference twice β refcount machinery destroys the vnode early β stale-fd/type-confusion UAF.
Threat model & preconditions
Any local unprivileged user with access to /dev/ptmx. Verified on the stock INVARIANTS guest: deterministic-in-seconds kernel panic from uid 1001; on production kernels the identical interleaving silently destroys a live vnode β UAF with a clear path to arbitrary kernel memory corruption and uid=0 (vnode-cache recycle; documented in VERDICT.md, not completed β the assert fires first).
Proof of concept
VERIFIED 2/2 on fresh-reset guests (findings/poc/DF-2755/df2755.c):
become session leader, posix_openpt + open slave + TIOCSCTTY to arm
the slot, then 2ΓN threads race TIOCSCTTY on master vs slave fds β
panic: vref: bad refcnt 00000000 1 with devfs_fo_ioctl+0x13f in the
trace. Fix (atomic cmpset ownership transfer at ALL five writer sites)
validated in-guest: nativekernel rebuild, 3 runs / 309.5M iterations
clean, ctty semantics intact.
Recommended fix
Convert every s_ttyvp writer to an atomic ownership transfer so exactly one racer releases the slot reference: cmpset in vn_ioctl, devfs_fo_ioctl, the devfs half-close, the fdrevoke scan, and ttyclosesession's two clears (full git-apply-able diff in findings/poc/DF-2755/fix.diff).
Timeline
- 2026-08-30 Discovered during pass-2 audit of vfs_vnops.c (GLM 5.3); unpriv panic reproduced 2/2 + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2755 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2755.c | β | 4.1 KB | view raw | |
| build.sh | β | 51 B | view raw | |
| run.sh | β | 61 B | view raw | |
| run.log | β | 911 B | view raw | |
| run.2.log | β | 838 B | view raw | |
| run.3.log | β | 1.1 KB | view raw | |
| build.log | β | 1.2 KB | view raw | |
| panic.txt | β | 567 B | view raw | |
| panic1.txt | β | 13.4 KB | view raw | |
| panic2.txt | β | 13.4 KB | view raw | |
| env.txt | β | 492 B | view raw | |
| fix.diff | β | 3.2 KB | view raw | |
| VERDICT.md | β | 5.5 KB | β raw | |
| README.md | β | 2.1 KB | β raw | |
| manifest.json | β | 1.0 KB | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-2755 β TIOCSCTTY s_ttyvp unsynchronized RMW race β double vrele β vnode UAF
Unprivileged local kernel memory-corruption (vnode refcount underflow /
use-after-free) in the controlling-tty reassignment path that lives in
vn_ioctl() (sys/kern/vfs_vnops.c:1030-1055) and its reachable twin in
devfs_fo_ioctl() (sys/vfs/devfs/devfs_vnops.c:1595-1611).
Build (unprivileged)
cc -O2 -pthread -o df2755 df2755.c
Run (unprivileged)
./df2755 [threads_per_side] [seconds] # default 3 60
The process becomes a session leader, opens a pty pair, arms the session's
controlling-tty slot with the slave vnode, then spins two groups of racer
threads: master-fd racers and slave-fd racers issuing ioctl(fd, TIOCSCTTY)
in a tight loop. Every successful ioctl runs the racy s_ttyvp
read-modify-write; same-side threads that interleave read-read-store-store
release the old controlling-tty vnode's slot reference twice.
Expected result
Stock kernel (verified 2/2, within seconds..tens of seconds):
panic: vref: bad refcnt 00000000 1 vref() at vref+0x36 devfs_fo_ioctl() at devfs_fo_ioctl+0x13f mapped_ioctl() at syscall2
β the double-vrele drove the still-open pty vnode's refcount to zero, the
vnode was prematurely destroyed, and the next twin's vref() tripped the
INVARIANTS assert on the freed vnode (use-after-free made loud). Without
INVARIANTS the same interleaving is a silent UAF on a live fd.
Patched kernel (fix.diff, cmpset ownership-transfer on all s_ttyvp writers): 3 clean runs / 309.5M racer iterations, no panic, ctty semantics intact (ctty_sanity exit 0).
Contents
df2755.c PoC source (final) build.sh / run.sh run.log baseline run 1 (panic) run.2.log baseline run 2 (panic) run.3.log fixed-kernel validation runs + ctty sanity build.log build/patch/kernel-build transcript panic.txt panic signature panic1.txt / panic2.txt raw serial env.txt guest/environment fix.diff git-apply-able unified diff (validated in-guest) VERDICT.md full narrative manifest.json / verdict.json
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 onlydevfs_fo_ioctl()β sys/vfs/devfs/devfs_vnops.c:1595-1611 β no lock at all
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:
- slot == vpS (slave). Both master-side twins pass the early-out (
vp != s_ttyvp) and both readovp = vpS. - Both execute
vref(vpM); s_ttyvp = vpM; vrele(vpS). - 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-srcguests, 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)
- Unpriv user opens pty pair, becomes session leader, arms slot with slave vnode; N threads race TIOCSCTTY master-vs-slave (this PoC).
- 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).
- Refcount hits 0 with fd still open β vnode destroyed/recycled while
fp->f_dataand the /dev/pts namecache entry still point at it. - 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_writeon 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).
Fix verification
fixedfix.diff applied to guest /usr/src (patch -p1, all hunks clean), make -j6 nativekernel KERNCONF=X86_64_GENERIC BUILD_RC=0, installkernel, reboot into #1 20:54:26. Baseline panic (2/2 on stock) absent on patched kernel across 3 runs / 309.5M racer iterations; ctty semantics (TIOCSCTTY/TIOCGPGRP//dev/tty) verified intact; guest then reset with-src.
['run.3.log', 'build.log', 'fix.diff']
Confirmed kernel references
Detail
Exploit chain
unpriv setsid + posix_openpt -> arm s_ttyvp slot with slave vnode -> N threads race ioctl(TIOCSCTTY) on master+slave fds of same tty -> same-side twin interleave double-vreles old-slot vnode -> v_refcnt underflow -> premature vnode destruction with live fd/namecache references -> vnode-cache recycle = type confusion through stale devfs_dev_fileops fd (or stale ncp vget) -> arbitrary kernel memory corruption; uid0 chain documented but not completed on the INVARIANTS guest (assert fires first, DF-2687 precedent)
Evidence (decisive lines)
['run.log / run.2.log + panic1.txt / panic2.txt: stock-kernel panics, trace vref+0x36 <- devfs_fo_ioctl+0x13f <- mapped_ioctl <- syscall2', 'run.3.log: fixed kernel, 3 clean runs / 309,517,530 iterations + ctty_sanity exit 0', 'fix.diff: cmpset ownership transfer, all 5 s_ttyvp writers, applied+built in-guest', 'VERDICT.md: full interleaving analysis and non-duplicate argument vs DF-2687']
PoC changes
Seed sketch did not exist for this novel finding; two PoC designs were needed: (1) 4 threads on the SAME fd was a no-op because the twin early-outs when s_ttyvp==vp (98.8M iterations, no crash -- design flaw); (2) final design races master-fd vs slave-fd thread groups of the same session leader so the slot ping-pongs between the two vnodes and same-side threads interleave read-read-store-store. Panicked within the first run window.
Verified recommended fix
Convert every s_ttyvp writer (both TIOCSCTTY twins, devfs half-close, fdrevoke scan, ttyclosesession) to atomic_cmpset_ptr ownership transfer so exactly one racer releases the slot reference
Verdict
Unprivileged uid-1001 PoC racing TIOCSCTTY master-vs-slave from 6 threads of a session leader deterministically (2/2 runs, seconds-scale) panics the stock INVARIANTS kernel with 'vref: bad refcnt 00000000 1' from devfs_fo_ioctl+0x13f -- the racy s_ttyvp twin's vref() on a vnode whose refcount the double-vrele already drove to zero while the fd was still open. That is a proven vnode-refcount-underflow UAF primitive (silent on non-INVARIANTS kernels); the identical race exists in vn_ioctl's mplock-only twin (the mplock excludes no other s_ttyvp writer). fix.diff (atomic_cmpset_ptr ownership transfer on all five s_ttyvp writers) validated in-guest: nativekernel rebuild, 3 runs / 309.5M racer iterations clean, ctty semantics intact.
No comments yet.