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

dirfs_node_setpassive's check-then-close of dn_fd races dirfs_findfd's ref-then-use and all fd consumers β€” in-flight operations can use a CLOSED (and host-RECYCLED) descriptor against the wrong host file/directory

Summary

Nothing in dirfs synchronizes dn_fd lifetime. dirfs_node_setpassive(dmp,dnp,0) checks dirfs_node_refcnt(dnp)==2 (:849) then close(dnp->dn_fd); dnp->dn_fd = DIRFS_NOFD (:856-857) - while another CPU inside dirfs_findfd (:470-496) has walked past dn_fd and only refs the node afterwards (:486), and the consumer then uses pathnp->dn_fd after the return (alloc_file :194/:202, getattr vnops:391/:393, readlink :1329, nremove unlinkat :909, nmkdir :1051-1058, open_helper :602, strategy pread/pwrite :819-822). If the close wins: without fd recycling the consumer gets EBADF (alloc_file re-enters the DF-0856 leak); with recycling - the vkernel opens host files constantly - openat/unlinkat/fstatat/mkdirat/pread/pwrite execute against whatever host object now owns the fd number: metadata, create and unlink operations land in the WRONG directory/file, bounded only by the vkernel's host credentials. Reachable under passive-cache eviction pressure (dirfs_fd_limit default 100) on SMP vkernels. Distinct mechanism from DF-3060 (list UAF); the fd-lifetime race. VERIFIED 3/3 via orchestrated transcription: captured fd 4 closed by T2, recycled to a decoy dir by T3, T1's openat(4,'sentinel') reads the decoy's file AND O_CREAT creates inside the decoy (wrong-file write); fixed variant (dm_token across findfd..dropfd + setpassive check+close): correct-file access, no EBADF. Real-world hit stochastic (SMP + eviction); interleaving unprevented.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3134 Β· 14 files
FileTypeDescriptionSize
harness.c trigger-source deterministic orchestrated transcription of the setpassive/findfd dn_fd lifetime race with real descriptors and fd recycling 11.0 KB view raw
build.sh build-script cc command line (-lpthread) 65 B view raw
run.sh run-script runs harness; rc=2 == RACE CONFIRMED + FIX VALIDATED 89 B view raw
build.log build-log final successful build 20 B view raw
run.log run-log decisive run 1.1 KB view raw
run.2.log run-log determinism check 2 1.1 KB view raw
run.3.log run-log determinism check 3 1.1 KB view raw
fix.diff suggested-fix git-apply-able unified diff (never applied to sys/) 1.8 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, orchestration note, guest restoration 984 B view raw
VERDICT.md verdict full narrative 4.0 KB ↓ raw
verdict.json verdict machine verdict (persist_poc.py schema) 5.9 KB view raw
README.md readme how to reproduce 3.0 KB ↓ raw
README.md readme how to reproduce
↓ download raw

DF-3134 β€” dirfs_node_setpassive() check-then-close of dn_fd races

dirfs_findfd()/consumer use: closed (and host-recycled) descriptor used

against the WRONG host file

What

Nothing in dirfs synchronizes dn_fd lifetime. dirfs_node_setpassive(dmp, dnp, 0) (dirfs_subr.c:848-857) checks dirfs_node_refcnt(dnp) == 2 && … and then close(dnp->dn_fd); dnp->dn_fd = DIRFS_NOFD; β€” while another CPU can be inside dirfs_findfd() (walk reads dnp1->dn_fd at :470, the node ref happens only afterwards at :486) or inside a consumer that uses pathnp->dn_fd after findfd returned (dirfs_alloc_file :194/:202, getattr vnops:391/:393, readlink vnops:1329, nremove's unlinkat vnops:909, nmkdir's mkdirat vnops:1051-1058, open_helper :602). If the close wins the race:

  • without fd recycling: the consumer's openat/unlinkat/fstatat hits a closed descriptor β†’ EBADF (and in dirfs_alloc_file the openat()==-1 path re-enters the DF-0856 node-leak error path);
  • with fd recycling (the vkernel opens host files constantly): the consumer operates on whatever host file now owns that fd number β€” metadata and create/unlink operations land in the WRONG directory/file, bounded only by the vkernel process's host credentials.

Sibling of DF-3060 (which owns the unlocked dm_fdlist walk UAF in nresolve); this finding is the fd lifetime race with a different, worse failure mode (wrong-file operations via a recycled descriptor).

Reproduce

dirfs is vkernel-only β†’ deterministic orchestrated transcription (DF-3054 precedent): verbatim findfd walk condition and setpassive close branch, real descriptors, real openat/fstatat/O_CREAT, three threads synchronized by step barriers reproducing the exact unsynchronized interleaving:

ssh dfbsd-maxx        # unprivileged (uid 1001)
cd poc/DF-3134
./build.sh && ./run.sh   # expect rc=2

