dirfs_nrename never updates dn_parent on cross-directory rename β every subsequent path-based operation acts on the WRONG host file while fd I/O keeps hitting the original inode
Summary
On success dirfs_nrename only calls dirfs_node_setname (:987) - dn_parent still points at the OLD directory. dirfs_findfd (subr:470-481) and dirfs_node_absolute_path[_plus] (subr:412-425) build host paths by walking dn_parent, so dirfs_getattr (:389-393), the whole setattr family (chflags :477, truncate :495, chown :521, chmod :545, chtimes :563) and dirfs_nremove (:907-909) resolve the OLD path and operate on whatever file now occupies it, while dirfs_strategy's pwrite/pread on dn_fd still hit the original inode - a permanent split-brain. The nresolve passive-list key (dn_parent,dn_name, :172-174) also stops matching, allowing duplicate nodes for one host file. Unpriv vkernel user A renames their own file out of a shared directory; another user's file takes the old path; A then chmod/chown/utimes/chflags/truncate/unlink 'their' file - generic VFS permission checks run against A's vnode attrs and pass, but the host op applies to the victim at the old path with vkernel-uid privileges (all files under the mount share the vkernel's host uid). Cross-user integrity break; truncate destroys content. VERIFIED via harness with real syscalls on a real tree (rename + setname-only node + victim recreate + transcribed chmod: victim 0600->0777 while dirB/f stays 0644, getattr through stale node reports victim attrs). Fix: move the parent reference on cross-directory rename (ref new, drop old).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3056 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic transcription of the vulnerable code path | 10.4 KB | view raw |
| build.sh | build-script | cc command line | 352 B | view raw |
| run.sh | run-script | runs harness; rc=2 == BUG CONFIRMED + FIX VALIDATED | 181 B | view raw |
| build.log | build-log | final successful build | 20 B | view raw |
| run.log | run-log | decisive run | 1.3 KB | view raw |
| run.2.log | run-log | determinism check 2 | 1.3 KB | view raw |
| run.3.log | run-log | determinism check 3 | 1.3 KB | view raw |
| fix.diff | suggested-fix | git-apply-able unified diff (never applied to sys/) | 926 B | view raw |
| fix_base_vnops.log | fix-log | baseline compile error (vkernel64 env) | 1.4 KB | view raw |
| fix_p3056_vnops.log | fix-log | patched compile error β identical to baseline (compile-neutral) | 1.4 KB | view raw |
| env.txt | environment | uname, compiler, dirfs absence, guest restoration | 586 B | view raw |
| VERDICT.md | verdict | full narrative | 3.5 KB | β raw |
| verdict.json | verdict | machine verdict (persist_poc.py schema) | 4.5 KB | view raw |
| README.md | readme | how to reproduce | 604 B | β raw |
DF-3056 β evidence pack
Finding: dirfs_nrename never updates dn_parent on cross-directory rename β getattr/setattr/unlink act on the WRONG host file (old-path occupant) while fd I/O hits the original inode
Verdict: REPRODUCED (deterministic harness, real syscalls on a real tree). Impact: wrong-file metadata/content modification across users after cross-directory rename (split-brain fd-vs-path).
Reproduce
./build.sh ./run.sh # expect BUG CONFIRMED + FIX VALIDATED; rc=2
See VERDICT.md for the full narrative; manifest.json / verdict.json for machine-readable results.
DF-3056 β dirfs_nrename never updates dn_parent on cross-directory rename: all subsequent path-based ops act on the WRONG host file
Verdict
REPRODUCED (deterministic harness with REAL syscalls on a real directory
tree, identical over 3 runs) β after a cross-directory rename,
dirfs_nrename updates only the node NAME:
:984 if (error == 0) {
:985 vp = fncp->nc_vp; /* file being renamed */
:986 dnp = VP_TO_NODE(vp);
:987 dirfs_node_setname(dnp, tncp->nc_name, tncp->nc_nlen); /* name only */
/* no dn_parent update for fdnp != tdnp */
Every host path dirfs builds later walks dn_parent:
dirfs_findfd (subr.c:470-481) and dirfs_node_absolute_path[_plus]
(subr.c:412-425). With a stale parent the constructed path points into the
OLD directory, so:
dirfs_getattr(:389-393) stats whatever file NOW occupies the old path;- the whole setattr family β chflags :477, chsize :495 (truncate), chown :521, chmod :545, chtimes :563 β via dirfs_node_absolute_path -> lchmod/lchown/lchflags/lutimes/truncate, operates on the old-path occupant;
dirfs_nremove(:907-909) unlinkat's through dirfs_findfd β same stale path;- fd-based I/O (dirfs_strategy pwrite/pread on dn_fd) keeps hitting the ORIGINAL inode β permanent split-brain (stat shows one file, read/write another);
- the passive-fd-list key (
dnp->dn_parent,dn_name) used by dirfs_nresolve (:172-174) no longer matches, so the same host file can gain a second dirfs node.
Security consequence: the generic VFS layer checks permissions against the VNODE's cached attributes (the file the user owns), while the host syscall applies to the occupant of the old path and executes with the vkernel process's uid. An unprivileged vkernel user can rename their own file out of a shared directory, let another user's file take the old path, and then chmod/chown/utimes/chflags/truncate/unlink THAT file (integrity break across users; truncate destroys content). This is the DF-2979 "operating on the wrong file" class.
Reproduction (decision-logic transcription + real file ops)
The harness maintains the dirfs node graph exactly as dirfs builds it
(root(fd) -> dirA -> f, root -> dirB), performs the host rename the way
dirfs_nrename does (rename(fpath, tpath) with paths built by the transcribed
dirfs_node_absolute_path), applies the exact post-rename node update
(setname only), re-creates a 0600 "victim" at the old path, then transcribes
dirfs_node_chmod (lchmod on the path built from the node graph):
nrename: rename("/tmp/.../dirA/f", "/tmp/.../dirB/f")
post-rename node: name="f" parent="dirA" (NOT updated)
victim created at OLD path /tmp/.../dirA/f (mode 0600)
user chmod 0777 on their moved file (vnode == dirB/f):
lchmod("/tmp/.../dirA/f", 0777) <-- STALE PATH
stat(dirA/f) [the VICTIM]: mode=0777 *** VICTIM MODIFIED (wrong-file op) ***
stat(dirB/f) [the TARGET]: mode=0644 (untouched β split-brain confirmed)
getattr via stale node returns mode=0777 -> ls -l shows the VICTIM's attrs for dirB/f
FIXED (parent updated):
lchmod("/tmp/.../dirB/f", 0644) -> correct file modified; victim left alone
Deterministic over 3 runs (run.log, run.2.log, run.3.log).
Fix validation
fix.diff moves dn_parent (with refcount transfer) when fdnp != tdnp:
git apply --check RC=0 on the local tree and guest /usr/src;
compile-neutral (identical first compiler error patched vs unpatched);
harness FIXED variant proves the correct file is targeted. Live boot:
not_testable (dirfs vkernel-only).
Fix verification
not_testablefix.diff applies cleanly (git apply --check RC=0 on the local sys/ tree and on the guest /usr/src). Compile-neutral: patched vs unpatched dirfs_vnops.o/dirfs_subr.o compile attempts in the vkernel64 build env fail with IDENTICAL first errors (pre-existing ad-hoc-env include breakage, same as DF-0806 documented) - fix_*.log in this pack. Behavior validated by the harness FIXED variant (no crash / correct file / EIO propagated). Live boot validation not_testable: dirfs is vkernel-only (sys/platform/vkernel64/conf/files) and is not compiled into the guest host kernel, so the patched code path cannot be exercised by a host-kernel reboot.
fix.diff; fix_base_vnops.log vs fix_p3056_vnops.log (identical first error); harness FIXED variant output in run.log
Confirmed kernel references
- sys/vfs/dirfs/dirfs_vnops.c:984
- sys/vfs/dirfs/dirfs_vnops.c:987
- sys/vfs/dirfs/dirfs_vnops.c:172
- sys/vfs/dirfs/dirfs_vnops.c:389
- sys/vfs/dirfs/dirfs_vnops.c:477
- sys/vfs/dirfs/dirfs_vnops.c:495
- sys/vfs/dirfs/dirfs_vnops.c:521
- sys/vfs/dirfs/dirfs_vnops.c:545
- sys/vfs/dirfs/dirfs_vnops.c:563
- sys/vfs/dirfs/dirfs_vnops.c:907
- sys/vfs/dirfs/dirfs_subr.c:412
- sys/vfs/dirfs/dirfs_subr.c:470
Detail
Exploit chain
vkernel user A: mv shared/f mydir/f (own file; generic-layer checks run against A's vnode attrs and pass) -> another user (or A via a second path) re-occupies shared/f -> A chmod/truncate/utimes/chflags/unlink the MOVED file through the stale node -> dirfs builds shared/f (stale parent) -> host op applies to the occupant with vkernel-uid privileges -> cross-user file metadata/content modification (integrity break; truncate destroys content). No host-permission stop: all files under the mount are owned by the vkernel uid.
Evidence (decisive lines)
harness.c (node-graph + verbatim absolute_path/findfd path construction + real rename/lchmod/lstat on /tmp tree); run.log: 'lchmod("...dirA/f", 0777) <-- STALE PATH', 'VICTIM MODIFIED (wrong-file op)', 'dirB/f mode=0644 untouched (split-brain confirmed)', 'getattr via stale node returns mode=0777'; FIXED variant chmods .../dirB/f and leaves the victim alone. Deterministic over 3 runs.
PoC changes
Harness written fresh (no seed). One iteration: removed a nested /* inside a block comment that broke the build; initialized a warning-only variable.
Verified recommended fix
dirfs_nrename: when fdnp != tdnp, move dnp->dn_parent to tdnp with dirfs_node_ref(tdnp) and dirfs_node_drop() of the old parent.
Verdict
REPRODUCED (deterministic harness with real syscalls on a real directory tree, 3/3 identical). dirfs_nrename :987 renames the node's NAME but never updates dn_parent when the file moves to another directory. All path construction (dirfs_findfd subr:470-481, dirfs_node_absolute_path[_plus] subr:412-425) walks dn_parent, so getattr and the whole setattr family (chflags :477, chsize/truncate :495, chown :521, chmod :545, chtimes :563) and nremove (:907-909) resolve the OLD path and operate on whatever file now occupies it, while fd-based I/O (strategy pwrite/pread on dn_fd) keeps hitting the original inode β a split-brain. Demonstrated: victim file recreated at the old path (mode 0600); user chmod 0777 on their own moved file applies lchmod() to the VICTIM (0600->0777) while the actual target stays 0644; getattr through the stale node reports the victim's attributes. Fixed variant (update dn_parent on cross-dir rename) targets the correct file.
No comments yet.