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

nlookup_init()/retry path read the shared fd_ncdir/fd_nrdir nchandle pairs without fd_spin while sibling threads swap them β€” torn (ncp,mount) pairs and hold-after-drop race on the jail '..'-gate comparison inputs

Field Value
ID DF-2717
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L
CWE CWE-362 Improper Synchronization
File sys/kern/vfs_nlookup.c
Lines 149-161, 225-242, 1380 (torn read vfs_cache.c:996-1025)
Area kern
Confidence likely
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

Writers serialize fd_ncdir/fd_nrdir under fdp->fd_spin (kern_fchdir, kern_chdir) β€” and kern_chroot updates fd_nrdir under p_token only, so the writers are not even mutually consistent. The nlookup reader side takes no lock at all: nlookup_init caches nl_basench and immediately cache_copy()s it (a non-atomic two-word load then _cache_hold on the loaded ncp), nlookup_init_at likewise, and every generation-mismatch retry re-dereferences nl_basench after an arbitrarily long walk. Under x86 TSO a chdir/fchdir storm from a sibling thread (fully unprivileged) can interleave to give the reader (old ncp, new mount) β€” a mismatched handle whose mount half flows into the root/jail '..'-gate equality check (confinement is solely mount+ncp equality) β€” or, in the tighter corner where the old cwd ncp is simultaneously being torn out of the namecache topology, a _cache_hold on an ncp whose last ref was just dropped β€” a use-after-free-grade refcount corruption. An established reader protocol exists (checkdirs takes fd_spin shared); nlookup is the hot path that omits it.

Threat model & preconditions

Unprivileged multi-threaded local process: thread A in path lookup while thread B hammers chdir/fchdir. Realistic impact: correctness anomalies/DoS-class misbehavior with a speculative jail-gate-bypass ceiling. Not guest-verified (two-adjacent-loads window; no deterministic oracle without instrumentation) β€” stands on the static protocol violation.

Snapshot the pairs under the existing lock: take spin_lock_shared(&p->p_fd->fd_spin) around the cache_copy in nlookup_init/nlookup_init_at, make kern_chroot update fd_nrdir under fd_spin like chdir/fchdir already do, and stop re-reading nl_basench on retry (use the init-time snapshot or re-read under the spin).

Timeline

  • 2026-08-30 Discovered during pass-2 audit of vfs_nlookup.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2717 Β· 22 files
FileTypeDescriptionSize
df2717_storm.c β€” 6.4 KB view raw
bench_open.c β€” 1.1 KB view raw
build.sh β€” 138 B view raw
run.sh β€” 172 B view raw
build.log β€” 125 B view raw
smoke.log β€” 117 B view raw
run-S1-swap-only.log β€” 682 B view raw
run-S2-dance-only.log β€” 734 B view raw
run-S3-full-panic.log β€” 786 B view raw
panic-smoke-console.txt β€” 13.6 KB view raw
panic-2-console.txt β€” 14.5 KB view raw
kgdb-bt.log β€” 2.9 KB view raw
kgdb-frame6-locals.log β€” 3.3 KB view raw
bench-stock.log β€” 140 B view raw
bench-fixed.log β€” 140 B view raw
run-fixed-storm.log β€” 686 B view raw
buildkernel-fixed.log β€” 5.6 MB ↓ download
env.txt β€” 883 B view raw
fix.diff β€” 3.6 KB view raw
VERDICT.md β€” 9.2 KB ↓ raw
manifest.json β€” 1.7 KB view raw
verdict.json β€” 4.9 KB view raw
VERDICT.md
↓ download raw

DF-2717 β€” VERDICT

Finding: nlookup_init()/retry path read the shared fd_ncdir/fd_nrdir nchandle pairs without fd_spin while sibling threads swap them β€” torn (ncp,mount) pairs and hold-after-drop race. sys/kern/vfs_nlookup.c:149-161. Filed Medium, "static protocol violation; not live-verified".

