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