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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2755 Β· 16 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download raw

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:

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Aug 31 20:54:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.