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)
PoC verification
Evidence pack
findings/poc/DF-0855 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 setupbuild.sh/run.shβ build and run wrappersfix.diffβ git-apply-able unified diff (2 hunks)VERDICT.mdβ full analysisbuild.log/run.log/run.stress.logβ full logsfix_build.logβ compile-neutral validationenv.txtβ guest environmentmanifest.jsonβ artifact catalog
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.
How dn_parent becomes NULL (the unlink path)
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:
- Happy path (parent intact): both BUGGY and FIXED
dirfs_findfdsucceed, returning the root node with the relative path. - Unlinked path (simulating
dirfs_nremovesettingdn_parent = NULL): - BUGGY:KKASSERT(dnp1 != NULL)fires β PANIC (simulated asg_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.cin the VKERNEL64_DIRFS build environment. Both fail with the identical error set β all pre-existingM_WAITOK/M_ZERO/kmalloc/kfreeundeclared 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.owith 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 thefstatβdirfs_getattrβdirfs_findfdpath 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 ofdirfs_findfd(dirfs_subr.c:450-497) with both BUGGY (KKASSERT) and FIXED (break+NULL) variants, plus the unlink setup fromdirfs_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
fixedVALIDATED 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
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
Confirmed kernel references
- sys/vfs/dirfs/dirfs_subr.c:469
- sys/vfs/dirfs/dirfs_subr.c:479
- sys/vfs/dirfs/dirfs_subr.c:480
- sys/vfs/dirfs/dirfs_subr.c:483
- sys/vfs/dirfs/dirfs_subr.c:422
- sys/vfs/dirfs/dirfs_subr.c:423
- sys/vfs/dirfs/dirfs_vnops.c:388
- sys/vfs/dirfs/dirfs_vnops.c:389
- sys/vfs/dirfs/dirfs_vnops.c:391
- sys/vfs/dirfs/dirfs_vnops.c:914
- sys/vfs/dirfs/dirfs_vnops.c:916
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.
No comments yet.