Verdict: The locking-protocol violation is confirmed by inspection, line-accurate β€” every reader and writer site was mapped and the two non-atomic loads plus the delayed _cache_hold in cache_copy were verified in source. The two race primitives claimed by the finding (torn two-word pair; _cache_hold after last-ref kfree_obj) did not land live within a quantified dedicated budget on the 6-vCPU INVARIANTS guest. However, the prescribed storm (chdir/fchdir swap + relative ".." lookups + mkdir/rmdir churn) panicked the guest 3/3 times in ≀90 s each β€” crash-dump analysis attributes the panic to a distinct adjacent bug in the same ".." machinery (unguarded NULL nc_parent at vfs_nlookup.c:756 dereferenced at :767), for which the DF-2717 unlocked reads are the delivery vehicle but not a necessary condition. Honest bottom line for DF-2717 itself: protocol violation real and certain at the source level; exploit window measured narrow (10^8-scale opportunities without a hit); no privilege escalation demonstrated. Recommend: keep as Medium hardening with the budget documented; file the NULL-parent panic separately (unpriv reliable local DoS β€” see below).

1. Protocol analysis (all path:line verified)

  • Readers with NO lock: nlookup_init sys/kern/vfs_nlookup.c:149-166 (both / and relative branches read fd_nrdir/fd_ncdir/fd_njdir via cache_copy); nlookup_init_at :225-228,242; nlookup() generation-retry :1376-1378 re-dereferences nl_basench (a pointer into fdp->fd_ncdir/fd_nrdir) after an arbitrarily long walk.
  • Writers: kern_chdir vfs_syscalls.c:1906-1911 and kern_fchdir :1900-1915 hold fd_spin around the two-word store and drop the old nchandle after unlock; mount-reload callback :572-593 holds fd_spin; fdcopy/fdshare kern_descrip.c:2411-2431/2528-2543 hold fd_spin. kern_chroot vfs_syscalls.c:2059-2066 updates fd_nrdir/fd_njdir under p_token only β€” no fd_spin, so even writer-vs-writer protocol is asymmetric.
  • cache_copy sys/kern/vfs_cache.c:996-1026: ncp = nch->ncp (:1006) and mp = nch->mount (:1007) are two adjacent non-atomic loads; the reference is taken only later (_cache_hold(ncp) :1023, guarded by KKASSERT(ncp->nc_refs > 0) at :735) or stolen from the per-CPU mntcache. _cache_drop at 1β†’0 sets nc_refs = -1 (:763) and calls kfree_obj immediately (:766).

Consequences as filed are therefore source-certain: (a) a chdir/fchdir store can interleave between :1006 and :1007 producing a mismatched (ncp,mount) handle that flows into the mount-boundary and jail .. gates (vfs_nlookup.c:716-718, 745-746); (b) the gap between the load at :1006 and the hold at :1023 admits hold-after-free.

2. Live race budget (unprivileged user, stock INVARIANTS kernel)

Run Mode Time Volume Result
S1 swap-only 0x9 150 s 156,107,562 cross-mount fchdir swaps (1.04 M/s) vs 161,845,152 reader nlookups clean
S2 dance-only 0xc 120 s 399,820 last-ref-drop dance cycles vs 122,075,241 reader opens clean
S3 no-fchdir 0xe 60 s 8,186,689 relchdirs + 365,732 dances + 57,102,855 opens clean
S3 full 0xf ≀90 s β€” panic 3/3

The DF-2717 windows are real but narrow: 1.6Γ—10^8 swap-vs-copy opportunities (torn pair) and 1.2Γ—10^8 copy-vs-drop opportunities (hold-after-drop) produced zero hits. On this x86-64 guest the two-adjacent-loads/store interleaving effectively requires same-cycle co-residency that the scheduler rarely yields; likewise the freed chunk is typically re-used before the delayed hold. No KKASSERT in _cache_hold, no refcount corruption, no escalation. DF-2717 alone: not reproduced (impact of its own primitive: none within budget).

3. The panic that DID reproduce β€” adjacent bug, root-caused

Signature (3/3, identical): panic: page fault, nlookup() at nlookup+0x66c (sys/kern/vfs_nlookup.c:767) from kern_chdir(), fault VA 0x84, %rax = 0.

