Beyond-EOF VM pages retained for the last buffer remain faultable: nvnode_pager_setsize()'s unmap is the only EOF enforcement, so mmap reads/writes past EOF succeed on every filesystem, pre-truncate content (incl. recycled disk-block residue) is served during the truncate zero-fill window, and on tmpfs beyond-EOF writes become file data
| Field | Value |
|---|---|
| ID | DF-2920 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-284 / CWE-200 |
| File | sys/kern/vfs_vm.c |
| Lines | 42-47, 165, 179-201, 463-495 (sink: vm_fault.c:1906-2015) |
| Area | kern/vfs + vm |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
vfs_vm.c's design keeps fully-valid VM pages beyond file EOF that fit in the last buffer cache buffer and claims 'we simply unmap them and do not allow userland to fault them in' (:46-47). nvnode_pager_setsize() implements only a one-shot pmap unmap (:486-495). vm_fault_object() maps any present+valid page with no v_filesize test; its only bounds check compares against object->size, which deliberately covers the whole last buffer (:463-466), and the pager-side EOF test is only reached for missing pages β so vnode_pager.c's claim that 'the VM fault code tests against v_filesize' is false. Verified on the guest as an unprivileged user on tmpfs, UFS and HAMMER2: a fresh mmap created after ftruncate() reads @70000 (beyond EOF, page retained) with no SIGBUS while @200000 (page absent) correctly SIGBUSes; WRITE faults beyond EOF succeed; on tmpfs the written byte survives extension as file data because tmpfs_truncateβtmpfs_reg_resize(...,trivial=1) makes nvextendbuf skip the covering zero-fill; and during nvtruncbuf's window between the v_filesize update/unmap (:165) and the bzero (:184) racing faults serve pre-truncate contents β 3,072,242 fstat-correlated hits in 10 s on tmpfs. The window also exposes raw on-disk beyond-EOF tail bytes pulled in by the truncate-time bread_kvabio β residue of previously-deleted files on UFS/HAMMER2; zeroing is additionally skipped entirely by NVEXTF_TRIVIAL (:179) and the bread-error path (:196-201). Unprivileged local user with read access to a file can read pre-truncate contents and on-disk recycled-block residue beyond EOF; modify the last buffer beyond EOF (on tmpfs becomes visible file data after a later extend); programs relying on SIGBUS past EOF silently see zeros. No userβroot route (file-data boundary bug, not memory corruption).
Proof of contest
VERIFIED (findings/poc/DF-2920/): truncfault.c β fill 320KiB file with 0x5A; ftruncate to 65536+1231 (mid-block); fresh mmap; read/write @70000. Vulnerable kernel: read returns 0x00 (no SIGBUS), write succeeds, tmpfs extend then pread returns 'X'; race thread reads pattern @70000 twice with fstat-verified size<70000 (3.07M hits/10s tmpfs). Fixed kernel (vm_fault_object EOF check): all beyond-EOF probes SIGBUS; partial EOF-page behavior stays POSIX-correct.
Recommended fix
Enforce EOF at fault time in vm_fault_object(): for OBJT_VNODE first objects, fail with KERN_PROTECTION_FAILURE when IDX_TO_OFF(first_pindex)
= vp->v_filesize (pages must stay valid for VMIO buffers, so the check cannot live in vfs_vm.c). Validated by full nativekernel rebuild + reboot: all probes flip to SIGBUS, exec/file-IO healthy.
Timeline
- 2026-09-02 Discovered during pass-2 audit of vfs_vm.c (GLM 5.3); reproduced on tmpfs/UFS/HAMMER2 as unpriv user + fix validated.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2920 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| truncfault.c | β | 8.3 KB | view raw | |
| build.sh | β | 130 B | view raw | |
| run.sh | β | 195 B | view raw | |
| run.tmpfs.log | β | 1.0 KB | view raw | |
| run.ufs.log | β | 1.0 KB | view raw | |
| run.h2.log | β | 1.0 KB | view raw | |
| run.fixed.log | β | 919 B | view raw | |
| run.fixed.h2.log | β | 910 B | view raw | |
| env.txt | β | 247 B | view raw | |
| fix-build.log | β | 443 B | view raw | |
| VERDICT.md | β | 4.7 KB | β raw | |
| README.md | β | 1.7 KB | β raw | |
| fix.diff | β | 1.1 KB | view raw |
DF-2920 β beyond-EOF mmap fault enforcement missing (vfs_vm.c)
Unprivileged PoC proving that after a mid-block ftruncate(), VM pages
beyond file EOF (retained for the last buffer cache buffer) can be
re-faulted by userland: reads return data instead of SIGBUS, writes
modify the last block, and during the truncate zero-fill window the
pre-truncate contents are served (3.07M correlated observations/10s on
tmpfs). On tmpfs, bytes written beyond EOF through such a mapping become
file data after a later extend (trivial=1 extend skips the covering
zero-fill).
Build (in guest)
./build.sh # cc -O2 -pthread -o /tmp/truncfault truncfault.c
Run (as unprivileged user)
./run.sh /tmp/df2920.tmpfs.bin 8 # tmpfs ./run.sh /mnt/ufs/df2920.ufs.bin 8 # UFS (md/vn image) ./run.sh /home/maxx/df2920.h2.bin 8 # HAMMER2 root
Expected on a VULNERABLE kernel
S1 read @70000 (beyond EOF): value 0x00 served, NO SIGBUS read @200000 (beyond object): SIGBUS <- contrast proves mechanism S3 write @70000: SUCCEEDED; on tmpfs pread after extend returns 0x58 S4 WINDOW-HITS(pattern twice while small) > 0 (tmpfs: ~300k/10s)
Expected on a FIXED kernel (fix.diff applied)
All beyond-EOF probes SIGBUS; S3 write fails; S4 window hits 0.
Files
truncfault.c probe (S0 sanity, S1/S2 reads, S3 write, S4 race) run.tmpfs.log baseline run on tmpfs (vulnerable kernel) run.ufs.log baseline run on UFS image (vn1) run.h2.log baseline run on HAMMER2 root run.fixed.log same probe on kernel patched with fix.diff env.txt guest uname / mounts VERDICT.md full narrative fix.diff git-apply-able fix (sys/vm/vm_fault.c)
DF-2920 β beyond-EOF mmap pages remain faultable after mid-block truncate
Verdict (baseline, stock kernel): REPRODUCED
File: sys/kern/vfs_vm.c (nvnode_pager_setsize(), called from
nvtruncbuf()/nvextendbuf())
Class: improper access control / information disclosure window +
memory-mapping semantics violation
Impact: local info disclosure (racy) + deterministic integrity
violation (writes beyond EOF become file data on tmpfs)
Confidence: certain β deterministically reproduced on tmpfs, UFS and
HAMMER2, unprivileged user, stock INVARIANTS kernel.
What the code intends
sys/kern/vfs_vm.c:42-47 states the design: pages beyond file EOF that
still fit inside the last buffer cache buffer are kept (valid, part of
the VMIO buffer) but "We simply unmap them and do not allow userland to
fault them in." nvnode_pager_setsize() implements only the first half:
a one-shot vm_page_protect(m, VM_PROT_NONE) unmap
(sys/kern/vfs_vm.c:486-495). There is no second mechanism. The comment
in sys/vm/vnode_pager.c:99 ("the VM fault code tests against
v_filesize") is false: vm_fault_object() maps any present and fully
valid page (sys/vm/vm_fault.c:1906-1968) with no EOF test whatsoever;
the only size test (vm_fault.c:2015) compares against object->size,
which deliberately covers the whole last buffer including pages beyond
EOF (vfs_vm.c:463-466). The pager-side EOF test
(vnode_pager_generic_getpages(), sys/vm/vnode_pager.c:505) is only
reached for missing pages.
Proof (guest, unprivileged user maxx)
truncfault.c: file of 320 KiB filled with 0x5A, ftruncate() to
65536+1231 (mid-block for every blksize 4K..64K), then:
| subtest | tmpfs | UFS (vn1) | hammer2 (/) |
|---|---|---|---|
| S1 fresh mmap after truncate, read @70000 (beyond EOF, page retained) | served, 0x00, no SIGBUS | served, 0x00 | served, 0x00 |
| S1 read @200000 (beyond object->size, page absent) | SIGBUS | SIGBUS | SIGBUS |
| S2 pre-existing mapping, read @70000 after truncate | served 0x00 | served 0x00 | served 0x00 |
| S3 fresh mmap WRITE @70000 after truncate | succeeded | succeeded | succeeded |
| S3 then extend, pread @70000 | 0x58 'X' β beyond-EOF write became file data | 0x00 (extend zero-fill covered it) | 0x00 (covered) |
| S4 strict race: pattern read twice at @70000 with fstat-verified size<70000 | 3,072,242 hits / 10 s | 0 (window closes faster than 2nd sample; 3110 1st-sample observations) | 0 (2 1st-sample observations) |
S3-tmpfs persistence mechanism: tmpfs_truncate() calls
tmpfs_reg_resize(vp, length, 1) (sys/vfs/tmpfs/tmpfs_subr.c:1404) β
trivial=1 even for ftruncate()-extends β so nvextendbuf() skips the
zero-fill of the block straddling the old EOF
(sys/kern/vfs_vm.c:386-408) and the bytes the user wrote beyond EOF
through the (illegitimate) mapping become in-file data.
S4 window mechanism: nvtruncbuf() updates vp->v_filesize and unmaps
beyond-EOF pages in nvnode_pager_setsize() before the zero-fill
bzero(bp->b_data + boff, blksize - boff) runs (vfs_vm.c:165 vs
vfs_vm.c:179-195). In that window any fault re-maps the retained valid
page and serves the pre-truncate contents (0x5A) while fstat() already
reports the smaller size β 3.07M correlated observations on tmpfs in 10 s.
The window also exposes recycled disk-block content: when the last
buffer is not cached, the truncate-time bread_kvabio() reads the whole
block from disk β including the beyond-EOF tail, which on UFS/HAMMER2
contains residue of previously-deleted files β into the valid page before
the bzero() erases it. The zero-fill is additionally skipped entirely
by the NVEXTF_TRIVIAL path and by the bread error path
(vfs_vm.c:196-201), leaving pre-truncate content exposed indefinitely.
Security impact
- Read side: an unprivileged user with read access to a file can read file contents beyond EOF (a) during the truncate window (proven), (b) permanently whenever the zero-fill is skipped (TRIVIAL / bread error), including on-disk residue of deleted files pulled in by the truncate-time bread. POSIX/SUSv require SIGBUS.
- Write side: an unprivileged user with write access can modify the last buffer beyond EOF through mappings created after the truncate; on tmpfs (trivial extend) those bytes become file content.
- The SIGBUS contract break also silently substitutes zeros where programs expect a fault, defeating "map generously, catch SIGBUS" bounds patterns.
Fix validation
Kernel rebuilt with fix.diff (vm_fault.c: reject faults beyond
v_filesize for OBJT_VNODE first objects): see run.fixed.log.
Baseline behavior on the same guest before the patch: run.*.log.
Fix verification
fixedRebuilt kernel with fix.diff (EOF check in vm_fault_object); identical PoC now gets SIGBUS for every beyond-EOF read/write probe on tmpfs and hammer2 (run.fixed*.log); partial EOF-page reads within the EOF page still served as zeros (POSIX-conformant); boot, exec of dynamic binaries, file I/O all healthy.
['findings/poc/DF-2920/run.fixed.log', 'findings/poc/DF-2920/run.fixed.h2.log', 'findings/poc/DF-2920/fix-build.log']
Confirmed kernel references
Detail
Exploit chain
unprivileged user maps a readable file -> another thread/proc (or same) ftruncate()s it mid-block -> user re-faults beyond EOF: (a) during the setsize->bzero window reads pre-truncate content including recycled disk-block residue read by the truncate-time bread (3.07M correlated observations/10s on tmpfs); (b) via TRIVIAL/bread-error paths permanently; (c) write-faults beyond EOF modify the last buffer block and on tmpfs persist as file data after extend
Evidence (decisive lines)
["findings/poc/DF-2920/run.tmpfs.log (S1 served-no-SIGBUS, S3 val=0x58 'X' became file data, S4 3,072,242 window hits)", 'findings/poc/DF-2920/run.ufs.log and run.h2.log (same S1-S3 behavior)', 'findings/poc/DF-2920/run.fixed.log + run.fixed.h2.log (all probes SIGBUS on patched kernel)', 'findings/poc/DF-2920/VERDICT.md']
PoC changes
Fixed vs the conceptual sketch: added SIGSEGV handling (failed vnode faults surface as SIGSEGV on this kernel), unbuffered stdout, per-subtest isolation, and the strict S4 protocol (pattern at v1, fstat-verified size < probe offset, pattern again at v2, size still small) to make window hits unambiguous.
Verified recommended fix
In vm_fault_object(), reject faults on OBJT_VNODE first objects whose page offset is >= vp->v_filesize (KERN_PROTECTION_FAILURE); see fix.diff
Verdict
Deterministically reproduced on stock INVARIANTS kernel as an unprivileged user on tmpfs, UFS and HAMMER2: after a mid-block ftruncate(), VM pages beyond EOF retained for the last buffer cache buffer are re-faultable because nvnode_pager_setsize() (sys/kern/vfs_vm.c:486-495) only unmaps them while vm_fault_object() (sys/vm/vm_fault.c:1906-1968) maps any present+valid page with no v_filesize check. Fresh mappings created long after the truncate read the retained page (zeros) instead of receiving SIGBUS, and WRITE faults beyond EOF succeed; on tmpfs the written bytes become file data after a later ftruncate-extend because tmpfs_reg_resize passes trivial=1 (tmpfs_subr.c:1404) so nvextendbuf skips the covering zero-fill. During the truncate itself the window between the v_filesize update/unmap (vfs_vm.c:165) and the bzero (vfs_vm.c:184) serves PRE-TRUNCATE contents to racing faults: 3,072,242 fstat-correlated hits in 10 s on tmpfs (run.tmpfs.log); the same window exposes on-disk residue pulled in by the truncate-time bread of the last block. Patched kernel (fix.diff adds the EOF check to vm_fault_object) flips every deterministic probe to SIGBUS and eliminates re-fault exposure; only the inherent in-flight-truncate pmap race remains.
No comments yet.