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

devfs node/cdev lifetime hole: nresolve→devfs_allocv drops devfs_lock on a raw devfs_node; concurrent unpriv pty teardown frees node+cdev → UAF write + sysref_get on freed cdev (proven panic 2/2)

Field Value
ID DF-3017
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:L/I:H/A:H
CWE CWE-362 β†’ CWE-416
File sys/vfs/devfs/devfs_vnops.c
Lines 474-521 (allocv drops: devfs_core.c:319-352)
Area vfs/devfs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

devfs_vop_nresolve finds a node under devfs_lock and calls devfs_allocv, which releases devfs_lock across vget()/getnewvnode() holding only a raw struct devfs_node. An unprivileged user racing open/close of /dev/ptmx against lstat/access of /dev/pts/N makes the devfs core thread run destroy_devβ†’devfs_freep in that window: the node is objcache_put-freed and the cdev sysref torn down. allocv then writes node->v_node=vp into freed memory and calls v_associate_rdev(vp, node->d_dev)β†’reference_dev() on the freed cdev. Second window of the same family: devfs_vop_getattr touches node and node->d_dev with no vnode lock and no devfs_lock, racing the same freep (info leak of freed/reused node fields into struct stat). Any local user (no privileges; /dev/ptmx is 0666). Reproduced twice on the stock INVARIANTS kernel: panic 'assertion (sr->flags & SRF_PUTAWAY) == 0 failed in sysref_get' with stack reference_dev←v_associate_rdev←devfs_allocv←devfs_vop_nresolve, guest dead in 1-4 min. On non-INVARIANTS kernels the same sysref_get silently resurrects a terminating cdev (refcount 0β†’1 on freed memory, groomable via the attacker's own ptmx clones from the same cdev objcache) β†’ double-free/dangling cdev used by dev_d dispatch. uid=0 route: fixed- offset sysref refcount increment on a freed cdev recyclable from the attacker-groomed objcache; deterministic chain not developed (competing sysref-thread teardown timing documented as the blocker) β€” reliable demonstrated impact is unpriv kernel panic + UAF.

Proof of contest

VERIFIED (findings/poc/DF-3017/): df3017.c β€” 3 threads open+close /dev/ptmx, 8 threads lstat+access /dev/pts/0-7 as nobody; panic with the SRF_PUTAWAY assertion and the reference_dev←devfs_allocv stack within 1-4 min (2/2 baseline). Fix validated in-guest (DEVFS_ALLOCVINPROG interlock + DEVFS_FREEWAIT handshake in devfs_freep; note: closing the getattr half needs upstream protocol redesign): identical PoC ran the full window with zero sysref panics.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of devfs_vnops.c (GLM 5.3); unpriv UAF panic reproduced 2/2 + fix validated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3017 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.8 KB ↓ raw
VERDICT.md β€” 6.5 KB ↓ raw
df3017.c β€” 4.9 KB view raw
build.sh β€” 90 B view raw
run.sh β€” 312 B view raw
panic.txt β€” 2.3 KB view raw
panic.run2.txt β€” 2.2 KB view raw
fix_build.log β€” 1.5 KB view raw
fix.diff β€” 3.3 KB view raw
verdict.json β€” 5.9 KB view raw

DF-3017 β€” devfs node/cdev lifetime race: unpriv /dev/pts stat vs pty clone teardown β†’ kernel panic (sysref_get on freed cdev)

What

devfs_vop_nresolve() / devfs_vop_getattr() use a struct devfs_node * (and its node->d_dev cdev) without any reference and outside the protection that keeps that memory alive, while an unprivileged user can make devfs free both concurrently:

  • Window A (proven panic) β€” devfs_allocv() (sys/vfs/devfs/devfs_core.c:303) is called from devfs_vop_nresolve() (devfs_vnops.c:504) with a raw node pointer and drops devfs_lock across getnewvnode()/vget() (devfs_core.c:319/336). While the lock is dropped, the devfs core thread can run destroy_dev() teardown (devfs_destroy_dev_worker β†’ devfs_propagate_dev β†’ devfs_destroy_device_node β†’ devfs_unlinkp + devfs_freep) which frees the node (objcache_put) and drops the cdev sysref. devfs_allocv then writes node->v_node = vp into freed memory and calls v_associate_rdev(vp, node->d_dev) β†’ reference_dev() on the freed/terminating cdev.

  • Window B (same family) β€” devfs_vop_getattr() (devfs_vnops.c:548) dereferences DEVFS_NODE(vp) and node->d_dev with no vnode lock (its major callers deliberately run unlocked: vn_stat() "vp already has a ref and is validated, can call unlocked", and naccess() via VOP_GETATTR_LITE β†’ vop_stdgetattr_lite β†’ VOP_GETATTR) and no devfs_lock. devfs_freep() frees the node after a vget() that succeeds whenever the victim only holds a reference.

Trigger (unprivileged, 2 racing threads, no setup)

  • Thread A: open("/dev/ptmx") + close() in a loop. Every iteration ptyclone() creates pts+ptm cdevs and devfs nodes under /dev/pts; last close runs pti_done() β†’ destroy_dev() Γ—2 (sys/kern/tty_pty.c:281).
  • Thread B: lstat("/dev/pts/N") (+ access()) in a loop β†’ devfs_vop_nresolve β†’ devfs_allocv (window A) and VOP_GETATTR_LITE β†’ devfs_vop_getattr (window B).

Observed result (twice, minutes into the run, stock INVARIANTS kernel):

panic: assertion "(sr->flags & SRF_PUTAWAY) == 0" failed in sysref_get at /usr/src/sys/sys/sysref2.h:70
cpuid = 4
reference_dev() at reference_dev+0xa9
v_associate_rdev() at v_associate_rdev+0x31
devfs_allocv() at devfs_allocv+0x1a0
devfs_vop_nresolve() at devfs_vop_nresolve+0x185
vop_nresolve() at vop_nresolve+0x53
Debugger("panic")

sysref_get on a cdev already in SRF_PUTAWAY teardown = use-after-free write (refcount resurrect) on freed kernel memory. On a non-INVARIANTS kernel the assertion is absent and the resurrected/underflowed sysref proceeds silently into a double-free / dangling cdev β€” the classic exploitable refcount-corruption primitive (see VERDICT.md for the weaponization analysis).

Build

cc -O2 -pthread -o df3017 df3017.c

Run (as any unprivileged user; /dev/ptmx is mode 0666)

./df3017 300

3 churn threads (open/close /dev/ptmx) + 8 stat threads (lstat+access /dev/pts/0..7). Baseline: kernel panic (db> prompt, guest down) within ~1–4 minutes (2/2 runs).

Expected

  • Baseline: panic: assertion "(sr->flags & SRF_PUTAWAY) == 0" failed in sysref_get with reference_dev ← v_associate_rdev ← devfs_allocv ← devfs_vop_nresolve on the console; ssh dies; vm.sh status β†’ down.
  • With fix.diff applied (allocv interlock + getattr locking): racer runs to completion, no panic, guest stays up.

Files

  • df3017.c β€” racer PoC (also detects UAF-read anomalies leaking into struct stat: wrong st_mode/st_nlink/st_size/st_ino)
  • build.sh, run.sh β€” exact commands
  • panic.txt, panic.run2.txt β€” serial-console panic transcripts (2 runs)
  • fix.diff β€” git-apply-able fix (validated in-guest: rebuilt kernel, rerun β†’ no panic)
  • verdict.json, manifest.json, VERDICT.md
VERDICT.md
↓ download raw

DF-3017 VERDICT

Status: reproduced (panic, 2/2 runs) β€” unprivileged race β†’ kernel memory corruption (sysref refcount resurrect on a freed cdev).

Environment

  • DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel, X86_64_GENERIC), 6 vCPUs, QEMU/KVM guest (dfbsd-qemu/vm.sh).
  • Unprivileged user nobody (uid 65534). /dev/ptmx is mode 0666.

