dirfs_node_chsize destroys the guest buffer cache/pager BEFORE the host truncate() is known to succeed β failed truncates (EFAULT on unlinked/over-length paths, EPERM by non-owner) silently discard acknowledged writes and desync the VM object; absolute_path NULL return never checked
Summary
dirfs_node_chsize (:778-793) runs nvtruncbuf/nvextendbuf on the vnode FIRST and only then calls truncate(tmp, nsize); tmp comes from dirfs_node_absolute_path (:778) which returns NULL for unlinked-but-open nodes and for paths > MAXPATHLEN - chsize is the only ch* wrapper with no NULL check. On any failed truncate the committed damage is never rolled back: nvtruncbuf destroys ALL buffers at/beyond the truncation point WITHOUT flushing dirty ones (vfs_vm.c:280-306) and resizes the VM object. Demonstrated: a write(2)-acknowledged dirty buffer silently destroyed, subsequent dirfs_read re-fetches the OLD host data through the still-open dn_fd (acked write reverted), mmap view SIGBUSes, and - because the setattr size branch has no ownership check (vnops:491-503) and chsize lacks the IMMUTABLE|APPEND guard chtimes has - any guest user can inflict this on the shared cached view of files they cannot modify. Over-length paths fully constructible by an unpriv user (dirfs itself creates entries via openat from cached-fd ancestors). VERIFIED 3/3 via deterministic harness transcription: (A) acked-write reversion on unlink+ftruncate (absolute_path NULL -> damage then EFAULT); (B) >MAXPATHLEN tree via fd-relative openat -> same damage; (C) root-owned file EPERM-after-damage with host file byte-identical. FIXED variant (NULL check + host-first): zero damage. No escalation (data-integrity/DoS; dirfs vkernel-only).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3133 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic transcription of chsize + absolute_path with vfs_vm.c-faithful buffer stubs and real truncate/pread outcomes | 18.1 KB | view raw |
| build.sh | build-script | cc command line | 65 B | view raw |
| run.sh | run-script | runs harness; rc=2 == BUG CONFIRMED + FIX VALIDATED | 89 B | view raw |
| build.log | build-log | final successful build | 966 B | view raw |
| run.log | run-log | decisive run | 1.7 KB | view raw |
| run.2.log | run-log | determinism check 2 | 1.7 KB | view raw |
| run.3.log | run-log | determinism check 3 | 1.7 KB | view raw |
| fix.diff | suggested-fix | git-apply-able unified diff (never applied to sys/) | 1.4 KB | view raw |
| fix_base_cc.log | fix-log | baseline compile error (vkernel64 env) | 155 B | view raw |
| fix_patched_cc.log | fix-log | patched compile error β identical to baseline (compile-neutral) | 127 B | view raw |
| env.txt | environment | uname, compiler, dirfs absence, guest restoration | 764 B | view raw |
| VERDICT.md | verdict | full narrative | 6.1 KB | β raw |
| verdict.json | verdict | machine verdict (persist_poc.py schema) | 5.6 KB | view raw |
| README.md | readme | how to reproduce | 3.2 KB | β raw |
DF-3133 β dirfs_node_chsize() destroys the guest page cache before the host
truncate() is known to succeed (and ignores absolute_path()==NULL)
What
dirfs_node_chsize (sys/vfs/dirfs/dirfs_subr.c:778-793) runs
nvtruncbuf()/nvextendbuf() on the vnode first, and only then attempts
the host-side truncate(tmp, nsize). If that truncate fails β
deterministically EFAULT when dirfs_node_absolute_path() returned NULL
(unlinked-but-open node with dn_parent already cleared by
dirfs_nremove/nrmdir/nrename, or absolute path > MAXPATHLEN), or EPERM/
EACCES/ENOSPC from the host β the damage is already done and is never
rolled back:
nvtruncbufdestroys every buffer at/beyond the truncation point without flushing (B_INVAL|B_RELBUF|B_NOCACHE, sys/kern/vfs_vm.c:300-302) β includingB_DELWRIbuffers holdingwrite(2)-acknowledged data β the acknowledged writes are silently lost and subsequent reads resurrect the OLD host data;- the VM object is resized (
nvnode_pager_setsize) tonsizewhile the host file is unchanged β mmap view desyncs (SIGBUS on data thatread(2)still returns); chsizehas nodn_flags & (IMMUTABLE|APPEND)guard (unlike chtimes :656-657) and the setattr size branch has no ownership check (dirfs_vnops.c:491-503 only checks MNT_RDONLY), so any guest user can inflict this on the shared cached view of any dirfs file whose host truncate then fails.
tmp (the dirfs_node_absolute_path() return, :778) is never NULL-checked β
unlike the sibling ch* wrappers whose KKASSERT(tmp) family is DF-0855.
Reproduce
dirfs is vkernel-only, so proof is by deterministic harness transcription
(DF-3054/DF-0806/DF-0807 precedent) β verbatim absolute_path + chsize
control flow, nvtruncbuf/nvextendbuf stubbed to the documented
vfs_vm.c semantics, real host files and real truncate()/pread so all
errno outcomes are real.
ssh dfbsd-maxx # unprivileged (uid 1001) cd poc/DF-3133 ./build.sh && ./run.sh # expect rc=2: 9/9 BUG confirmations + FIXED variants
Expected (see run.log): * A) unlinked-but-open: chsize β EFAULT after the dirty acknowledged write was destroyed and the pager truncated; host data still alive through the open fd; guest re-read resurrects OLD data. * B) >MAXPATHLEN absolute path β tree really constructed on the host via fd-relative openat() (the dirfs fd-cache trick); same damage; the 4x240 boundary path truncates normally (sanity). * C) valid path, root-owned file: EPERM from the host after the guest buffers/pager for that file were destroyed; host file verified untouched. * FIXED variant (NULL check + host-truncate-first): ENAMETOOLONG / errno propagated with ZERO damage in every scenario.
3/3 runs byte-identical (run.log, run.2.log, run.3.log β same MD5).
Fix
fix.diff (also validated): NULL check β ENAMETOOLONG, host
truncate() first, buffer/pager updates only after success. Applies cleanly
(git apply --check RC=0 local tree + guest /usr/src), compile-neutral
(fix_base_cc.log vs fix_patched_cc.log: identical first error β the
pre-existing vkernel-env errno.h breakage documented in DF-3054).
Live vkernel boot not available on this guest β boot validation not_testable
(same as DF-3054).
DF-3133 β dirfs_node_chsize() is destructive-before-validate
Verdict
REPRODUCED (deterministic harness, 3/3 runs byte-identical) β
dirfs_node_chsize (dirfs_subr.c:778-793) mutates the vnode's buffer cache
and pager (nvtruncbuf/nvextendbuf) BEFORE the host truncate() is
attempted, never checks dirfs_node_absolute_path() for NULL, and never
rolls anything back when the host truncate fails. Demonstrated failure
modes, each with the guest-side damage already committed:
- unlinked-but-open (dn_parent cleared by dirfs_nremove vnops.c:914-916
/ nrmdir :1128-1130 / nrename :1000-1002): absolute_path β NULL β
truncate(NULL)β EFAULT. nvtruncbuf had already destroyed the B_DELWRI buffer holding awrite(2)-acknowledged write (vfs_vm.c:300-302 β dirty buffers are destroyed, not flushed) and truncated the VM object. The host file's data is still fully alive through the still-open descriptor (POSIX unlink semantics hold on the host), and the guest's next read re-fetches the OLD data: the acknowledged write is silently REVERTED. The mmap view SIGBUSes on data read(2) still returns. - absolute path > MAXPATHLEN (>1024 bytes of components, constructed for real on the host through fd-relative openat() β exactly the mechanism dirfs's own passive fd cache uses, so such trees are fully constructible by an unprivileged user): same NULL β EFAULT β committed damage. The 4x240-byte boundary (count β€ MAXPATHLEN) truncates normally β sanity.
- host refuses the truncate (root-owned file, valid path): EPERM
returned, but the guest's cached buffers/pager for that file were
destroyed anyway. The setattr size branch (vnops :491-503) has no
ownership check (only MNT_RDONLY) and chsize lacks the
IMMUTABLE|APPENDguard chtimes has (:656-657), so any guest user can inflict this on the shared cached view of files they cannot modify.
Impact ceiling: guest (vkernel) data-integrity + availability β silent loss
of acknowledged writes, resurrection of stale data, mmap/read desync, and
denial of the cached contents of arbitrary dirfs files. dirfs is
vkernel-only (sys/platform/vkernel64/conf/files:45-47), so this is a
vkernel integrity/DoS issue, not a host-kernel crash.
The bug β line by line
:778 tmp = dirfs_node_absolute_path(dmp, dnp, &pathfree); /* may be NULL */
:779 if (nsize < osize) {
:780 error = nvtruncbuf(vp, nsize, biosize, -1, 0); /* DESTROYS buffers
+ pager FIRST */
:781 } else {
:782 error = nvextendbuf(vp, osize, nsize, ...); /* extends FIRST */
:785 }
:786 if (error == 0 && truncate(tmp, nsize) < 0) /* host SECOND;
tmp may be NULL */
:787 error = errno; /* damage stays */
:788 if (error == 0)
:789 dnp->dn_size = nsize;
nvtruncbuf_bp_trunc (sys/kern/vfs_vm.c:280-306) takes both the clean AND
the dirty rb-tree; dirty buffers get bremfree + B_INVAL|B_RELBUF|B_NOCACHE
+ brelse β the data is dropped, never written. nvnode_pager_setsize
(vfs_vm.c:165) then resizes the VM object. (The bawrite at vfs_vm.c:343 is
only in the metasync callback, which matches negative-loffset metadata
buffers only β vfs_vm.c:316.)
Distinction from known findings
- DF-0855 owns the
KKASSERT(tmp)family in chtimes:660/chflags:698/ chmod:718/chown:736 (panic sites). chsize :778 has NO KKASSERT β its NULL flows intotruncate(NULL)(EFAULT) after the destructive helpers ran. This finding is about the destructive ordering, which no known finding covers. - DF-3057 is dirfs_write's bread()-error clobber (vnops:740-753) β different function, different mechanism.
- DF-0808 owns nrename's absolute_path_plus NULL β rename(NULL,...) (no damage to the page cache).
- DF-3054/DF-0855/DF-0856 own the findfd-family NULL derefs and the alloc_file leak. Untouched here.
Reproduction (harness; dirfs is vkernel-only β DF-0806/0807/3054 precedent)
A) chsize returned 14 (Bad address) BUG A1: truncate(NULL) -> EFAULT after damage already done BUG A2: acknowledged (dirty) write DESTROYED without flush BUG A3: pager/VM object truncated to 0 while host file unchanged BUG A4: guest re-read resurrects OLD data β acknowledged write REVERTED FIXED A: clean ENAMETOOLONG / zero damage, dirty buffer intact B) BUG precondition: >MAXPATHLEN tree IS constructible via fd-relative openat() (dirfs does the same through the passive fd cache) BUG B1/B2: absolute_path NULL -> EFAULT after buffer+pager damage B-sanity: 4x240 boundary path truncates normally FIXED B: ENAMETOOLONG, zero damage C) chsize returned 13 (Permission denied) BUG C1/C2: host refuses truncate, guest pager/buffers destroyed anyway host file verified untouched β exit code 2, 9/9 confirmations, 0 mismatches; 3/3 identical runs
Fix validation
fix.diff(NULL check β ENAMETOOLONG; host truncate FIRST; buffer/pager updates only on success) βgit apply --checkRC=0 on the local sys/ tree AND on the guest /usr/src.- Compile-neutral: patched vs unpatched
dirfs_subr.ccompiled in the vkernel64 ad-hoc env fail with IDENTICAL first errors (pre-existingerrno.hinclude breakage, same as DF-3054/DF-0806 documented). (fix_base_cc.logvsfix_patched_cc.log.) - Behavior: harness FIXED variants show zero damage in all scenarios.
- Live boot test: not_testable β dirfs is not compiled into the guest host kernel and no vkernel boot is available on this guest (same as DF-3054).
How to reproduce
ssh dfbsd-maxx # unprivileged (uid 1001) cd poc/DF-3133 ./build.sh && ./run.sh # expect: rc=2 "BUG CONFIRMED + FIX VALIDATED"
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
not_testablefix.diff (NULL check -> ENAMETOOLONG; host truncate first; buffer/pager mutation only on success) applies cleanly (git apply --check RC=0 on the local sys/ tree and on the guest /usr/src). Compile-neutral: patched vs unpatched dirfs_subr.c in the vkernel64 ad-hoc env fail with IDENTICAL first errors (pre-existing errno.h include breakage, same as DF-3054/DF-0806 documented) β fix_base_cc.log vs fix_patched_cc.log. Behavior validated by the harness FIXED variants (zero damage, clean error propagation) in every scenario. Live boot validation not_testable: dirfs is vkernel-only and not compiled into the guest host kernel, so the patched path cannot be exercised by a host-kernel reboot.
fix.diff; fix_base_cc.log vs fix_patched_cc.log (identical first error); harness FIXED variant output in run.log (FIXED A/B lines)
Confirmed kernel references
- sys/vfs/dirfs/dirfs_subr.c:778
- sys/vfs/dirfs/dirfs_subr.c:779
- sys/vfs/dirfs/dirfs_subr.c:786
- sys/vfs/dirfs/dirfs_subr.c:377
- sys/vfs/dirfs/dirfs_subr.c:433
- sys/vfs/dirfs/dirfs_vnops.c:491
- sys/vfs/dirfs/dirfs_vnops.c:914
- sys/kern/vfs_vm.c:146
- sys/kern/vfs_vm.c:165
- sys/kern/vfs_vm.c:280
- sys/kern/vfs_vm.c:300
- sys/kern/vfs_syscalls.c:4117
Detail
Exploit chain
vkernel user -> (own file) open+ftruncate after unlink, or deep >1024-byte path built via fd-relative creation, or ftruncate of a host-root-owned file -> VOP_SETATTR size branch (no ownership check) -> dirfs_node_chsize -> nvtruncbuf destroys buffers/pager (dirty B_DELWRI data lost, VM object resized) -> host truncate() fails EFAULT/EPERM -> error returned but damage committed: acknowledged writes gone, reads resurrect old host data, mmap SIGBUSes; persists for the vnode's cache lifetime
Evidence (decisive lines)
harness.c (verbatim absolute_path :377-443 + chsize :748-799 control flow; nvtruncbuf/nvextendbuf stubbed to documented vfs_vm.c semantics; real truncate/pread errno outcomes); run.log β 'BUG A1..A4', 'BUG B1/B2' + fd-relative constructibility precondition, 'BUG C1/C2' + 'host file untouched'; exit code 2, 9/9 confirmations, 3/3 runs identical MD5; fix.diff applies cleanly (git apply --check RC=0 local + guest /usr/src); fix_base_cc.log vs fix_patched_cc.log compile-neutral
PoC changes
Harness written fresh (no seed). Iterations: (1) helper that joined path fragments ignored its format arg (scenario B silently tested the wrong directory) β replaced with a concatenating helper and re-verified scenario A unchanged; (2) added debug prints mid-debug, removed. All scenario results were re-captured after the fix; 3/3 identical runs.
Verified recommended fix
dirfs_node_chsize: check absolute_path for NULL (ENAMETOOLONG), perform the host truncate() FIRST, and only run nvtruncbuf/nvextendbuf + dn_size update after it succeeds (fix.diff).
Verdict
REPRODUCED (deterministic harness; dirfs is vkernel-only so no live-boot test β DF-0806/0807/3054 precedent). dirfs_node_chsize (dirfs_subr.c:778-793) runs nvtruncbuf/nvextendbuf on the vnode BEFORE the host truncate() is attempted, never checks dirfs_node_absolute_path() for NULL, and never rolls back on failure. Demonstrated: (A) unlinked-but-open file β absolute_path NULL (dn_parent cleared by nremove vnops:914-916) -> truncate(NULL) EFAULT after the B_DELWRI buffer holding a write(2)-acknowledged write was destroyed WITHOUT flush (vfs_vm.c:300-302) and the VM object truncated; host data still alive through the open fd; guest re-read resurrects the OLD data (acknowledged write silently reverted); mmap view desyncs. (B) absolute path >MAXPATHLEN β the deep tree is really constructed on the host via fd-relative openat() (the dirfs passive-fd-cache mechanism), proving constructibility by an unprivileged user; same EFAULT-after-damage; 4x240 boundary path truncates normally. (C) valid path, root-owned file: host truncate EPERM, guest buffers/pager destroyed anyway (no ownership check in the setattr size branch vnops:491-503; chsize also lacks the IMMUTABLE/APPEND guard chtimes has at :656-657); host file verified untouched afterwards. FIXED variant (NULL check + host-first): ENAMETOOLONG/errno with zero damage in every scenario. Impact ceiling: vkernel data integrity + availability (silent loss of acknowledged writes, stale-data resurrection, cached-view denial of arbitrary dirfs files); no host-kernel crash, no escalation.
No comments yet.