kgdb on /var/crash/vmcore.0 (logs in pack): nch = {ncp = 0x0}, nctmp = {ncp = 0x0}, nd->nl_nch.ncp = node with nc_parent = 0, nc_head = 0, nc_name = 0, nc_flag = 0x1210, nc_refs = 30, path "../../../tmp/df2717/a", element "..", last_element = 0; nl_basench's value sat on the hammer2 mount while the walk base sat on tmpfs (walk re-based mid-syscall through the init copy / retry re-deref).

Mechanism (each step source-verified):

  1. rmdir(v) while v is the process cwd defers the zap (NCF_DEFEREDZAP) because nc_refs != refcmp β€” the fd-table ref is held. Single-threaded rmdir(cwd); ls .. works fine (verified on the guest) because the topology stays connected.
  2. Under full-storm churn the deferred zap completes while fd_ncdir still holds the last reference: the node is unlinked (nc_parent = NULL, unhashed, unnamed) yet stays alive, referenced almost solely through fd_ncdir.
  3. Reader/walker threads adopt the disconnected node as walk base via the DF-2717 sites (cache_copy(&fdp->fd_ncdir) at vfs_nlookup.c:159-160, retry re-deref at :1376-1378) β€” 30 references had accumulated by panic time.
  4. The ".." handler runs nctmp.ncp = nctmp.ncp->nc_parent (vfs_nlookup.c:756) with no NULL check, the NULL flows through cache_copy (NULL-safe) into nch, and nch.ncp->nc_generation at :767 faults (NULL+0x84). The intended NULL-parent handling ((par.ncp = nch.ncp->nc_parent) != NULL at :831) exists but runs after the unguarded deref.

Attribution to DF-2717: step 3 uses the finding's unlocked read sites as the delivery vehicle, but a correctly locked reader would copy the same disconnected handle (the value in fd_ncdir is legitimate between the deferred-zap completion and the chdir-away). The panic therefore does not depend on the missing lock; its root causes are the deferedzap-vs-cwd disconnect and the missing NULL guard. It should be filed as its own finding: unprivileged, reliable local DoS (panic) with a trivial trigger program (this pack's df2717_storm 90 3 0xf).

4. Fix and A/B validation

fix.diff has two parts:

  • Part A (DF-2717): fd_spin around the fd_nchandle reads in nlookup_init (both branches incl. fd_njdir), guarded fd_spin in nlookup_init_at and in the nlookup() retry when nl_basench points into the caller's fdp; kern_chroot now takes fd_spin around its fd_nrdir/fd_njdir updates with vrele/cache_drop deferred outside the spinlock (matches kern_chdir's pattern). cache_copy is spinlock-safe (atomics only; the source comment at vfs_cache.c:725-732 explicitly blesses spinlock-holding callers).
  • Part B (the reproduced panic): after nctmp.ncp = nctmp.ncp->nc_parent, recover with nctmp = nd->nl_rootnch β€” the same recovery the function already uses for the jail-break NULL case β€” instead of dereferencing NULL. (Semantics note: ".." from a topology-disconnected cwd then resolves relative to the process root; jailed processes get their jail root, so no escape is introduced.)