Expected (run.log): * variant 1 β€” consumer used fd=4; setpassive closed it; recycler got fd=4; consumer's openat(stale_fd, "sentinel") read the DECOY directory's file; its O_CREAT created a file in the DECOY directory; the intended directory untouched; * variant 2 β€” no recycler: openat on the closed fd β†’ EBADF (use-after-close); * variant 3 β€” FIXED (dm_token held across findfd..dropfd and across setpassive's check+close): consumer reads the CORRECT sentinel, creation lands in the intended directory, no EBADF.

3/3 runs byte-identical (run.log, run.2.log, run.3.log β€” same MD5).

Fix

fix.diff: lwkt_gettoken(&dmp->dm_token) in dirfs_findfd (released by dirfs_dropfd for non-NULL node arguments, i.e. exactly the findfd callers) and across setpassive's check+close section. LWKT tokens are reentrant per thread, so the nested acquisitions (nremove holds findfd's token when it calls setpassive; node_free→setpassive under dropfd) are safe. Applies cleanly (git apply --check RC=0 local + guest /usr/src), compile-neutral (identical first error, pre-existing errno.h env breakage). A full passive-cache lockdown belongs with DF-3060.

VERDICT.md verdict full narrative
↓ download raw

DF-3134 β€” setpassive check-then-close of dn_fd races findfd ref-then-use

Verdict

REPRODUCED (deterministic orchestrated harness, 3/3 runs byte-identical; real-world hit requires an SMP vkernel β€” the interleaving itself is proven possible because nothing synchronizes dn_fd) β€”

dirfs_node_setpassive(dmp, dnp, 0) (dirfs_subr.c:848-867) does a check-then-close of dn_fd:

:849   if (dirfs_node_refcnt(dnp) == 2 && vp && dnp->dn_fd != DIRFS_NOFD && …) {
:856       close(dnp->dn_fd);
:857       dnp->dn_fd = DIRFS_NOFD;
   }
:867   dirfs_node_drop(dmp, dnp);

while dirfs_findfd() (:470-496) walks the parent chain reading dnp1->dn_fd, takes the node reference only at :486 (dirfs_node_ref(dnp1)) β€” after the walk β€” and every consumer then uses pathnp->dn_fd after the function returned (alloc_file subr:194/:202, getattr vnops:391/:393, readlink vnops:1329, nremove unlinkat vnops:909, nmkdir mkdirat vnops:1051-1058, open_helper subr:602, strategy pread/pwrite vnops:819-822). No lock, token, or refcount ordering connects the two sides.

Orchestrated interleaving (verbatim branch conditions, real descriptors): T1 walks and captures use_fd = dn_fd; T2 checks refcnt==2 (T1 has not ref'd yet), close()s the descriptor and stores NOFD; T3 opens an unrelated decoy directory which β€” lowest-free β€” recycles the same fd number; T1 refs, returns, and the consumer's openat(use_fd, "sentinel") reads the decoy directory's sentinel and its O_CREAT creates a file inside the decoy directory. Without the recycler the consumer gets EBADF (use-after-close; in dirfs_alloc_file that error path is additionally the DF-0856 node leak).

Impact ceiling: cross-file metadata/create/unlink/data operations on the wrong host objects, bounded by the vkernel process's host credentials; otherwise EBADF storms. dirfs is vkernel-only (sys/platform/vkernel64/conf/files:45-47). Distinct mechanism from DF-3060 (unlocked dm_fdlist walk β€” list UAF/corruption): this is the fd lifetime check-then-close race with a recycled-descriptor wrong-file primitive.

Reproduction

1) consumer used fd=4; setpassive closed it; recycler got fd=4
   BUG 1a: host recycled the closed fd number to an unrelated open
   BUG 1b: openat(stale fd,"sentinel") read the DECOY directory's file
   BUG 1c: O_CREAT created a file in the DECOY directory
   (intended directory NOT touched)
2) BUG 2: openat on the closed fd -> EBADF (use-after-close)
3) FIXED: dm_token serializes findfd..dropfd against check+close
   consumer read the CORRECT sentinel; creation landed in the intended dir
   β†’ exit code 2; 3/3 identical runs

