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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2938 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 β 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 storesobject->handle = handle(:138) /object->un_pager.devp.dev = handle(:140) for OBJT_DEVICE objects withoutreference_dev().sys/vm/device_pager.c:312-335βold_dev_pager_ctoronly validates the mapping range; it takes no cdev reference (the FreeBSD lineage of this function calleddev_ref(dev)).sys/vm/device_pager.c:337-346βold_dev_pager_dtorclearsdev->si_object(read + write of the cdev) but neverrelease_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_gcon last close of a 0666/dev/dsp*.*clone (sys/dev/sound/clone.c:431, device made 0666 atsys/dev/sound/pcm/dsp.c:2505-2508, old pager allocated atdsp.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
devuaf.ko(evidence-pack module) creates/dev/devuaf0666 with a classicd_mmap(nod_mmap_single), sovm_mmaptakes thedev_pager_alloc()= old-pager path, exactly like syscons/agp/vga/ bktr/uvc/fwdev/nvmm/dsp.trig(run asnobody, 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).- Root side performs the "driver teardown":
destroy_dev()(IOCTL_UAF_SWAP), then immediatelymake_dev()s a replacement cdev pinned (viausched_set) to a chosen cpu so the freed chunk's fate is observable. trigfaults 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'andSTATUS old_chunk=0xfffff80116a0ef80 ... last_a_dev=0xfffff80116a0ef80β the kernel handed the destroyed cdev pointer intod_mmap; the mapping of a destroyed device now aliases a page belonging to the replacement device generation (type confusion through freed memory).munmapsilently 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 unprivilegedmunmappanicked exactly as predicted:panic: assertion "dev->si_object" failed in old_dev_pager_dtor at /usr/src/sys/vm/device_pager.c:343with backtraceold_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 withusched_set(USCHED_SET_CPU); not related to the finding. - The file-level companion race DF-2939 (dtor runs before the object is
unlisted β
si_objectclobber β the same :343 KKASSERT) is a separate root cause; the F1 fix does not address it.
Fix verification
fixedfix.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
Confirmed kernel references
- sys/vm/device_pager.c:124
- sys/vm/device_pager.c:138-141
- sys/vm/device_pager.c:146-149
- sys/vm/device_pager.c:211
- sys/vm/device_pager.c:312-335
- sys/vm/device_pager.c:337-346
- sys/vm/device_pager.c:348-361
- sys/kern/kern_device.c:262-283
- sys/vm/vm_mmap.c:1363-1381
- sys/vm/vm_mmap.c:1478
- sys/dev/sound/pcm/dsp.c:2357
- sys/dev/sound/pcm/dsp.c:2505-2508
- sys/dev/sound/clone.c:420-434
- sys/bus/u4b/uvc/uvc_v4l2.c:763
- sys/bus/u4b/uvc/uvc_v4l2.c:783-784
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.
No comments yet.