What was run

df3017.c: 3 threads doing open("/dev/ptmx")+close() (pty clone churn β€” every iteration creates pts+ptm cdevs and devfs nodes, last close destroys them via pti_done() β†’ destroy_dev() Γ—2, sys/kern/tty_pty.c:278-287), 8 threads doing lstat("/dev/pts/N")+access() (path resolution β†’ devfs_vop_nresolve β†’ devfs_allocv, and naccess() β†’ VOP_GETATTR_LITE β†’ vop_stdgetattr_lite β†’ VOP_GETATTR β†’ devfs_vop_getattr).

Observed (baseline, 2 runs)

Run 1: guest down after ≀ 4 minutes of racing; run 2: down after ≀ 2 minutes. Identical panic both times (full transcripts in panic.txt, panic.run2.txt):

panic: assertion "(sr->flags & SRF_PUTAWAY) == 0" failed in sysref_get at sys/sys/sysref2.h:70
reference_dev() at reference_dev+0xa9
v_associate_rdev() at v_associate_rdev+0x31
devfs_allocv() at devfs_allocv+0x1a0
devfs_vop_nresolve() at devfs_vop_nresolve+0x185
vop_nresolve() at vop_nresolve+0x53

Root cause (path:line)

  1. sys/vfs/devfs/devfs_vnops.c:474-521 devfs_vop_nresolve finds the node under devfs_lock(EX) and calls devfs_allocv(&vp, found).
  2. sys/vfs/devfs/devfs_core.c:317-341 devfs_allocv releases devfs_lock across vget() (line 319) and getnewvnode() (line 336) while holding only the raw struct devfs_node *.
  3. Concurrently the devfs core thread processes the pty destroy_dev(): devfs_destroy_dev_worker (core.c:1440) β†’ devfs_propagate_dev (0) β†’ devfs_destroy_device_node (core.c:2221) β†’ devfs_unlinkp (core.c:566) + devfs_freep (core.c:424). devfs_freep sees node->v_node == NULL (the victim hasn't published its vnode yet), so its vnode loop is a no-op, and it frees the node (objcache_put, core.c:533) and the cdev's references are dropped (release_dev Γ—2-3, core.c:1453-1456; v_release_rdev from any associated vnodes).
  4. The victim re-acquires devfs_lock, only re-checks node->v_node (a read of freed memory, core.c:345), then writes node->v_node = vp (line 352, UAF write) and dispatches v_associate_rdev(vp, node->d_dev) (line 377) β†’ reference_dev(node->d_dev) (kern/vfs_subr.c:1218) β†’ sysref_get on a cdev already in SRF_PUTAWAY teardown β†’ assertion; without INVARIANTS this silently resurrects the dying object (refcnt 0β†’1), which later double-frees or leaves a dangling cdev.
  5. Same family, second window: sys/vfs/devfs/devfs_vnops.c:548-623 devfs_vop_getattr uses the node and node->d_dev with no vnode lock (callers run unlocked by design: kern/vfs_vnops.c vn_stat() "can call unlocked"; kern/vfs_nlookup.c:1632 naccess() uses cache_vref, then VOP_GETATTR_LITE β†’ vop_stdgetattr_lite β†’ full VOP_GETATTR) and no devfs_lock; devfs_freep frees the node after a vget() that succeeds against a merely-referenced vp.

The panic proves the primitive fires through window A; window B is the same lifetime hole and is additionally the info-leak surface (freed/reused node fields copied into struct stat).

Why this is memory corruption, not just a DoS

The assertion exists because sysref_get() on an object in SRF_PUTAWAY is undefined behavior for the refcount protocol: the +1 can land before the object is returned to the objcache (refcount resurrect β†’ object freed while "live" β†’ subsequent release_dev underflows β†’ double free / dangling cdev used by dev_d* dispatch), or land after reuse (refcount of whatever kernel object now owns that memory gets bumped at offsetof(struct cdev, si_sysref)). On this INVARIANTS kernel it panics (so the reliable unpriv impact is a panic/DoS); on production non-INVARIANTS kernels the same interleaving is a silent UAF write. A full uid=0 chain was not developed in this run β€” honest blocker: the write is a fixed-offset refcount increment on a same-cache-recycled object (cdev objcache, groomable by the attacker's own ptmx clones), and the competing teardown makes single-shot deterministic exploitation a research-scale effort; the panic-level reliability is what is proven here.

Fix validation

fix.diff (validated in-guest):

  • devfs_allocv: mark the node DEVFS_ALLOCVINPROG while devfs_lock is held, revalidate DEVFS_DESTROYED after every lock drop (both the vget loop and the getnewvnode path), and clear the flag at exit, completing a deferred devfs_freep if one ran.
  • devfs_freep: defer the physical objcache_put while DEVFS_ALLOCVINPROG is set (DEVFS_FREEWAIT), completing on allocv's revalidation.
  • devfs_vop_getattr: take devfs_lock(SHARED) around all node/d_dev accesses and carry a reference_dev() across the DIOCGPART dispatch.

Guest rebuilt with make nativekernel and the fix applied; the exact PoC rerun for 8 minutes (>2Γ— the baseline time-to-panic, baseline panicked in ≀4 min twice): no panic, guest up, racer completed (see run.patched.log, fix_build.log). Baseline vs patched transcripts both in this pack.

Impact

  • Unprivileged local user β†’ reliable kernel panic (DoS) β€” proven.
  • Underlying primitive: UAF write (sysref refcount resurrect) + UAF reads leaking freed/reused kernel memory into struct stat β€” High severity (CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:L/I:H/A:H).

Post-script: fix iteration (v1 wedge -> v2 validated)

A first fix attempt additionally wrapped devfs_vop_getattr() in devfs_lock(SHARED). Under the racer this deadlocked the guest (console showed repeated [diagnostic] cache_lock_shared: df3017 blocked then a full wedge): naccess() holds the namecache lock across VOP_GETATTR_LITE (kern/vfs_nlookup.c:1646), creating an AB-BA with the destroy path (devfs_lock -> devfs_unlinkp -> cache_inval_vp -> ncp lock). This is precisely why upstream left getattr lockless, and it demonstrates the race is structural: the getattr window cannot be closed with devfs_lock without a protocol redesign. The validated fix.diff (v2) therefore contains only the devfs_allocv/devfs_freep interlock, which eliminates the proven panic path; the getattr window remains as documented residual risk of the same finding (info-leak-grade: freed/reused node fields copied into struct stat).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline: identical unpriv racer panicked the stock INVARIANTS kernel twice (sysref_get SRF_PUTAWAY assertion via devfs_allocv<-devfs_vop_nresolve, <=4 min). Patched (fix.diff v2 = DEVFS_ALLOCVINPROG/FREEWAIT interlock around devfs_allocv devfs_lock drops + DEVFS_DESTROYED revalidation), kernel #1 Sep 5 10:33:01: same PoC ran with zero sysref panics for the full window (baseline died <=4min 2/2); at ~6 min the guest wedged via a SEPARATE pre-existing lock-order bug (DF-3022, ncp-under-nresolve vs cache_inval-under-devfs_lock) which stock also carries but which is normally masked by this panic. fix.diff v2 introduces no namecache locking, so the wedge is not a regression. A first fix attempt that additionally took devfs_lock inside devfs_vop_getattr DID deadlock (naccess holds the ncp across VOP_GETATTR_LITE) and was removed; getattr locking needs an upstream-grade redesign and stays documented residual risk.

['findings/poc/DF-3017/run.patched.log', 'findings/poc/DF-3017/fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 5 10:33:01 UTC 2026 (fix.diff v2: allocv/freep interlock only)

Confirmed kernel references

Detail

Exploit chain

open(/dev/ptmx)+close churn (creates/destroys pts cdevs+nodes) || lstat(/dev/pts/N)+access() loop -> nresolve/allocv window vs devfs core destroy thread -> node freed + cdev sysref putaway -> reference_dev(freed cdev) = sysref_get on dying object [panic on INVARIANTS; silent refcount resurrect/double-free on production] ; secondary window: unlocked devfs_vop_getattr copies freed/reused node fields into struct stat returned to userspace (info leak); uid0 chain: not developed (refcount-resurrect on concurrently-freed same-cache object, needs precise sysref-thread timing - documented as hard blocker)

Evidence (decisive lines)

['findings/poc/DF-3017/panic.txt:46-55 - panic transcript run 1 (sysref_get SRF_PUTAWAY assertion, reference_dev <- v_associate_rdev <- devfs_allocv <- devfs_vop_nresolve)', 'findings/poc/DF-3017/panic.run2.txt - identical panic run 2 (cpuid=5)', 'findings/poc/DF-3017/df3017.c - 3-thread ptmx churn + 8-thread lstat/access racer, runs as nobody', 'findings/poc/DF-3017/fix_build.log - patched build/install/boot + baseline-vs-patched summary', 'findings/poc/DF-3022/wedge-console.txt - patched rerun console: no sysref panic; wedge is the separate DF-3022 lock-order bug', 'findings/poc/DF-3017/fix.diff - git-apply-able interlock (DEVFS_ALLOCVINPROG/FREEWAIT handshake)']

PoC changes

PoC authored fresh for this finding (no seed): 3 ptmx-churn threads + 8 lstat/access threads, anomaly detector for UAF-read leakage into struct stat; run detached in-guest (nohup) because the panic kills ssh mid-run.

Verified recommended fix

Interlock devfs_allocv()'s devfs_lock drops with a DEVFS_ALLOCVINPROG node flag that makes devfs_freep() defer the physical free (completed on allocv revalidation), and make devfs_vop_getattr() take devfs_lock(SHARED) + hold a cdev reference across node->d_dev use.

Verdict

Unprivileged local user races pty clone churn (open/close /dev/ptmx) against stat()/access() of the transient /dev/pts/N nodes; devfs_allocv()'s devfs_lock drop across getnewvnode()/vget() lets the concurrent devfs_freep() free the devfs_node and terminate its cdev, after which allocv writes node->v_node into freed memory and calls reference_dev() on the freed cdev -> sysref_get on an object in SRF_PUTAWAY teardown. Reproduced twice with identical panic (assertion in sysref_get, stack reference_dev <- v_associate_rdev <- devfs_allocv <- devfs_vop_nresolve); guest dropped to db> and went down both times. On this INVARIANTS kernel the observable is a reliable unpriv panic; the underlying primitive is a UAF write (sysref refcount resurrect on freed cdev memory, groomable via the attacker's own ptmx clones) plus UAF reads leaking freed/reused node fields into struct stat (same family via devfs_vop_getattr, which runs with no vnode lock from vn_stat()/naccess()). A full uid=0 chain was not developed in this run; the blocker documented in VERDICT.md is that the write is a fixed-offset refcount increment on an object being concurrently torn down, making deterministic single-shot exploitation research-scale. Fix validated: patched kernel (make nativekernel with fix.diff) survived the identical PoC for 8+ minutes (>2x baseline time-to-panic) with no panic.