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

OBJT_DEVICE pager objects never reference the cdev: device teardown while a mapping persists leaves a dangling object->handle β†’ cdev use-after-free in old_dev_pager_fault (indirect call via freed si_ops) and old_dev_pager_dtor (assert + NULL write)

Field Value
ID DF-2938
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-416 Use After Free
File sys/vm/device_pager.c
Lines 124-149, 211, 337-346, 360 (dispatch: kern_device.c:276)
Area vm + kern/dev
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

cdev_pager_allocate() stores the cdev as OBJT_DEVICE object->handle without reference_dev(), and old_dev_pager_dtor() never release_dev()s it β€” the dev_ref/dev_rel pair the FreeBSD lineage had was dropped in the DragonFly port. A device mapping pins only the vm_object, so when the cdev's last counted sysref drops β€” driver detach/kldunload destroy_dev(), the sound clone GC destroy_dev() on last close of a 0666 /dev/dsp. clone, or USB unplug of a 0666 /dev/video* β€” the cdev is freed while the mapping's pager object still points at it. Verified on the stock INVARIANTS guest with an unprivileged uid=65534 trigger: the next fault on the mapping runs dev_dmmap(dev=freed) β†’ dev->si_ops->d_mmap through freed heap (demonstrated last_a_dev == old_chunk, with the stale mapping served a page owned by the replacement device that took over the chunk β€” cross-object type confusion), and the unprivileged munmap drove old_dev_pager_dtor into the freed-and-reused chunk producing panic: assertion "dev->si_object" failed in old_dev_pager_dtor at device_pager.c:343. Attack surface: any machine exposing an old-pager (d_mmap, no d_dmmap_single) device to unprivileged users β€” sound (0666 dsp clones; the user controls both the mapping and the close that triggers the GC destroy), webcams (0666 uvc video nodes, USB unplug), and every driver with a d_mmap (syscons, agp, vga, bktr, fwdev, nvmm) via privileged teardown with the fault/munmap side unprivileged. Primitive: attacker-timed indirect call through freed ~0x120-byte heap plus an unconditional NULL write at offsetof(struct cdev, si_object) at munmap; on this guest (no SMAP/SMEP/KASLR) a controlled reclaim converts the stale si_ops into a ring-0 call β€” feasible chain documented, full uid=0 not completed within this run; deterministic kernel panic demonstrated instead.

Proof of contest

