# VERDICT — DF-2938

**Status: reproduced** (stock INVARIANTS kernel, unprivileged trigger
uid=65534).  **Impact: kernel heap use-after-free** — demonstrated both
as a fault-time indirect call through the freed `struct cdev` (with
cross-object type confusion: the stale mapping was served a page
belonging to the device that took over the chunk) and as a
destructor-time read+write through the freed chunk producing
`panic: assertion "dev->si_object" failed in old_dev_pager_dtor at
device_pager.c:343`.  Fix authored, built, and validated on the guest.

## Root cause (path:line)

* `sys/vm/device_pager.c:124` — `cdev_pager_allocate()` runs the ctor
  and then stores `object->handle = handle` (:138) /
  `object->un_pager.devp.dev = handle` (:140) for OBJT_DEVICE objects
  **without `reference_dev()`**.
* `sys/vm/device_pager.c:312-335` — `old_dev_pager_ctor` only
  validates the mapping range; it takes no cdev reference (the
  FreeBSD lineage of this function called `dev_ref(dev)`).
* `sys/vm/device_pager.c:337-346` — `old_dev_pager_dtor` clears
  `dev->si_object` (read + write of the cdev) but never
  `release_dev()`s it.
* `sys/vm/vm_mmap.c:1367-1374` — device mmap stores only the vm_object
  in the vm_map entry (`vm_map_find(map, object, NULL, ...)` :1478);
  nothing else pins the cdev for the lifetime of the mapping.
* Freeing side (all examples drop the last *counted* cdev ref while a
  mapping survives): `destroy_dev()` from driver detach/kldunload;
  `snd_clone_gc` on last close of a 0666 `/dev/dsp*.*` clone
  (`sys/dev/sound/clone.c:431`, device made 0666 at
  `sys/dev/sound/pcm/dsp.c:2505-2508`, old pager allocated at
  `dsp.c:2357`); USB unplug of a 0666 uvc video node
  (`sys/bus/u4b/uvc/uvc_v4l2.c:763`, device made 0666 at :783-784).

UAF sinks: `old_dev_pager_fault` :360 `dev_dmmap(dev, ...)` →
`sys/kern/kern_device.c:276` `dev->si_ops->d_mmap(&ap)` (indirect call
through freed memory); `old_dev_pager_dtor` :343-344 (KKASSERT read +
NULL store into freed memory).

## How it was reproduced

1. `devuaf.ko` (evidence-pack module) creates `/dev/devuaf` 0666 with a
   classic `d_mmap` (no `d_mmap_single`), so `vm_mmap` takes the
   `dev_pager_alloc()` = old-pager path, exactly like syscons/agp/vga/
   bktr/uvc/fwdev/nvmm/dsp.
2. `trig` (run as `nobody`, uid 65534) opens it, mmaps 2 pages
   MAP_SHARED, faults page 0 while the device is alive ('A' — normal
   old-pager fake-page insertion through the *live* cdev).
3. Root side performs the "driver teardown": `destroy_dev()`
   (`IOCTL_UAF_SWAP`), then immediately `make_dev()`s a replacement
   cdev pinned (via `usched_set`) to a chosen cpu so the freed chunk's
   fate is observable.
4. `trig` faults page 1: `vm_fault → dev_pager_getpage →
   old_dev_pager_fault → dev_dmmap(object->handle = the FREED cdev)`.

Observed (stock kernel, serial console + `run.log`):

* cpu-0 round: `TRIG: page1[0] = 'B'` and
  `STATUS old_chunk=0xfffff80116a0ef80 ... last_a_dev=0xfffff80116a0ef80`
  — the kernel handed the *destroyed* cdev pointer into `d_mmap`; the
  mapping of a destroyed device now aliases a page belonging to the
  replacement device generation (type confusion through freed memory).
  `munmap` silently wrote NULL into the freed chunk (dtor UAF write).
