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

vm_object_page_clean() partial-range clean is a silent no-op β€” msync(MS_SYNC) performs no writeback for any partial range on UFS and for EVERY range on hammer2 (default fs)

Summary

PG_CLEANCHK is set only by pass1, and pass1 runs only when the requested range exactly covers the whole object (start==0 && end==object->size-1); pass2 and collect_flush skip every page without the flag and no in-tree caller passes OBJPC_IGNORE_CLEANCHK β€” any partial-range vm_object_page_clean() flushes nothing yet returns success. msync() reaches the VM only through this function (no VOP_FSYNC fallback). On hammer2, nvnode_pager_setsize sizes vnode objects beyond EOF to cover the straddling buffer (a 4096-byte file has object->size==16), so wholescan is unsatisfiable by any in-file range: EVERY msync(MS_SYNC) on the default filesystem is a silent no-op. Proven 3/3 on hammer2 and 2/2 on UFS on the stock kernel (vnodeout/vnodepgsout deltas 0/0 for dead arms; only the block-rounded-beyond-EOF range flushes); the NFS server's partial-range clean is also a no-op. Durability left to the ~30s syncer β€” crash-window silent data loss, POSIX msync contract broken. One-line run-pass1-over-requested-range fix validated on rebuilt guest kernel: all arms flush.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5829 Β· 16 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw
msync_poc.c β€” 6.7 KB view raw
build.sh β€” 92 B view raw
run.sh β€” 118 B view raw
build.log β€” 565 B view raw
run.log β€” 2.1 KB view raw
run.2.log β€” 2.1 KB view raw
run.h2.log β€” 3.3 KB view raw
run.3-ufs.log β€” 1.4 KB view raw
run.patched-h2.log β€” 3.0 KB view raw
fix.diff β€” 1.3 KB view raw
fix_build.log β€” 5.7 MB ↓ download
env.txt β€” 381 B view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.4 KB view raw

DF-5829 β€” vm_object_page_clean() partial-range clean is a silent no-op

What

vm_object_page_clean() marks the pages it is about to clean with PG_CLEANCHK only in pass1, and pass1 runs only when the requested range covers the entire object (wholescan). pass2 and vm_object_page_collect_flush() both skip any page without PG_CLEANCHK, and no in-tree caller passes OBJPC_IGNORE_CLEANCHK. Therefore every partial-range clean flushes nothing and returns success.

msync(MS_SYNC) maps 1:1 onto vm_object_page_clean() (sys/vm/vm_map.c:3148) and has no other flush mechanism, so:

  • on UFS (object sized exactly to the file): msync() of a sub-range, or of a whole mapping that is smaller than the file, silently does nothing;
  • on hammer2 (the default root filesystem β€” objects are sized beyond EOF by nvnode_pager_setsize(), sys/kern/vfs_vm.c:465, to cover the buffer straddling EOF): wholescan can never be true for any range the user can express on the file, so every msync(MS_SYNC) of a hammer2 file is a silent no-op (proven: the only range that flushes is one that extends beyond EOF to the block-rounded object->size β€” arm F).

Observable: vm.stats.vm.v_vnodeout / v_vnodepgsout are incremented only in vnode_pager_putpages() (sys/vm/vnode_pager.c:764), which is reached only from the pass2 flush path. A real MS_SYNC flush must bump them.

Build

cc -O2 -Wall -o msync_poc msync_poc.c        (on the guest)

Run (unprivileged)

./msync_poc 3 [dir]        # dir defaults to the hammer2 home dir;
                           # use a UFS dir (e.g. under /boot) for the
                           # UFS arms (needs root to place files there)

Arms (child dirties exactly one 4 KiB page through MAP_SHARED, then msyncs):

arm file / mapping msync range expected (vulnerable)
A 8p file, mmap 8p, dirty p6 [5p,7p) partial delta 0 (no flush)
B 8p file, mmap 8p, dirty p6 [0p,8p) whole file UFS: flush; H2: 0
C 8p file, mmap 4p, dirty p1 [0p,4p) whole mapping delta 0 (no flush)
D 1p file, mmap 1p, dirty p0 [0p,1p) whole file UFS: flush; H2: 0
E 1p file, mmap 4p, dirty p0 [0p,4p) beyond EOF delta 0
F 1p file, mmap 16p, dirty p0 [0p,16p) beyond EOF flushes on H2

Arm F flushing on hammer2 while B/D do not is the mechanistic proof that the gate is exactly start==0 && end_pindex==object->size-1 with object->size block-rounded beyond EOF.

Expected output (vulnerable kernel)

