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

dirfs_findfd KKASSERT panic / NULL deref on unlinked dirfs nodes

Summary

dirfs_subr.c:479 dnp1=dnp1->dn_parent :480 KKASSERT(dnp1!=NULL). dn_parent set NULL by dirfs_nremove (vnops.c:914-916) dirfs_nrmdir (vnops.c:1128-1130) dirfs_nrename (vnops.c:1000-1002). nremove does NOT set dn_links=0 so dirfs_inactive does NOT vrecycle vnode stays alive for open fd lifetime. fstat(fd) on unlinked fd: VOP_GETATTR->dirfs_getattr->dirfs_findfd on dn_parent==NULL -> KKASSERT panic. Same class: dirfs_node_absolute_path callers dirfs_node_chtimes:660 chflags:698 chmod:718 chown:736 KKASSERT(tmp) after absolute_path returns NULL. VKERNEL64 includes INVARIANTS KKASSERT always active. Trigger: open /mnt/f unlink /mnt/f fstat(fd) = panic. Any unprivileged vkernel user. Fix: return NULL instead of KKASSERT + callers check NULL return ESTALE.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0855 Β· 14 files
FileTypeDescriptionSize
harness.c trigger-source faithful transcription of dirfs_findfd (BUGGY+FIXED) + unlink setup 8.9 KB view raw
build.sh build-script cc -O2 -Wall -o harness harness.c 195 B view raw
run.sh run-script ./harness 146 B view raw
fix.diff suggested-fix KKASSERT->break+NULL in findfd; ESTALE guard in getattr 1.0 KB view raw
build.log build-log final successful harness build 66 B view raw
run.log run-log decisive run: BUG=YES FIX=YES 1.1 KB view raw
run.stress.log run-log 3 consecutive runs (deterministic) 3.0 KB view raw
fix_build.log fix-build-log compile-neutral validation: patched ≑ unpatched; +include compiles 216KB .o 816 B view raw
env.txt environment uname, cc version, dirfs vkernel64-only confirmation 442 B view raw
VERDICT.md verdict full analysis: mechanism, reachability, fix, validation 10.2 KB ↓ raw
README.md readme summary + reproduce instructions 1.7 KB ↓ raw
manifest.json manifest this file 3.0 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme summary + reproduce instructions
↓ download raw

DF-0855 β€” dirfs_findfd KKASSERT panic / NULL deref on unlinked dirfs nodes

Summary

dirfs_findfd (sys/vfs/dirfs/dirfs_subr.c:479-480) uses KKASSERT(dnp1 != NULL) after dnp1 = dnp1->dn_parent, which panics when the node has been unlinked (dn_parent == NULL). The sibling function dirfs_node_absolute_path_plus handles the same NULL-parent case correctly (if (dnp1 == NULL) break;), proving the guard was simply omitted from dirfs_findfd.

Trigger: open /mnt/f; unlink /mnt/f; fstat(fd) β†’ VOP_GETATTR β†’ dirfs_getattr β†’ dirfs_findfd on the unlinked node β†’ KKASSERT(dnp1 != NULL) panic.

dirfs is vkernel64-only (not in sys/conf/files; 3 entries in sys/platform/vkernel64/conf/files as optional dirfs). The running X86_64_GENERIC host kernel does not include it. A deterministic harness transcribes the buggy code path and proves the panic; the fix is compile-validated.

How to reproduce

ssh dfbsd-maxx
cd poc/DF-0855
./build.sh && ./run.sh

Expected: DF_0855_BUG_PANIC_ON_UNLINKED_NODE = YES and DF_0855_FIX_RETURNS_NULL_NO_PANIC = YES, exit 0.

Fix

fix.diff β€” replace KKASSERT(dnp1 != NULL) with if (dnp1 == NULL) break; in dirfs_findfd (mirroring dirfs_node_absolute_path_plus), plus a NULL guard in dirfs_getattr returning ESTALE.

Files

  • harness.c β€” deterministic transcription of dirfs_findfd + unlink setup
  • build.sh / run.sh β€” build and run wrappers
  • fix.diff β€” git-apply-able unified diff (2 hunks)
  • VERDICT.md β€” full analysis
  • build.log / run.log / run.stress.log β€” full logs
  • fix_build.log β€” compile-neutral validation
  • env.txt β€” guest environment
  • manifest.json β€” artifact catalog
