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)
PoC verification
Evidence pack
findings/poc/DF-3017 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 fromdevfs_vop_nresolve()(devfs_vnops.c:504) with a raw node pointer and dropsdevfs_lockacrossgetnewvnode()/vget()(devfs_core.c:319/336). While the lock is dropped, the devfs core thread can rundestroy_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_allocvthen writesnode->v_node = vpinto freed memory and callsv_associate_rdev(vp, node->d_dev)βreference_dev()on the freed/terminating cdev. -
Window B (same family) β
devfs_vop_getattr()(devfs_vnops.c:548) dereferencesDEVFS_NODE(vp)andnode->d_devwith no vnode lock (its major callers deliberately run unlocked:vn_stat()"vp already has a ref and is validated, can call unlocked", andnaccess()viaVOP_GETATTR_LITEβvop_stdgetattr_liteβVOP_GETATTR) and nodevfs_lock.devfs_freep()frees the node after avget()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 iterationptyclone()creates pts+ptm cdevs and devfs nodes under/dev/pts; last close runspti_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) andVOP_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_getwithreference_dev β v_associate_rdev β devfs_allocv β devfs_vop_nresolveon 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 intostruct stat: wrongst_mode/st_nlink/st_size/st_ino)build.sh,run.shβ exact commandspanic.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
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)
sys/vfs/devfs/devfs_vnops.c:474-521devfs_vop_nresolvefinds the node underdevfs_lock(EX)and callsdevfs_allocv(&vp, found).sys/vfs/devfs/devfs_core.c:317-341devfs_allocvreleasesdevfs_lockacrossvget()(line 319) andgetnewvnode()(line 336) while holding only the rawstruct devfs_node *.- 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_freepseesnode->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_rdevfrom any associated vnodes). - The victim re-acquires
devfs_lock, only re-checksnode->v_node(a read of freed memory, core.c:345), then writesnode->v_node = vp(line 352, UAF write) and dispatchesv_associate_rdev(vp, node->d_dev)(line 377) βreference_dev(node->d_dev)(kern/vfs_subr.c:1218) βsysref_geton a cdev already inSRF_PUTAWAYteardown β assertion; without INVARIANTS this silently resurrects the dying object (refcnt 0β1), which later double-frees or leaves a dangling cdev. - Same family, second window:
sys/vfs/devfs/devfs_vnops.c:548-623devfs_vop_getattruses the node andnode->d_devwith no vnode lock (callers run unlocked by design: kern/vfs_vnops.cvn_stat()"can call unlocked"; kern/vfs_nlookup.c:1632naccess()usescache_vref, thenVOP_GETATTR_LITEβvop_stdgetattr_liteβ fullVOP_GETATTR) and nodevfs_lock;devfs_freepfrees the node after avget()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 nodeDEVFS_ALLOCVINPROGwhiledevfs_lockis held, revalidateDEVFS_DESTROYEDafter every lock drop (both thevgetloop and thegetnewvnodepath), and clear the flag at exit, completing a deferreddevfs_freepif one ran.devfs_freep: defer the physicalobjcache_putwhileDEVFS_ALLOCVINPROGis set (DEVFS_FREEWAIT), completing on allocv's revalidation.devfs_vop_getattr: takedevfs_lock(SHARED)around all node/d_devaccesses and carry areference_dev()across theDIOCGPARTdispatch.
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
fixedBaseline: 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']
Confirmed kernel references
- sys/vfs/devfs/devfs_vnops.c:474-521
- sys/vfs/devfs/devfs_vnops.c:548-623
- sys/vfs/devfs/devfs_core.c:303-390
- sys/vfs/devfs/devfs_core.c:424-535
- sys/vfs/devfs/devfs_core.c:1440-1459
- sys/kern/vfs_subr.c:1211-1223
- sys/kern/tty_pty.c:278-287
- sys/kern/vfs_vnops.c:833-840
- sys/kern/vfs_nlookup.c:1632-1646
- sys/kern/vfs_default.c:1228-1234
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.
No comments yet.