see run.h2.log (hammer2) and run.3-ufs.log (UFS): partial ranges A and C (and on H2 also B and D) show vnodeout delta = 0, controls flush.

Fix

fix.diff β€” always run the PG_CLEANCHK marking pass over the requested range; keep the OBJ_WRITEABLE/OBJ_MIGHTBEDIRTY clearing gated on wholescan. With the patched kernel arm A flushes (see VERDICT.md).

VERDICT.md
↓ download raw

DF-5829 β€” VERDICT

Status: reproduced (fix validated) β€” impact dos-class data-integrity violation (silent loss of msync(MS_SYNC) durability), not memory corruption. No escalation chain applies to this class; the deliverable is the demonstrated semantic violation on the stock kernel and its disappearance under the one-line fix.

Root cause (all citations into the audited tree)

  1. sys/vm/vm_object.c:981 β€” wholescan = (start == 0 && info.end_pindex == object->size - 1); β€” pass1 is gated on the range covering the entire object from page 0.
  2. sys/vm/vm_object.c:991 β€” if (wholescan) { … pass1 … } β€” pass1 is the only code that ever sets PG_CLEANCHK (:1039).
  3. sys/vm/vm_object.c:1074-1075 β€” pass2 skips every page without PG_CLEANCHK: if ((p->flags & PG_CLEANCHK) == 0) goto done;
  4. sys/vm/vm_object.c:1175-1179 β€” vm_object_page_collect_flush() also stops at the first page without PG_CLEANCHK; no in-tree caller passes OBJPC_IGNORE_CLEANCHK (verified by grep over sys/).

β‡’ any partial-range vm_object_page_clean() flushes nothing.

  1. sys/vm/vm_map.c:3148-3152 β€” msync() reaches the VM only through vm_object_page_clean(object, OFF_TO_IDX(offset), OFF_TO_IDX(offset + size + PAGE_MASK), flags); there is no VOP_FSYNC fallback in vm_map_clean().
  2. sys/kern/vfs_vm.c:465-467 (nvnode_pager_setsize) β€” filesystems that use nvextendbuf/nvtruncbuf (hammer2; buffer size 64 KiB) size object->size beyond EOF to cover the straddling buffer. For a 4096-byte hammer2 file, object->size == 16 pages β€” so no msync range expressible on the file can ever satisfy the wholescan condition.

How it was verified (guest `DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2

06:02:54 UTC 2026, stock kernel; root fs hammer2,/boot` UFS)

Observable: vm.stats.vm.v_vnodeout/v_vnodepgsout increment only in vnode_pager_putpages() (sys/vm/vnode_pager.c:764), reachable only from the pass2 flush. Rig: msync_poc.c (unprivileged, uid 1001): dirty exactly one page through MAP_SHARED, msync the given window, sample the counters around the syscall, SIGKILL the child before teardown.

Baseline (stock kernel)

hammer2 (run.h2.log, 3 reps, perfectly deterministic):

A partial-range  msync[5p,7p)   vnodeout delta = 0   vnodepgsout delta = 0
B whole-file ctrl msync[0p,8p)  vnodeout delta = 0   vnodepgsout delta = 0
C whole-mapping  msync[0p,4p)   vnodeout delta = 0   vnodepgsout delta = 0
D whole 1p file  msync[0p,1p)   vnodeout delta = 0   vnodepgsout delta = 0
E 1p file        msync[0p,4p)   vnodeout delta = 0   vnodepgsout delta = 0
F 1p file        msync[0p,16p)  vnodeout delta = 1   vnodepgsout delta = 1  <- flush!
  • Arms A–D: every in-file msync(MS_SYNC) on the default filesystem (hammer2) performs zero writeback and returns success.
  • Arm F flushes only because a 16-page range happens to equal the block-rounded object->size of the 1-page file β€” mechanistic proof that the gate is exactly end_pindex == object->size - 1 with the object sized beyond EOF.

UFS (run.3-ufs.log, 2 reps): A=0/0, B=1/4..6, C=0/0, D=1/1 β€” on UFS (objects sized exactly to the file) the whole-file msync works, while the partial range (A) and the whole-mapping-of-larger-file (C) cases are silent no-ops.

Negative control (run.log): on tmpfs (objects are swap-backed) vm_object_page_clean() early-returns at the type != OBJT_VNODE gate β€” all arms flat; documents that the tmpfs result is the early-out, not the wholescan bug.

Fix validation (patched kernel, same guest)

fix.diff applied to the guest's /usr/src (make nativekernel, log in fix_build.log), rebooted into #1 Tue Oct 13 09:06:21 UTC 2026, identical rig (run.patched-h2.log, 3 reps, deterministic):

