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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2717 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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_initsys/kern/vfs_nlookup.c:149-166 (both/and relative branches readfd_nrdir/fd_ncdir/fd_njdirviacache_copy);nlookup_init_at:225-228,242;nlookup()generation-retry :1376-1378 re-dereferencesnl_basench(a pointer intofdp->fd_ncdir/fd_nrdir) after an arbitrarily long walk. - Writers:
kern_chdirvfs_syscalls.c:1906-1911 andkern_fchdir:1900-1915 holdfd_spinaround the two-word store and drop the old nchandle after unlock; mount-reload callback :572-593 holdsfd_spin;fdcopy/fdsharekern_descrip.c:2411-2431/2528-2543 holdfd_spin.kern_chrootvfs_syscalls.c:2059-2066 updatesfd_nrdir/fd_njdirunderp_tokenonly β nofd_spin, so even writer-vs-writer protocol is asymmetric. cache_copysys/kern/vfs_cache.c:996-1026:ncp = nch->ncp(:1006) andmp = nch->mount(:1007) are two adjacent non-atomic loads; the reference is taken only later (_cache_hold(ncp):1023, guarded byKKASSERT(ncp->nc_refs > 0)at :735) or stolen from the per-CPU mntcache._cache_dropat 1β0 setsnc_refs = -1(:763) and callskfree_objimmediately (: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):
rmdir(v)whilevis the process cwd defers the zap (NCF_DEFEREDZAP) becausenc_refs != refcmpβ the fd-table ref is held. Single-threadedrmdir(cwd); ls ..works fine (verified on the guest) because the topology stays connected.- Under full-storm churn the deferred zap completes while
fd_ncdirstill holds the last reference: the node is unlinked (nc_parent = NULL, unhashed, unnamed) yet stays alive, referenced almost solely throughfd_ncdir. - 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. - The
".."handler runsnctmp.ncp = nctmp.ncp->nc_parent(vfs_nlookup.c:756) with no NULL check, the NULL flows throughcache_copy(NULL-safe) intonch, andnch.ncp->nc_generationat :767 faults (NULL+0x84). The intended NULL-parent handling ((par.ncp = nch.ncp->nc_parent) != NULLat :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_spinaround the fd_nchandle reads innlookup_init(both branches incl.fd_njdir), guardedfd_spininnlookup_init_atand in thenlookup()retry whennl_basenchpoints into the caller'sfdp;kern_chrootnow takesfd_spinaround itsfd_nrdir/fd_njdirupdates withvrele/cache_dropdeferred outside the spinlock (matcheskern_chdir's pattern).cache_copyis 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 withnctmp = 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_njdirhas no concurrent writer once a jail exists (onlykern_chrootbefore 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_debugsysctl 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_testableConfirmed kernel references
- sys/kern/vfs_nlookup.c:149
- sys/kern/vfs_nlookup.c:159
- sys/kern/vfs_nlookup.c:225
- sys/kern/vfs_nlookup.c:242
- sys/kern/vfs_nlookup.c:756
- sys/kern/vfs_nlookup.c:767
- sys/kern/vfs_nlookup.c:1376
- sys/kern/vfs_cache.c:1006
- sys/kern/vfs_cache.c:1023
- sys/kern/vfs_cache.c:763
- sys/kern/vfs_cache.c:766
- sys/kern/vfs_syscalls.c:1906
- sys/kern/vfs_syscalls.c:2059
- sys/kern/kern_descrip.c:2411
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.
No comments yet.