VERDICT.md verdict full analysis: mechanism, reachability, fix, validation
↓ download raw

DF-0855 β€” dirfs_findfd KKASSERT panic / NULL deref on unlinked dirfs nodes

Verdict

REPRODUCED (deterministic code-level harness) β€” the KKASSERT panic in dirfs_findfd on an unlinked dirfs node (dn_parent == NULL) is confirmed by faithful line-by-line source transcription. The fix.diff (replace KKASSERT(dnp1 != NULL) with if (dnp1 == NULL) break; + NULL-return guard in dirfs_getattr) is compile-validated (patched ≑ unpatched; with the pre-existing missing-include addressed, patched compiles cleanly to 216 KB .o). A live-kernel boot test is not_testable because dirfs is vkernel64-only (not compiled into the running X86_64_GENERIC host kernel and no vkernel runs on this guest).

The bug β€” line-by-line (sys/vfs/dirfs/dirfs_subr.c)

450: dirfs_node_t
451: dirfs_findfd(dirfs_mount_t dmp, dirfs_node_t cur,
452:           char **pathto, char **pathfreep)
453: {
454:     dirfs_node_t dnp1;
...
469:     dnp1 = cur;
470:     while (dnp1 == cur || dnp1->dn_fd == DIRFS_NOFD) {
471:         count += dnp1->dn_namelen;
...
478:             buf[MAXPATHLEN - count] = '/';
479:         dnp1 = dnp1->dn_parent;
480:         KKASSERT(dnp1 != NULL);            /* <-- PANIC if unlinked */
481:     }

The loop walks up the dn_parent chain looking for an ancestor with a valid fd. At :479 it dereferences dnp1->dn_parent; at :480 it KKASSERT(dnp1 != NULL). When the node has been unlinked (dn_parent == NULL), the KKASSERT fires β†’ kernel panic (with INVARIANTS on, which VKERNEL64 ships by default per options INVARIANTS).

The author already knew this case is reachable

The sibling function dirfs_node_absolute_path_plus (dirfs_subr.c:412-425) does the exact same parent walk, but handles the NULL case correctly:

412:     dnp1 = cur;
413:     while (dirfs_node_isroot(dnp1) == 0) {
...
422:         dnp1 = dnp1->dn_parent;
423:         if (dnp1 == NULL)       /* <-- correct: break + NULL return */
424:             break;
425:     }

And at :433: if (dnp1 && count <= MAXPATHLEN) β€” the NULL check is present. dirfs_findfd was simply not given the same guard. This is a clear omission, not a design choice.

dirfs_nremove (dirfs_vnops.c:906-922):

907:     pathnp = dirfs_findfd(dmp, dnp, &tmp, &pathfree);   /* BEFORE unlink */
909:     error = unlinkat(pathnp->dn_fd, tmp, 0);
910:     if (error == 0) {
914:         if (dnp->dn_parent) {
915:             dirfs_node_drop(dmp, dnp->dn_parent);
916:             dnp->dn_parent = NULL;                        /* UNLINKED */
917:         }

After unlink, the vnode stays alive (open fd held by the process). The finding notes dirfs_nremove does not set dn_links=0, so dirfs_inactive does not vrecycle the vnode β€” it persists for the fd's lifetime.

The trigger chain (fstat on unlinked fd)

fstat(fd)
  β†’ VOP_GETATTR
  β†’ dirfs_getattr (dirfs_vnops.c:388-394):
      388: if (!dirfs_node_isroot(dnp)) {
      389:     pathnp = dirfs_findfd(dmp, dnp, &tmp, &pathfree);
      391:     KKASSERT(pathnp->dn_fd != DIRFS_NOFD);   /* NULL deref too */
  β†’ dirfs_findfd walks dn_parent β†’ NULL β†’ KKASSERT at :480 β†’ PANIC

Even if dirfs_findfd returned NULL gracefully, line 391's KKASSERT(pathnp->dn_fd != DIRFS_NOFD) would NULL-deref pathnp β†’ panic.

Reachability on this guest (why a harness)

dirfs is vkernel64-only. Confirmed: - grep -c dirfs /usr/src/sys/conf/files β‡’ 0 (not in host-kernel file list) - grep -c dirfs /usr/src/sys/platform/vkernel64/conf/files β‡’ 3 (optional dirfs) - /boot/kernel/dirfs* β‡’ does not exist; kldstat | grep dirfs β‡’ none - Running kernel: 6.5-DEVELOPMENT #0 (X86_64_GENERIC) β€” no options DIRFS

dirfs is a pass-through filesystem that runs inside a vkernel (a userspace process simulating a kernel). There is no vkernel running on this guest. Per the dead-code/latent-bug clause (DF-0594/0616/0281 precedent, and the sibling findings DF-0806/0807/0808), the deterministic harness is the accepted proof: it transcribes the exact buggy loop (:469-481) and the unlink setup (:914-916), proving the KKASSERT fires on the first iteration when cur->dn_parent == NULL.

Reproduction β€” harness (harness.c)

./harness builds a 2-level dirfs tree (root with valid fd, child with DIRFS_NOFD), then:

  1. Happy path (parent intact): both BUGGY and FIXED dirfs_findfd succeed, returning the root node with the relative path.
  2. Unlinked path (simulating dirfs_nremove setting dn_parent = NULL): - BUGGY: KKASSERT(dnp1 != NULL) fires β†’ PANIC (simulated as g_panic_fired=1 + return NULL). - FIXED: if (dnp1 == NULL) break; exits the loop gracefully, returns NULL β†’ caller returns ESTALE. No panic, no leak.
=== DF-0855 dirfs_findfd KKASSERT panic / NULL deref ===

[happy path, parent intact]
  BUGGY: pathnp=0x8004904c0 panic=0 path="file.txt"
  FIXED: pathnp=0x8004904c0 panic=0 path="file.txt"

[AFTER dirfs_nremove: file->dn_parent = NULL (unlinked, vnode alive)]
[fstat(fd) -> VOP_GETATTR -> dirfs_getattr -> dirfs_findfd]
  BUGGY: pathnp=0x0 panic=1
         ** PANIC: assertion "dnp1 != NULL" failed in dirfs_findfd at dirfs_subr.c:480
  FIXED: pathnp=0x0 tmp=NULL pf=0x0
         => no panic, no leak; caller (dirfs_getattr) returns ESTALE

=== SUMMARY ===
DF_0855_BUG_PANIC_ON_UNLINKED_NODE   = YES (KKASSERT fires)
DF_0855_FIX_RETURNS_NULL_NO_PANIC    = YES

Deterministic across 3 consecutive runs (run.log, run.stress.log).

Impact ceiling

  • Claimed (finding): KKASSERT panic / NULL deref β†’ DoS (Medium, CWE-476/617). CONFIRMED at the code/harness level.
  • Impact: any unprivileged vkernel user who can open+unlink a file then fstat the fd triggers an unrecoverable kernel panic (DoS). This is a vkernel panic (the vkernel process crashes), not a host-kernel panic β€” dirfs code runs in the vkernel userspace process.
  • On this guest (host kernel, no dirfs), the primitive is not live-reachable. The harness is the proof at the code/transcription level.
  • No escalation path: this is a NULL-deref/KKASSERT panic (DoS), not a memory-corruption primitive. No slab grooming, no control-flow hijack, no uid=0 chain. The realistic ceiling is panic / DoS.

Exploit chain

none (not applicable). This is a NULL-deref / KKASSERT panic β€” a DoS, not a memory-corruption primitive. There is no write primitive, no UAF, no type confusion. The harness proves the panic deterministically; there is no escalation path to develop.

The fix β€” fix.diff

Two changes:

1. dirfs_findfd (dirfs_subr.c:479-480) β€” root cause

        dnp1 = dnp1->dn_parent;
-       KKASSERT(dnp1 != NULL);
+       if (dnp1 == NULL)
+           break;

Mirror the correct pattern from dirfs_node_absolute_path_plus (:422-424). When dn_parent is NULL (unlinked node), break out of the loop; the existing if (dnp1 && count <= MAXPATHLEN) check at :483 handles the NULL return gracefully.

2. dirfs_getattr (dirfs_vnops.c:388-397) β€” caller NULL guard

    if (!dirfs_node_isroot(dnp)) {
        pathnp = dirfs_findfd(dmp, dnp, &tmp, &pathfree);

-       KKASSERT(pathnp->dn_fd != DIRFS_NOFD);
+       if (pathnp == NULL) {
+           /* Node has been unlinked (dn_parent == NULL). */
+           error = ESTALE;
+       } else {
+           KKASSERT(pathnp->dn_fd != DIRFS_NOFD);

-       error = dirfs_node_stat(pathnp->dn_fd, tmp, dnp);
-       dirfs_dropfd(dmp, pathnp, pathfree);
+           error = dirfs_node_stat(pathnp->dn_fd, tmp, dnp);
+           dirfs_dropfd(dmp, pathnp, pathfree);
+       }
    } else {

Even with the findfd fix, dirfs_getattr would NULL-deref pathnp at the KKASSERT(pathnp->dn_fd != DIRFS_NOFD) line. Return ESTALE (the standard POSIX code for "stale file handle" β€” the file was unlinked, the fd is still valid but the path-based metadata refresh can no longer resolve).

Other callers (noted, not fixed β€” separate paths)

The following callers also deref dirfs_findfd's return without a NULL check, and would benefit from the same guard: - dirfs_readlink (vnops.c:1326-1329) β€” pathnp->dn_fd used in readlinkat - dirfs_nmkdir (vnops.c:1054-1055) β€” dnp1->dn_fd used as pfd - dirfs_alloc_file (subr.c:191-194) β€” pathnp->dn_fd used in openat - dirfs_open_helper (subr.c:596-597) β€” pathnp->dn_fd used as parentfd

These are separate trigger paths (not the fstat→getattr chain cited in this finding) and are left for follow-up hardening.

Fix validation (Phase 8)

  • patch -p1 --dry-run: RC=0, both hunks succeed (dirfs_subr.c:477, dirfs_vnops.c:388).
  • Patched source reads correctly: verified if (dnp1 == NULL) break; at subr.c:479-480 and the if/else ESTALE guard at vnops.c:389-397.
  • Compile-neutral proven: compiled PATCHED and UNPATCHED dirfs_subr.c in the VKERNEL64_DIRFS build environment. Both fail with the identical error set β€” all pre-existing M_WAITOK/M_ZERO/kmalloc/kfree undeclared at lines 62, 63, 80, 392, 466 (missing #include <sys/malloc.h> β€” a pre-existing source bug unrelated to this fix). Zero errors at the fix lines 479-480. Patched ≑ unpatched.
  • Clean compile with include fix: adding #include <sys/malloc.h> (the pre-existing missing include) to the patched source β†’ compiles cleanly to a 216 KB .o with zero errors. This proves the fix itself is valid C that the compiler accepts.
  • Live boot test: not_testable β€” dirfs is not in the host kernel and no vkernel runs on this guest, so the fstatβ†’dirfs_getattrβ†’dirfs_findfd path cannot be exercised on a live kernel here. The harness transcription is the deterministic proof that the fix closes the panic.

PoC changes

  • harness.c β€” written from scratch (no pre-existing PoC). Faithful transcription of dirfs_findfd (dirfs_subr.c:450-497) with both BUGGY (KKASSERT) and FIXED (break+NULL) variants, plus the unlink setup from dirfs_nremove (vnops.c:914-916).
  • build.sh / run.sh β€” standard build/run wrappers.
  • fix.diff β€” authored post-verification: break+NULL in findfd + ESTALE guard in getattr.

How to reproduce

ssh dfbsd-maxx   # unprivileged (uid 1001)
cd poc/DF-0855
./build.sh && ./run.sh
# expected: "BUG_PANIC_ON_UNLINKED_NODE = YES" and
#           "FIX_RETURNS_NULL_NO_PANIC = YES", exit 0

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED at compile level (not_testable for live boot -- dirfs is vkernel64-only, not in the host kernel). fix.diff applies cleanly (patch -p1 --dry-run RC=0, both hunks succeed at subr.c:477 and vnops.c:388). Compile-neutral proven: patched == unpatched dirfs_subr.c in the VKERNEL64_DIRFS build env produce IDENTICAL error sets (all pre-existing M_WAITOK/M_ZERO/kmalloc/kfree undeclared at lines 62,63,80,392,466 from missing #include -- zero errors at the fix lines 479-480). With the pre-existing include added, the patched source compiles cleanly to a 216KB .o with zero errors. The harness FIXED transcription deterministically proves the fix closes the panic (returns NULL gracefully instead of KKASSERT).

baseline (unpatched dirfs_subr.c): KKASSERT(dnp1 != NULL) at :480 -- harness proves PANIC fires on unlinked node (panic=1)
patched (fix.diff applied): if (dnp1 == NULL) break; at :479-480 -- harness proves no panic, returns NULL (panic=0), caller returns ESTALE
compile-neutral: patched == unpatched (identical pre-existing errors at lines 62,63,80,392,466; zero new errors at fix lines)
clean compile (+include): PATCHED dirfs_subr.c -> 216040-byte .o, COMPILE_RC=0
↓ fix.diffn/a (vkernel64-only module; compile-validated, not boot-tested)

Confirmed kernel references

Detail

Exploit chain

none (not applicable). This is a NULL-deref / KKASSERT panic -- a DoS, not a memory-corruption primitive. There is no write, no UAF, no type confusion, no slab involvement. The harness proves the panic fires deterministically on the first loop iteration when cur->dn_parent==NULL. No escalation path to develop; the realistic ceiling is kernel panic / DoS (vkernel process crash).

Evidence (decisive lines)

[AFTER dirfs_nremove: file->dn_parent = NULL (unlinked, vnode alive)]
[fstat(fd) -> VOP_GETATTR -> dirfs_getattr -> dirfs_findfd]
  BUGGY: pathnp=0x0 panic=1
         ** PANIC: assertion "dnp1 != NULL" failed in dirfs_findfd at dirfs_subr.c:480
         ** => kernel panic (KKASSERT w/ INVARIANTS) or NULL deref (without)
  FIXED: pathnp=0x0 tmp=NULL pf=0x0
         => no panic, no leak; caller (dirfs_getattr) returns ESTALE
=== SUMMARY ===
DF_0855_BUG_PANIC_ON_UNLINKED_NODE   = YES (KKASSERT fires)
DF_0855_FIX_RETURNS_NULL_NO_PANIC    = YES
RUN_EXIT=0

PoC changes

Written from scratch (no pre-existing PoC in findings/poc/DF-0855/). harness.c faithfully transcribes dirfs_findfd (dirfs_subr.c:450-497) with both BUGGY (KKASSERT) and FIXED (break+NULL) variants, plus the dirfs_nremove unlink setup (vnops.c:906-922). build.sh/run.sh are standard wrappers. fix.diff authored post-verification: 2 hunks (dirfs_subr.c:479-480 KKASSERT->break+NULL mirroring absolute_path_plus; dirfs_vnops.c:388-397 NULL guard returning ESTALE).

Verified recommended fix

Replace KKASSERT(dnp1 != NULL) with 'if (dnp1 == NULL) break;' in dirfs_findfd (dirfs_subr.c:480), mirroring the correct pattern already in dirfs_node_absolute_path_plus (:422-424); the existing 'if (dnp1 && count <= MAXPATHLEN)' at :483 then handles the NULL return. Additionally add a NULL guard in dirfs_getattr (dirfs_vnops.c:389-394) returning ESTALE when pathnp==NULL, since the caller's KKASSERT(pathnp->dn_fd) would otherwise NULL-deref. Supersedes finding proposal (the finding proposed 'return NULL instead of KKASSERT + callers check NULL return ESTALE' -- this implements exactly that, with the specific caller fix for the cited fstat->getattr trigger path). The full git-apply-able diff lives in findings/poc/DF-0855/fix.diff.

Verdict

REPRODUCED (deterministic code-level harness). The KKASSERT panic in dirfs_findfd (dirfs_subr.c:479-480) on an unlinked dirfs node (dn_parent==NULL) is confirmed by faithful line-by-line transcription of the buggy loop (:469-481) and the unlink setup from dirfs_nremove (vnops.c:914-916). The bug: the while-loop walks dnp1=dnp1->dn_parent then KKASSERT(dnp1!=NULL) -- when dirfs_nremove sets dnp->dn_parent=NULL after a successful unlinkat, any subsequent VOP on the still-open fd (e.g. fstat->VOP_GETATTR->dirfs_getattr->dirfs_findfd at vnops.c:389) hits the KKASSERT and panics. The sibling function dirfs_node_absolute_path_plus (:422-424) handles the identical NULL-parent case correctly with 'if (dnp1==NULL) break;', proving the guard was simply omitted from dirfs_findfd. dirfs is vkernel64-only (grep sys/conf/files=0, grep sys/platform/vkernel64/conf/files=3 'optional dirfs', /boot/kernel/dirfs* absent, kldstat empty) so a live host-kernel trigger is impossible; the deterministic harness is the accepted proof per DF-0806/0807/0808 precedent.