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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3056 Β· 14 files
FileTypeDescriptionSize
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
README.md readme how to reproduce
↓ download 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.

VERDICT.md verdict full narrative
↓ download raw

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_testable
baseline reproduced→ patch + rebuild →patched reproduced

fix.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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.