A=1/2 B=1/6 C=1/4 D=1/1 E=1/1 F=1/1

The partial-range msync (arm A) now flushes; the rig's own verdict line flips to "partial range unexpectedly flushed", i.e. the vulnerable behavior is gone. Guest reset to the clean-source snapshot afterwards.

Impact

  • Any unprivileged process that mmap()s a file PROT_WRITE and relies on msync(MS_SYNC) for durability gets a silent no-op: on hammer2 for every range; on UFS for every range that does not start at file page 0 and end exactly at EOF. Data the app believes durable lives only in the page cache until the ~30 s syncer (or munmap/last-close paths) happens to flush; a crash/panic in that window loses it.
  • POSIX msync() contract broken; MS_SYNC|MS_INVALIDATE semantics broken (dirty pages are not dropped); the NFS server's partial-range clean (sys/vfs/nfs/nfs_serv.c:3663) is also a no-op.
  • Not memory corruption; no privesc; no kernel crash.

Fix

fix.diff β€” run the PG_CLEANCHK marking pass over the requested range unconditionally; keep the OBJ_WRITEABLE|OBJ_MIGHTBEDIRTY / vclrobjdirty() clearing gated on wholescan && !error (those flags describe the whole object, not the cleaned range).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff to the guest's /usr/src copy (host sys/ untouched), built with 'make -j8 nativekernel KERNCONF=X86_64_GENERIC' (fix_build.log), rebooted into the patched kernel (#1 Oct 13 09:06:21) and re-ran the identical rig: the previously-flat arms all flushed deterministically (A=1/2 B=1/6 C=1/4 D=1/1 E=1/1 F=1/1 over 3 reps, run.patched-h2.log). The vulnerable behavior is gone; whole-object and beyond-EOF controls still flush as before. Guest reset to the clean-source snapshot afterwards.

run.patched-h2.log, fix_build.log, fix.diff
↓ fix.diffper-fix-DF-5829

Confirmed kernel references

Detail

Evidence (decisive lines)

run.h2.log (hammer2 stock: A-E=0/0, F=1/1, 3 reps), run.3-ufs.log (UFS stock: A=0/0 B=1/4-6 C=0/0 D=1/1), run.patched-h2.log (patched: A=1/2 B=1/6 C=1/4 D=1/1 E=1/1 F=1/1), fix.diff, fix_build.log, VERDICT.md

PoC changes

Seed was a description only. Wrote msync_poc.c from scratch: arms A-F as unprivileged child processes (dirty one MAP_SHARED page, msync window, sleep), parent samples vm.stats.vm.v_vnodeout/v_vnodepgsout (incremented only in vnode_pager_putpages, sys/vm/vnode_pager.c:764) around the syscall and SIGKILLs the child before teardown so exit cannot flush. First run was invalid: /tmp on the guest is tmpfs (swap-backed objects -> page_clean early-outs); second run on hammer2 showed even the controls flat, which led to the object-size-beyond-EOF discovery (nvnode_pager_setsize) and the beyond-EOF arms E/F that turn the hammer2 anomaly into mechanistic proof. UFS arms run under /boot with a root-placed directory.

Verified recommended fix

In vm_object_page_clean(), always run the PG_CLEANCHK marking pass1 over the requested range; keep OBJ_WRITEABLE/OBJ_MIGHTBEDIRTY/vclrobjdirty clearing gated on wholescan && !error (see fix.diff).

Verdict

vm_object_page_clean() gates its PG_CLEANCHK marking pass (pass1) on 'wholescan' (range == entire object from page 0), while pass2 and collect_flush skip every page lacking PG_CLEANCHK; therefore every partial-range clean silently flushes nothing and returns success. msync(MS_SYNC) maps 1:1 onto this function with no fallback. Verified on the stock kernel: on hammer2 (whose vnode objects are sized beyond EOF by nvnode_pager_setsize, so wholescan is unsatisfiable by any in-file range) ALL msync ranges performed zero vnode pageout (v_vnodeout/v_vnodepgsout deltas 0 across 3 deterministic reps); on UFS, partial-range and whole-mapping-of-larger-file msyncs were no-ops while whole-file msync flushed. Arm F (msync of a 16-page range on a 1-page file, i.e. exactly the block-rounded object->size) flushed on the stock kernel - mechanistic proof of the gate. One-line fix validated by rebuilding the kernel on the guest: with pass1 run unconditionally over the requested range, the previously-dead arms all flush (A=1/2 B=1/6 C=1/4 D=1/1, 3 reps).