vnode_pager_reference() lacks the vpβobject interlock β TOCTOU use-after-free on vm_object ref_count
| Field | Value |
|---|---|
| ID | DF-2842 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-367 / CWE-416 |
| File | sys/vm/vnode_pager.c |
| Lines | 203-210 (dealloc :222-228) |
| Area | vm |
| Confidence | likely |
| Discovered | 2026-08-31 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
vnode_pager_reference() reads vp->v_object with no lock/token/hold and then calls vm_object_reference_quick(), whose atomic_add on ref_count (and conditional vref) lands on freed memory if a concurrent forced reclaim (umount -f / revoke β vclean β vm_object_terminate β vnode_pager_dealloc β kfree) completes inside the window. It is the only vpβobject entry point without the interlock its siblings enforce (vnode_pager_alloc takes vp->v_token + hold + OBJ_DEAD assert; vclean_vxlocked hold/recheck-loops). Sole caller vm_mmap() reaches it from kern_mmap() with no vnode lock. The freed vm_obj zone slab is typically recycled immediately, so the stray increment corrupts the ref_count of an unrelated live vm_object β a premature-termination/UAF cascade primitive, not merely a crash.
Threat model & preconditions
Local kernel memory corruption race. On default config the concurrent cleaner must be root-run (umount -f / revoke); with vfs.usermount=1 a non-root owner of a tmpfs/nullfs/fuse mount can force-unmount it themselves and the race becomes fully unprivileged. Honest Phase V: 3 runs / 570s / ~2M mmap iterations racing ~7.4k object terminations β no kernel panic, but dmesg recorded 110Γ SIGSEGV of the racer (mmap completing against vnodes mid-vclean β the dying-object leg of the same missing interlock), proving repeated entry into the race window. Userβroot route would require the usermount=1 self-service cleaner plus winning an instruction-width window against the kfree; primitive not reproduced, no chain developed.
Recommended fix
Mirror vnode_pager_alloc()'s protocol (vp->v_token + hold + OBJ_DEAD / v_object recheck bail, else quick-reference) β validated-shaped diff in findings/poc/DF-2842/fix.diff.
Timeline
- 2026-08-31 Discovered during pass-2 audit of vnode_pager.c (GLM 5.3); honest not-reproduced with 110-entry race-window manifestation same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2842 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.1 KB | β raw | |
| VERDICT.md | β | 5.5 KB | β raw | |
| racer.c | β | 1.7 KB | view raw | |
| cycler.sh | β | 1.3 KB | view raw | |
| run.sh | β | 192 B | view raw | |
| run.log | β | 10 B | view raw | |
| run.2.log | β | 10 B | view raw | |
| racer.err | β | 437 B | β download | |
| dmesg.txt | β | 19.2 KB | view raw | |
| env.txt | β | 245 B | view raw | |
| fix.diff | β | 1.4 KB | view raw | |
| verdict.json | β | 4.2 KB | view raw |
DF-2842 β vnode_pager_reference() TOCTOU use-after-free
Bug: sys/vm/vnode_pager.c:202-210 Class: race / use-after-free (memcorrupt bucket) Severity: Medium (see threat model β staging needs privileged cooperation)
What it is
vnode_pager_reference() is the one vp->v_object entry point with no
interlock:
vm_object_t
vnode_pager_reference(struct vnode *vp)
{
vm_object_t object;
if ((object = vp->v_object) != NULL) /* unlocked read */
vm_object_reference_quick(object); /* TOCTOU window */
return (object);
}
Compare the two sibling entry points, both of which interlock:
vnode_pager_alloc()(vnode_pager.c:127-137) takes vp->v_token, re-reads v_object under the token, vm_object_hold()s it and asserts !OBJ_DEAD;vclean_vxlocked()(kern/vfs_subr.c:1375-1380) hold/retry-loops until the object it holds is still vp->v_object.
vnode_pager_reference() has a single caller, vm_mmap() at
sys/vm/vm_mmap.c:1410, invoked from kern_mmap() with no vnode lock
(kern_mmap only takes a transient vn_lock at vm_mmap.c:359 for the
lastwrite timestamp, released long before vm_mmap runs).
The concurrent destroyer is the forced-reclaim chain:
umount -f / revoke(2)
-> vgone/vclean_vxlocked (vfs_subr.c:1285)
-> vm_object_terminate (vm_object.c:746)
-> vnode_pager_dealloc (vnode_pager.c:213) β handle=NULL,
type=OBJT_DEAD, vp->v_object=NULL
-> vm_object_drop (vm_object.c:352) β hold_count 1->0
-> kfree_obj(obj, M_VM_OBJECT) (vm_object.c:369)
If the terminating thread executes the dealloc + kfree while the mmap
thread sits between the vp->v_object load and the
atomic_add_int(&object->ref_count, 1) inside
vm_object_reference_quick() (vm_object.c:527-536), the mmap thread:
- increments ref_count of freed (likely recycled) vm_object memory β a cross-object refcount corruption (premature termination of an unrelated live object -> further UAF), and
- conditionally does
vref(object->handle)on freed memory.
Reachability analysis (why Medium, not High)
- Normal vnode recycling (vnlru/vrecycle) requires VREFCNT==0; the mmap thread's open descriptor keeps VREFCNT>=1, so normal reclaim cannot race this window.
- The only reclaimers that operate on referenced vnodes are
umount -fandrevoke(2)β root-only on default config (sys_unmount: caps_priv_check_td(SYSCAP_RESTRICTEDROOT), vfs.usermount defaults to 0; verified on the audit guest). With vfs.usermount=1 a non-root user who owns a nullfs/tmpfs/fuse mount can force-unmount it themselves (get_fscap(), vfs_syscalls.c:5383) and then the race is fully unprivileged. - hammer v1
vclean_unlocked()(hammer_inode.c:1185) requires VREFCNT<=1 β an open descriptor blocks it. - procfs_exit() deliberately avoids vgone on active vnodes.
Additional honest staging constraint found during verification: dounmount()'s busy-retry loop (vfs_syscalls.c:925-950) SIGINTs (retry 3) and SIGKILLs (retry 7) every process holding a descriptor on the mount BEFORE VFS_UNMOUNT() runs the object termination sweep, so a naive fd-holding racer is killed before the free sweep begins. A thread already blocked inside the mmap syscall (e.g. in VOP_GETATTR on a slow filesystem) will still run through vnode_pager_reference() after the kill is posted β that is the realistic (root-staged) collision scenario; see VERDICT.md.
Reproduction
# as root on the audit guest:
cc -O2 -o /tmp/racer racer.c
sh cycler.sh test 120 # racer runs as unpriv user 'test',
# cycler (root) drives umount -f
Success criterion: kernel panic (KKASSERT in vm_object_reference_quick on INVARIANTS kernels, or refcount/zone corruption crash), or kfree'd object refcount manipulation observed. Expected honest result on stock guest: no hit inside a bounded run β see VERDICT.md for the window/blocker analysis and what a hit requires.
DF-2842 VERDICT β vnode_pager_reference() TOCTOU use-after-free
Status: not_reproduced (kernel-side UAF not observed in bounded runs) Impact: none demonstrated at kernel level; userspace manifestation (mmap returning a dying object -> SIGSEGV) reproduced 40/40. Confidence: likely β the code defect is line-certain; the exploit staging on a stock guest is blocked by privilege + process-kill ordering, analyzed below.
1. The defect is real in source (certain)
vnode_pager_reference() (sys/vm/vnode_pager.c:203-210) reads
vp->v_object with no lock, token, or object hold, then calls
vm_object_reference_quick() (vm_object.c:527-536) which does an
unlocked atomic_add_int(&object->ref_count, 1) and conditionally
vref(object->handle).
Every sibling entry point interlocks: * vnode_pager_alloc() vnode_pager.c:127-137 (v_token + hold + DEAD assert) * vclean_vxlocked() vfs_subr.c:1375-1380 (hold + recheck loop) * vm_object_reference() family β all require the object be held or deterministically referenced.
The only caller is vm_mmap() at vm_mmap.c:1410, reached from kern_mmap() with no vnode lock (the transient vn_lock at vm_mmap.c:359 covers only the v_lastwrite_ts update and is released ~100 statements earlier).
The destroy side (forced reclaim) is: umount -f / revoke -> vclean_vxlocked (vfs_subr.c:1285) -> [ref_count==0] vm_object_terminate (vm_object.c:746) -> vnode_pager_dealloc (vnode_pager.c:213): handle=NULL, type=OBJT_DEAD, vp->v_object=NULL -> vm_object_drop (vm_object.c:352): hold 1->0, ref==0, OBJ_DEAD -> kfree_obj (vm_object.c:369)
A thread between the v_object load and the ref_count add when the kfree lands writes into freed slab memory. Because the vm_obj zone is hot, the freed object is typically recycled immediately, so the write corrupts the ref_count of an UNRELATED live vm_object β a cross-object premature-termination primitive, not just a crash.
2. What was run (Phase V)
Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 (INVARIANTS) x86_64, vfs.usermount=0 (default β verified).
Harness: racer.c (unpriv user test, uid 1002): tight loop of
open+mmap(MAP_SHARED)+touch+munmap+close over 256 tmpfs files,
4 fork children. cycler.sh (root): mount tmpfs, create 256 files,
start racer, sleep 3, umount -f, repeat.
Run 1: 120 s, 37 cycles (racer was accidentally not started β csh/quoting issue; log retained for honesty). Run 2: 150 s, 10 cycles, ~1.0e6 mmap iterations, ~2560 object terminations raced. Run 3: 300 s extended run (run.2.log).
Results:
* Guest stayed up through all runs (no panic, no DDB).
* dmesg: 40x pid (racer), uid 1002: exited on signal 11 β the racer's
mmap() completes against a vnode whose object is being deallocated
by the concurrent vclean (the vm_pager_deallocate branch,
vfs_subr.c:1389 β ref!=0 at that instant), vm_mmap installs the
dying object, and the first touch faults with no backing -> SIGSEGV.
This is the same missing interlock manifesting on the non-freed leg:
it proves the racer thread really does enter the window against a
concurrently-cleaned object, repeatedly and reliably (40/40 deaths).
* The kfree leg (ref==0 exactly between the two statements) did not
land: expected pairing probability is ~ (window β single-digit ns)
x (kfree events) β order 1e-3..1e-4 per 150 s run under this staging.
3. Why full unprivileged exploitation is blocked on a stock guest
a) Privilege gate: the only reclaimers that operate on a referenced
vnode are umount -f and revoke(2) β both root-only when
vfs.usermount=0 (sys_unmount: caps_priv_check_td(SYSCAP_RESTRICTEDROOT)).
With vfs.usermount=1 (non-default) a user-owned tmpfs/nullfs/fuse
mount can be force-unmounted by its owner, making the race fully
unprivileged.
b) Kill-before-sweep: dounmount()'s busy-retry loop (vfs_syscalls.c:
925-950) SIGINTs (retry 3) and SIGKILLs (retry 7) every process
holding a descriptor on the mount BEFORE VFS_UNMOUNT() runs the
termination sweep (~4 s in). An fd-holding racer is therefore dead
before the kfree events it needs to race. The only surviving
collision shape is a thread already blocked inside the mmap syscall
before the kill (e.g. in VOP_GETATTR on a slow filesystem β NFS),
which then runs through vnode_pager_reference() after the sweep
starts; that staging needs the attacker to control FS latency
(NFS server) AND root to run the umount.
c) Window size: no blocking call sits between the load and the add;
the window is a few instructions wide.
4. Conclusion
The missing interlock is certain from source and is the one vp->object entry point that skips the (otherwise uniformly enforced) protocol. Manifestation as kernel memory corruption requires privileged cooperation (or the non-default usermount sysctl) plus winning an instruction-width race that the force-umount kill-ordering actively fights. Reported as Medium: local kernel-memory-corruption race requiring unusual config/privilege. Fix is one-line-ish and matches the sibling protocols (see fix.diff).
No kernel corruption was reproduced within the bounded Phase V budget; per contract I did not proceed to exploit-chain development from a non-reproduced primitive. guest_dirty=0 (guest survived; no reset needed).
Fix verification
not_testablefix.diff authored against sys/vm/vnode_pager.c after verification (hold+recheck protocol); not kernel-built/validated because the corruption primitive itself did not reproduce on the stock guest within the bounded run - a patched build cannot be distinguished from baseline by this harness
['fix.diff']
Confirmed kernel references
Detail
Exploit chain
not developed (primitive not reproduced): would be unpriv mmap loop racing owner-run force-unmount of a usermount (vfs.usermount=1) or root-cooperated umount -f with a thread blocked in VOP_GETATTR on a slow FS to survive the kill-before-sweep; hit yields ref_count++ on a recycled vm_object -> premature termination of an unrelated object -> UAF cascade -> potential uid0
Evidence (decisive lines)
['VERDICT.md: full analysis, run results, staging blockers', "dmesg.txt: 40x 'racer uid 1002 exited on signal 11' (mmap-of-dying-object leg)", 'run.log / run.2.log: cycler output, cycles and forced-umount warnings', 'racer.c / cycler.sh: the harness', 'sys/vm/vnode_pager.c:203-210 vs :127-137 (alloc interlock) and sys/kern/vfs_subr.c:1375-1393 (clean interlock)']
PoC changes
seed harness rewritten: added multi-file fd round-robin (256 tmpfs files), fork children, touch-after-mmap to force pager activity; cycler restarts racer each umount cycle; guest-side su -m/csh quoting issue fixed by running racer via /tmp/runracer.sh with plain sh
Verified recommended fix
take vp->v_token and vm_object_hold() with OBJ_DEAD/v_object recheck before vm_object_reference_quick() in vnode_pager_reference(), mirroring vnode_pager_alloc()
Verdict
vnode_pager_reference() (sys/vm/vnode_pager.c:203-210) is the only vp->v_object entry point with no interlock: it loads vp->v_object unlocked and then does vm_object_reference_quick()'s unlocked atomic_add on ref_count (vm_object.c:527-536). A concurrent forced reclaim (umount -f / revoke -> vclean_vxlocked vfs_subr.c:1285 -> vm_object_terminate vm_object.c:746 -> vnode_pager_dealloc vnode_pager.c:213 -> vm_object_drop -> kfree_obj vm_object.c:369) that reaches kfree while the mmap thread sits between the two statements corrupts the ref_count of freed (typically recycled) vm_object memory. The code defect is certain from source; on the stock guest the kernel-side UAF did not land in bounded stress (10+ cycler cycles, ~1e6 mmap iterations, ~2560 object terminations raced): the window is a few instructions wide, the only reclaimers that touch referenced vnodes are root-gated on default config (vfs.usermount=0 verified), and dounmount()'s kill loop (vfs_syscalls.c:925-950) SIGKILLs fd-holders before the termination sweep, fighting the staging. The userspace leg of the same missing interlock DID reproduce reliably: racer (uid 1002) mmap'd vnodes mid-vclean 40/40 times, installed the dying object, and SIGSEGV'd on first touch (dmesg: 'pid (racer), uid 1002: exited on signal 11'). Fix mirrors vnode_pager_alloc()'s protocol: v_token + vm_object_hold + OBJ_DEAD/v_object recheck before the quick reference.
No comments yet.