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)
PoC verification
Evidence pack
findings/poc/DF-3055 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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_testablefix.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
Confirmed kernel references
- sys/vfs/dirfs/dirfs_vnops.c:1194
- sys/vfs/dirfs/dirfs_vnops.c:1196
- sys/vfs/dirfs/dirfs_vnops.c:1199
- sys/vfs/dirfs/dirfs_vnops.c:1200
- sys/vfs/dirfs/dirfs_vnops.c:1067
- sys/vfs/dirfs/dirfs_vnops.c:1070
- sys/vfs/dirfs/dirfs_vnops.c:1072
- sys/vfs/dirfs/dirfs_vnops.c:139
- sys/sys/event.h:168
- sys/vfs/dirfs/dirfs_subr.c:196
- sys/vfs/dirfs/dirfs_subr.c:202
- sys/vfs/dirfs/dirfs_subr.c:212
- sys/kern/vfs_syscalls.c:kern_symlink
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.
No comments yet.