* cpu-1 round: replacement `make_dev()` **re-used the freed chunk**
  (`SWAP old=0xfffff8004f2a7c80 new=0xfffff8004f2a7c80 reused=1`), and
  the unprivileged `munmap` panicked exactly as predicted:
  `panic: assertion "dev->si_object" failed in old_dev_pager_dtor at
  /usr/src/sys/vm/device_pager.c:343` with backtrace
  `old_dev_pager_dtor ← dev_pager_dealloc ← vm_object_terminate ←
  vm_object_deallocate ← vm_map_entry_dispose` (`panic.txt`).

## Exploit chain / primitive characterization

Primitive: (a) attacker-timed **indirect call** —
`dev->si_ops->d_mmap(&ap)` where `dev` is a freed `struct cdev`
(~0x120 bytes, `devfs_dev_cache` objcache backed by
`kmalloc(M_DEVFS)`) — the call happens on any later fault of the
stale mapping, arbitrarily long after the teardown; and (b) an
**unconditional NULL write** at `offsetof(struct cdev, si_object)`
plus a NULL check (KKASSERT on INVARIANTS builds) at munmap time.

Full uid0 chain on this guest (no KASLR/SMAP/SMEP): reclaim the freed
chunk with attacker-controlled bytes, point the stale `si_ops` at a
user-mapped fake `dev_ops`, and let the fault-time `d_mmap` call land
in ring-0 shellcode.  On the stock guest the chunk demonstrably
round-trips through the per-cpu objcache magazine and was reclaimed by
the next `make_dev()` (`reused=1` above); converting that into
*content* control requires draining the objcache into the general
kmalloc zone (memory-pressure cycle) and spraying same-size
user-bytes allocations (e.g. SysV msg segments) — feasible but not
completed within this run's time budget.  Demonstrated end-to-end:
freed-chunk indirect call + type confusion + deterministic INVARIANTS
panic from an unprivileged process.  A driver with a `d_mmap` whose
ops table lives in *unloadable module memory* additionally turns the
stale-ops read into a jump through freed/reused module memory on any
kernel build.

Real-world unprivileged vectors (no PoC module needed): `/dev/dsp*.*`
0666 clones (old pager via `dsp_mmap_single` → `dev_pager_alloc`,
destroyed by the clone GC when the last fd closes — the unprivileged
user controls *both* sides) and uvc `/dev/video*` 0666 on unplug; any
other old-pager device via privileged teardown (kldunload) with the
fault/munmap side unprivileged.

## Fix validation

`fix.diff` restores the missing refcount pair: `reference_dev(dev)`
when `cdev_pager_allocate()` creates the OBJT_DEVICE object for the
old ops (device_pager.c:146-156) and `release_dev(dev)` in
`old_dev_pager_dtor` (:351-359).  Applied to the guest's `/usr/src`,
`make -j6 nativekernel` + `installkernel` (kernel #1,
Thu Sep 3 18:35:46 UTC 2026), rebooted, exact same PoC re-run on all
six cpus:

* baseline (stock #0): chunk re-use observed, unprivileged munmap →
  panic at device_pager.c:343.
* patched (#1): 6/6 rounds `reused=0` (the destroyed cdev cannot be
  freed — it is pinned), all munmaps clean, guest stays up
  (`fixed.0.log` … `fixed.5.log`).  The post-teardown fault is now
  served through the still-valid, pinned cdev — no freed-memory
  access at all.

fix_status: **fixed**.

## Notes

* The PoC's first iteration used `lwkt_migratecpu()` inside the ioctl
  which itself panicked the dfly scheduler (usched_dfly.c:453) — a PoC
  technique bug, replaced with `usched_set(USCHED_SET_CPU)`; not
  related to the finding.
* The file-level companion race DF-2939 (dtor runs before the object is
  unlisted → `si_object` clobber → the same :343 KKASSERT) is a
  separate root cause; the F1 fix does not address it.
