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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2842 Β· 12 files
FileTypeDescriptionSize
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:

  1. 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
  2. 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 -f and revoke(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.

VERDICT.md
↓ download raw

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_testable
baseline no→ patch + rebuild →patched clean

fix.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']
↓ fix.diffper-fix-DF-2842

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.