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

dirfs_nsymlink/dirfs_nmkdir execute the success path when dirfs_alloc_file() fails: dirfs_knote(*vpp==NULL) SIGSEGV, negative-cache of an existing name, and the real error clobbered by stale errno

Summary

dirfs_nsymlink runs cache_setvp (:1199) and dirfs_knote(*vpp,...) (:1200) unconditionally after dirfs_alloc_file (:1194); on failure *vpp was never assigned (alloc_file assigns only on success; kern_symlink pre-initializes vp=NULL) so :1200 computes KNOTE(&NULL->v_pollinfo...) -> NULL deref -> vkernel panic. :1196-1197 replaces the real error with stale libc errno (0 -> fake SUCCESS); :1199 negative-caches a name whose object EXISTS. dirfs_nmkdir repeats the clobber (:1070) and cache_setvp(NULL) (:1072). Unpriv vkernel user wins a create/remove race (concurrent same-uid unlink between host symlink()/mkdirat() and alloc_file's fstatat - winnable in a loop) -> vkernel crash; near-misses leave namecache negative-caching an existing name and wrong/fake-success returns. VERIFIED deterministically by two-TU harness transcription (errno-clobber table incl. fake-success, SIGSEGV in vulnerable variant, fixed variant propagates ENOENT). Fix: gate the success path on error==0 and propagate the real error.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3055 Β· 15 files
FileTypeDescriptionSize
harness.c trigger-source deterministic transcription of the vulnerable code path 6.5 KB view raw
knote.c trigger-source KNOTE/knote machinery TU (gcc8 NULL-fold workaround) 1.5 KB view raw
build.sh build-script cc command line 465 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 689 B view raw
run.2.log run-log determinism check 2 689 B view raw
run.3.log run-log determinism check 3 689 B view raw
fix.diff suggested-fix git-apply-able unified diff (never applied to sys/) 1.4 KB view raw
fix_base_vnops.log fix-log baseline compile error (vkernel64 env) 1.4 KB view raw
fix_p3055_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 4.3 KB ↓ raw
verdict.json verdict machine verdict (persist_poc.py schema) 4.7 KB view raw
README.md readme how to reproduce 650 B ↓ raw
README.md readme how to reproduce
↓ download raw

DF-3055 β€” evidence pack

Finding: dirfs_nsymlink/nmkdir run the success path when dirfs_alloc_file fails: dirfs_knote(NULL) SIGSEGV, negative-cache of an existing name, error clobbered by stale errno

Verdict: REPRODUCED (deterministic two-TU harness; dirfs is vkernel-only). Impact: vkernel crash (racy create/remove trigger) + persistent namecache inconsistency + errno clobber (incl. fake success).

Reproduce

./build.sh   # cc -O2 -Wall -o harness harness.c knote.c
./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-3055 β€” dirfs_nsymlink/dirfs_nmkdir execute the SUCCESS path when dirfs_alloc_file() fails: dirfs_knote(NULL) crash, negative-cache of an existing name, error clobbered by stale errno

Verdict

REPRODUCED (deterministic harness, 3/3 runs identical) β€” when dirfs_alloc_file() fails after the host symlink()/mkdirat() succeeded, dirfs_nsymlink still executes its success block:

:1194  error = dirfs_alloc_file(dmp, &dnp, pdnp, ncp, vpp, NULL, 0);
:1196  if (error)
:1197          error = errno;              /* real error clobbered by STALE errno */
:1198  cache_setunresolved(ap->a_nch);
:1199  cache_setvp(ap->a_nch, *vpp);       /* *vpp == NULL -> negative entry for an EXISTING name */
:1200  dirfs_knote(*vpp, NOTE_WRITE);      /* NULL deref -> KNOTE(&NULL->v_pollinfo...) */

kern_symlink() initializes vp = NULL before VOP_NSYMLINK and dirfs_alloc_file only assigns *vpp on success (subr.c:212-213), so *vpp is NULL on every failure path (openat failure subr.c:196-199, stat failure subr.c:202-210). dirfs_knote() computes &vp->v_pollinfo.vpi_kqinfo.ki_note and KNOTE (sys/sys/event.h:168) dereferences the list head β€” a NULL-pointer dereference (SIGSEGV/panic). The harness proves it with a two-TU transcription (the KNOTE machinery in a separate translation unit, mirroring dirfs_vnops.c vs kern_event.c; a single TU lets gcc8 fold the NULL away).

dirfs_nmkdir (vnops.c:1067-1073) has the same structure but its knote is on dvp (safe); it still executes cache_setvp(ap->a_nch, NULL) at :1072 and clobbers the error at :1070 β€” the kernel namecache negative-caches a name whose object EXISTS on the host, so subsequent lookups return ENOENT until the entry is invalidated, and mkdir reports a wrong (or 0 = success) error.

The errno clobber (proven)

:1196-1197 / :1070 replace the real dirfs_alloc_file error with the vkernel process's libc errno β€” which the LAST libc call did not set (the create succeeded; the failure was inside kern-level code). Harness output:

real alloc_file error=2 (ENOENT), stale errno=0  -> nsymlink returns 0 (SUCCESS despite failure!)
real alloc_file error=2 (ENOENT), stale errno=13 -> nsymlink returns 13
real alloc_file error=2 (ENOENT), stale errno=21 -> nsymlink returns 21

A stale errno of 0 turns a failed symlink() into a SUCCESS return with nothing created and a negative-cache entry β€” userland believes the symlink exists.

Reachability of the alloc_file failure

After symlink(ap->a_target, path) / mkdirat() succeed, alloc_file fails when its fstatat (dirfs_node_stat) fails β€” practically when the fresh object disappears in between, e.g. a concurrent same-uid process (another vkernel process, or the vkernel's host user outside the vkernel) unlinking in a loop; the window is findfd+fstatat but standard create/remove races win it. (The over-length-path case crashes earlier β€” that is DF-3054.)

Impact ceiling: vkernel crash (DoS) via the racy trigger + persistent namecache inconsistency; availability only.

Distinction from known findings

DF-0856 covers alloc_file's node/refcount LEAK on the openat error path β€” this finding is about what dirfs_vnops.c does AFTER the failure return (NULL deref / negative cache / errno clobber), not the leak.

Reproduction

ssh dfbsd-maxx
cd poc/DF-3055
./build.sh    # cc -O2 -Wall -o harness harness.c knote.c
./run.sh      # errno-clobber table + SIGSEGV in vulnerable variant; fixed variant survives

knote.c models the KNOTE/knote machinery with a volatile list head (gcc8 otherwise deletes the NULL deref as dead/UB code β€” documented in the harness comments; several iterations were needed to defeat gcc8's IPA constant-propagation + UB-DCE, see poc_changes in verdict.json).

Result (identical over 3 runs):

[nsymlink-vuln] child killed by SIGSEGV -- NULL DEREF at dirfs_knote(*vpp) CONFIRMED
[nsymlink-fixed] returned error=2        (real ENOENT propagated, no crash)

Fix validation

fix.diff gates the success blocks on error == 0 and drops the errno clobber (propagate the real error): git apply --check RC=0 on the local tree and guest /usr/src; compile-neutral (identical first compiler error patched vs unpatched in the vkernel64 env β€” fix_*.log); harness FIXED variant validates the behavior. 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_p3055_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: symlink() loop in dirfs dir + concurrent unlink() by same-uid process (another vkernel proc or host shell) -> occasionally fstatat(ENOENT) after successful symlink() -> dirfs_nsymlink :1200 KNOTE(NULL->v_pollinfo) -> vkernel panic (DoS). Non-crash residue on near-misses: negative-cache of an existing name (lookups return ENOENT until invalidation) and errno-clobbered returns (incl. fake success).

Evidence (decisive lines)

harness.c + knote.c (two-TU transcription; KNOTE/knote machinery separate, mirroring dirfs_vnops.c vs kern_event.c); run.log: errno table showing 'stale errno=0 -> returns 0 (SUCCESS despite failure!)' and '[nsymlink-vuln] child killed by SIGSEGV -- NULL DEREF at dirfs_knote(*vpp) CONFIRMED'; fixed variant returns 2 (real ENOENT). run.2/run.3 identical.

PoC changes

Harness written fresh (no seed). 6 attempts to make the NULL deref survive gcc 8's optimizer: empty-branch KNOTE removed by DCE; volatile-pointer cast was not a volatile lvalue; noinline knote still IPA-folded; volatile function pointer devirtualized; attribute((optnone)) ignored by cc 8.3; FINAL: two-TU build with the knote/KNOTE machinery in knote.c and a volatile-qualified list head -> load cannot be elided, SIGSEGV reproduces deterministically. All 6 iterations documented in the harness header comment.

Verified recommended fix

dirfs_nsymlink/dirfs_nmkdir: only run cache_setvp/dirfs_knote when dirfs_alloc_file returned 0; propagate its error instead of overwriting it with stale errno.

Verdict

REPRODUCED (deterministic harness; dirfs vkernel-only, no live-boot test). When dirfs_alloc_file fails after the host symlink()/mkdirat() succeeded (fstatat loses a create/remove race with a same-uid process), dirfs_nsymlink runs its success block anyway: :1196-1197 clobber the real error with STALE libc errno (0 -> returns SUCCESS for a failed symlink), :1199 cache_setvp(nch, NULL) negative-caches a name whose object EXISTS, and :1200 dirfs_knote(*vpp==NULL) dereferences KNOTE(&NULL->v_pollinfo.vpi_kqinfo.ki_note) -> SIGSEGV. dirfs_nmkdir :1070/:1072 has the same clobber + negative-cache. Harness proves the errno-clobber table and the SIGSEGV; fixed variant (gate success block on error==0) propagates ENOENT and survives.