Validation performed on the guest (patched kernel #1: Tue Oct 20 08:31:33 UTC 2026 built from the guest's /usr/src with exactly the diff below β€” verified byte-identical to the validated sources; full kernel build log: buildkernel-fixed.log, RC=0):

  • Full-mix storm re-run twice, 150 s each: no panic, guest up (run-fixed-storm.log; run 1: 114,908,038 swaps + 3,144,309 dance cycles + 138,997,980 opens; run 2: 162,930,640 swaps + 2,326,609 dance cycles + 131,940,060 opens). Baseline: 3/3 panics in ≀90 s. No "..' traverse disconnected" guard messages fired in these two runs (the underlying disconnect chain is probabilistic; the guard is defense-in-depth for the crash case).
  • Microbench (2,000,000 relative-path open() best-of-3): stock 458,786 ops/s (2.18 Β΅s/op) vs patched 450,273 ops/s (2.22 Β΅s/op) β€” β‰ˆ1.9 % delta, within the guest's run-to-run noise band (stock itself varied 454,552–458,786 across its two runs).

5. Notes

  • Jail variant not run: fd_njdir has no concurrent writer once a jail exists (only kern_chroot before jail setup writes it), so the torn-read surface for the jail nch copy is nil; the ".." gate itself (vfs_nlookup.c:745-749) was exercised by every storm run via the tmpfs/hammer2 mount crossings.
  • The debug.nlookup_debug sysctl bits are kprintf verbosity only (vfs_nlookup.c:969,1043,1153,1177,1256,1314,1368) β€” they cannot force the retry path; retries occurred naturally from the dance's generation churn.
  • Guest was returned to a clean state (vm.sh reset with-src) after validation.

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
↓ fix.diffper-fix-DF-2717

Confirmed kernel references

Detail

Evidence (decisive lines)

Dedicated sub-storms: run-S1-swap-only.log (150 s clean, 1.56e8 swaps), run-S2-dance-only.log (120 s clean, 1.22e8 opens); adjacent panic: panic-smoke-console.txt + panic-2-console.txt + run-S3-full-panic.log (3/3 nlookup+0x66c) and kgdb-frame6-locals.log (nch.ncp=0x0, nl_nch.ncp nc_parent=0 nc_refs=30, path ../../../tmp/df2717/a); fix validation: run-fixed-storm.log (patched kernel clean), bench-stock.log vs bench-fixed.log

PoC changes

Replaced the seed sketch with a mode-selectable pthread storm (df2717_storm.c argv[3] bitmask): bit0 fchdir cross-mount swapper (tmpfs vs hammer2 dirs for distinct mount pointers), bit1 relative ..-walking chdir swapper, bit2 volatile dance (mkdir vNNN / chdir vNNN / rmdir vNNN / chdir parent) that manufactures last-ref drops of fd_ncdir-held namecache nodes, bit3 relative-path open readers; added stats output and a bench_open.c microbenchmark for the fix A/B.

Verified recommended fix

Take fd_spin around the fd_ncdir/fd_nrdir/fd_njdir cache_copy reads in nlookup_init/nlookup_init_at/nlookup retry, make kern_chroot use fd_spin like kern_chdir, and NULL-guard nctmp.ncp->nc_parent in the .. walk (fall back to nl_rootnch) so a topology-disconnected cwd cannot panic line 767.

Verdict

Locking protocol violation confirmed line-accurate by inspection: nlookup_init (sys/kern/vfs_nlookup.c:149-166), nlookup_init_at (:225-228,242) and the nlookup retry (:1376-1378) read the shared fd_ncdir/fd_nrdir nchandle pairs with no fd_spin, while kern_chdir/kern_fchdir write them under fd_spin and kern_chroot (vfs_syscalls.c:2059-2066) writes fd_nrdir under p_token only; cache_copy (vfs_cache.c:996-1026) is two adjacent non-atomic loads with a delayed _cache_hold. Both claimed race primitives were storm-tested live and did NOT land in a quantified budget: 156,107,562 cross-mount fchdir swaps vs 161,845,152 reader nlookups in 150 s (torn pair) and 399,820 last-ref-drop dance cycles vs 122,075,241 reader copies in 120 s (hold-after-drop) both ran clean, so no escalation, no leak, no refcount corruption was observed for DF-2717 itself. However the prescribed full storm panicked the guest 3/3 times within 90 s (nlookup+0x66c = vfs_nlookup.c:767, NULL+0x84); crash-dump analysis proves this is a DISTINCT adjacent bug: rmdir of the process cwd defers a namecache zap which completes under churn while fd_ncdir still holds the last ref, leaving a topology-disconnected node (nc_parent=NULL) that the unlocked fd_ncdir reads deliver into the .. walk, whose unguarded nctmp.ncp = nctmp.ncp->nc_parent at :756 feeds a NULL nch.ncp dereferenced at :767. The panic does not depend on the missing lock (a locked reader copies the same disconnected handle) and should be filed separately as an unprivileged reliable local DoS. fix.diff carries both the fd_spin protocol fix (Part A) and the NULL-parent guard (Part B); the patched kernel survived the same storm budget that panicked 3/3 and shows no measurable open() throughput regression.