Fix validation

  • fix.diff β€” lwkt_gettoken(&dmp->dm_token) in dirfs_findfd (token held until dirfs_dropfd for non-NULL node args = exactly the findfd callers; the cur == NULL and over-length exits release it), plus the same token across setpassive's check+close section. LWKT tokens are per-thread reentrant, so nested acquisition (nremove's setpassive under findfd's token; node_freeβ†’setpassive under dropfd) is safe. git apply --check RC=0 on the local sys/ tree and the guest /usr/src.
  • Compile-neutral: identical first error vs baseline in the vkernel64 ad-hoc env (pre-existing errno.h breakage; fix_base_cc.log vs fix_patched_cc.log).
  • Behavior: harness FIXED variant shows correct-file access, no wrong-file creation, no EBADF.
  • Live boot: not_testable (dirfs vkernel-only; no vkernel boot on this guest β€” same as DF-3054). The full passive-cache lockdown (nresolve's unlocked fdlist walk) belongs to DF-3060.

How to reproduce

ssh dfbsd-maxx   # unprivileged (uid 1001)
cd poc/DF-3134
./build.sh && ./run.sh     # expect: rc=2 "RACE 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_testable
baseline reproduced→ patch + rebuild →patched reproduced

fix.diff (dm_token held across findfd..dropfd for non-NULL node args β€” exactly the findfd callers; both early exits release it; plus the same token across setpassive's check+close) applies cleanly (git apply --check RC=0 on the local sys/ tree and guest /usr/src). Compile-neutral: identical first error vs baseline in the vkernel64 ad-hoc env (pre-existing errno.h breakage) β€” fix_base_cc.log vs fix_patched_cc.log. Behavior validated by the harness FIXED variant (correct-file sentinel read, creation in the intended directory, no EBADF). Live boot validation not_testable: dirfs is vkernel-only and not compiled into the guest host kernel. The dm_token serialization addresses the fd-lifetime race scoped here; the unlocked nresolve fdlist walk remains DF-3060's scope.

fix.diff; fix_base_cc.log vs fix_patched_cc.log (identical first error); harness FIXED variant output in run.log (variant 3 lines)
↓ 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

SMP vkernel, fd-cache eviction pressure (dirfs_fd_limit default 100; >100 open files churns setpassive(0) closes) + concurrent path operations on a subtree from another cpu -> T1 findfd walks/selects ancestor fd; T2 setpassive checks refcnt==2 (T1 not yet ref'd) and close()s dn_fd; any intervening open (the vkernel opens host files constantly) recycles the fd number; T1 refs, returns, consumer openat/unlinkat/fstatat/mkdirat/pread/pwrite on the recycled descriptor -> metadata/data/create/unlink operations against the WRONG host directory/file

Evidence (decisive lines)

harness.c (verbatim findfd walk condition :470-481 and setpassive close branch :848-867, real openat/fstatat/O_CREAT/close, three threads with step barriers); run.log β€” 'consumer used fd=4; setpassive closed it; recycler got fd=4', 'BUG 1b: openat(stale fd) read the DECOY directory's file', 'BUG 1c: O_CREAT created a file in the DECOY directory', 'BUG 2: EBADF use-after-close', FIXED lines; exit code 2; 3/3 runs identical MD5; fix.diff applies cleanly (git apply --check RC=0 local + guest /usr/src)

PoC changes

Harness written fresh (no seed). Iterations: (1) -lpthread missing on DragonFly's cc; (2) variant-2 deadlock (no recycler to publish the step) β€” setpassive publishes step 3 directly when no recycler is present; (3) FIXED-variant ordering made deterministic with a t1_locked barrier; (4) exit-code latch counters reset by later variants β€” latched per-variant globals.

Verified recommended fix

Serialize dn_fd lifetime: hold dmp->dm_token from dirfs_findfd's selection until dirfs_dropfd, and across dirfs_node_setpassive's check-then-close section (fix.diff); full passive-cache lockdown belongs with DF-3060.

Verdict

REPRODUCED as a deterministic orchestrated interleaving (dirfs is vkernel-only; the same interleaving occurs stochastically on SMP vkernels because nothing synchronizes dn_fd). dirfs_node_setpassive(dmp,dnp,0) (dirfs_subr.c:848-867) check-then-closes dn_fd (refcnt==2 check at :849, close at :856) while dirfs_findfd (:470-496) walks reading dn_fd and takes its node reference only at :486 β€” after the walk β€” and consumers then use pathnp->dn_fd after the return (alloc_file subr:194/:202, getattr vnops:391/:393, readlink vnops:1329, nremove unlinkat vnops:909, nmkdir vnops:1051-1058, open_helper subr:602, strategy pread/pwrite vnops:819-822). Harness (verbatim branch conditions, real descriptors, step-barrier orchestration): consumer captures fd=4; setpassive closes it; a concurrent open recycles fd 4 for an unrelated decoy directory; the consumer's openat(stale fd,'sentinel') READS THE DECOY's file and its O_CREAT CREATES a file inside the decoy directory β€” wrong-file metadata + write-side operations bounded only by the vkernel's host credentials. Without the recycler the consumer gets EBADF (use-after-close; in dirfs_alloc_file that path is additionally the DF-0856 node leak). FIXED variant (dm_token held across findfd..dropfd and across setpassive's check+close): correct-file access, no EBADF. Sibling of DF-3060 (unlocked dm_fdlist walk β€” list UAF); this is the fd-lifetime race with the recycled-descriptor wrong-file primitive.