VERIFIED (findings/poc/DF-2938/): devuaf.ko creates /dev/devuaf 0666 with a classic d_mmap; trig.c as nobody mmaps 2 pages, faults p0, root-side destroy_dev + make_dev replacement, fault p1 β†’ stale cdev into d_mmap + cross-object page; munmap β†’ panic at device_pager.c:343 from the unprivileged process when the chunk was reused. Fix validated (reference_dev at object creation / release_dev in dtor, kernel #1 in-guest): 6/6 cpus reused=0, clean munmaps, no panic.

Validated fix.diff in findings/poc/DF-2938/.

Timeline

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2938 Β· 19 files
FileTypeDescriptionSize
devuaf.c β€” 4.7 KB view raw
trig.c β€” 2.3 KB view raw
ctl.c β€” 1.7 KB view raw
setup.sh β€” 453 B view raw
orch.sh β€” 630 B view raw
run.log β€” 427 B view raw
run.baseline-cpu1.log β€” 1.4 KB view raw
panic.txt β€” 939 B view raw
fixed.0.log β€” 427 B view raw
fixed.1.log β€” 427 B view raw
fixed.2.log β€” 428 B view raw
fixed.3.log β€” 428 B view raw
fixed.4.log β€” 428 B view raw
fixed.5.log β€” 428 B view raw
env.txt β€” 234 B view raw
fix.diff β€” 815 B view raw
VERDICT.md β€” 6.7 KB ↓ raw
manifest.json β€” 1.8 KB view raw
verdict.json β€” 5.7 KB view raw
VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (reference_dev at device_pager.c:156 / release_dev at :359) applied in-guest, kernel rebuilt and booted; the exact baseline PoC no longer frees the cdev while the mapping exists: 6/6 cpu rounds reused=0 (chunk cannot be re-used), post-teardown faults served through the pinned (valid) cdev, all unprivileged munmaps complete cleanly, no panic. Baseline on stock #0 had reused=1 + panic at device_pager.c:343.

fixed.0.log fixed.1.log fixed.2.log fixed.3.log fixed.4.log fixed.5.log (patched kernel runs); run.log + panic.txt (baseline); fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Thu Sep 3 18:35:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv: open+mmap old-pager device (2 pages) -> fault p0 -> root-side teardown (kldunload / clone GC / USB unplug; PoC: module destroy_dev) -> cdev freed (sysref -> objcache) -> unpriv fault p1: vm_fault -> dev_pager_getpage -> old_dev_pager_fault -> dev_dmmap(freed dev) -> dev->si_ops->d_mmap indirect call through freed heap; unpriv munmap: vm_map_entry_dispose -> vm_object_deallocate -> dev_pager_dealloc -> old_dev_pager_dtor -> KKASSERT read + si_object=NULL write through freed chunk (panic when chunk reused). uid0 continuation (not completed): reclaim chunk with controlled bytes (objcache->kmalloc drain under memory pressure + same-size user-byte spray), point stale si_ops at user-mapped fake dev_ops (guest has no SMAP), fault-time d_mmap call executes ring-0 payload; no KASLR/SMEP on this guest.

Evidence (decisive lines)

['run.log: STATUS old_chunk=0xfffff80116a0ef80 last_a_dev=0xfffff80116a0ef80 VERDICT: d_mmap last served by THE FREED CDEV; page0=A page1=B uid=65534', 'run.baseline-cpu1.log + panic.txt: devuaf: SWAP old=0xfffff8004f2a7c80 new=0xfffff8004f2a7c80 reused=1 (cpu 1); panic: assertion "dev->si_object" failed in old_dev_pager_dtor at /usr/src/sys/vm/device_pager.c:343; backtrace old_dev_pager_dtor <- dev_pager_dealloc <- vm_object_terminate <- vm_object_deallocate <- vm_map_entry_dispose', 'fixed.[0-5].log: patched kernel #1, all cpus reused=0, trig_exit=0, guest up', 'VERDICT.md: full narrative + fix validation', 'fix.diff: reference_dev/release_dev pair']

PoC changes

Rewrote the seed concept into a loadable-module PoC: world-mappable d_mmap device + root-side destroy_dev ioctl standing in for driver teardown/dsp clone GC/USB unplug, unprivileged trig.c doing mmap/fault/munmap. First iteration used lwkt_migratecpu() inside the ioctl which independently panicked the dfly scheduler (usched_dfly.c:453) -- replaced with usched_set(USCHED_SET_CPU) user-side binding. Removed sys/ioctl.h include (kernel uses sys/ioccom.h), added sys/devfs.h for devfs_config().

Verified recommended fix

Take reference_dev(dev) when cdev_pager_allocate creates the OBJT_DEVICE object for old_dev_pager_ops and release_dev(dev) in old_dev_pager_dtor, so the pager object pins the cdev for the lifetime of the mapping

Verdict

Reproduced on the stock INVARIANTS guest with an unprivileged (uid=65534) trigger: an OBJT_DEVICE mapping of a device that is later destroy_dev()'d leaves the pager object holding a dangling cdev pointer. The next fault on the mapping calls dev->si_ops->d_mmap through the FREED cdev (demonstrated: last_a_dev == old_chunk, the stale mapping was served a page belonging to the replacement device object that took over the chunk -- cross-object type confusion), and the unprivileged munmap drove old_dev_pager_dtor into the freed-and-reused chunk producing panic: assertion "dev->si_object" failed in old_dev_pager_dtor at device_pager.c:343. Unprivileged real-world vectors: /dev/dsp. 0666 clones (old pager via dsp_mmap_single->dev_pager_alloc, destroyed by the clone GC on last close) and uvc /dev/video* 0666 on unplug; all other old-pager devices via privileged teardown with unprivileged fault/munmap. fix.diff (reference_dev at object creation + release_dev in old_dev_pager_dtor) was built as kernel #1 and validated: 6/6 cpus reused=0, clean munmaps, no panic.