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)
PoC verification
Evidence pack
findings/poc/DF-5829 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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):wholescancan never be true for any range the user can express on the file, so everymsync(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-roundedobject->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).
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)
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.sys/vm/vm_object.c:991βif (wholescan) { β¦ pass1 β¦ }β pass1 is the only code that ever setsPG_CLEANCHK(:1039).sys/vm/vm_object.c:1074-1075β pass2 skips every page withoutPG_CLEANCHK:if ((p->flags & PG_CLEANCHK) == 0) goto done;sys/vm/vm_object.c:1175-1179βvm_object_page_collect_flush()also stops at the first page withoutPG_CLEANCHK; no in-tree caller passesOBJPC_IGNORE_CLEANCHK(verified by grep oversys/).
β any partial-range vm_object_page_clean() flushes nothing.
sys/vm/vm_map.c:3148-3152βmsync()reaches the VM only throughvm_object_page_clean(object, OFF_TO_IDX(offset), OFF_TO_IDX(offset + size + PAGE_MASK), flags); there is no VOP_FSYNC fallback invm_map_clean().sys/kern/vfs_vm.c:465-467(nvnode_pager_setsize) β filesystems that usenvextendbuf/nvtruncbuf(hammer2; buffer size 64 KiB) sizeobject->sizebeyond EOF to cover the straddling buffer. For a 4096-byte hammer2 file,object->size == 16pages β 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->sizeof the 1-page file β mechanistic proof that the gate is exactlyend_pindex == object->size - 1with 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_INVALIDATEsemantics 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
fixedApplied 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
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).
No comments yet.