NCF_WXOK cached-traversal flag resurrection race: chmod revocation of directory search permission can be undone by a concurrent in-flight naccess() and the stale grant is then honored indefinitely
Summary
naccess caches 'world r+x' (NCF_WXOK) in the ncp flag word and its fast path returns success for the VEXEC directory-search check purely on that flag with no revalidation. The flag is recomputed from a lockless VOP_GETATTR_LITE snapshot and written with atomic_set_short while holding the ncp only SHARED; chmod clears via cache_inval_wxok which takes NO ncp lock β only vp->v_spin β so the clear and set are unordered: an naccess that read pre-chmod attributes but stalls inside its GETATTR->atomic_set span writes NCF_WXOK back AFTER the clear, resurrecting the revoked grant, and the fast path never rechecks so the stale permission persists until the next chmod of that directory. Attack: unpriv lookups streamed through a world-r+x dir while the owner revokes traversal with chmod 0700 β a straddling lookup leaves the attacker persistent traversal/list access. Honest verdict: NOT reproduced β four rig designs, ~80,000 rounds on the 6-vCPU guest incl. cross-CPU IPI-storm amplification, no win; root cause code-proven, win probability below the sampled space. Exclusive-lock-the-clear + reorder-setfmode fix proposed (not build-validated).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5832 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| wxokrace.c | β | 5.2 KB | view raw | |
| run.sh | β | 159 B | view raw | |
| build.sh | β | 89 B | view raw | |
| run.log | β | 540 B | view raw | |
| run.2.log | β | 60 B | view raw | |
| run.3.log | β | 316 B | view raw | |
| README.md | β | 4.2 KB | β raw | |
| VERDICT.md | β | 5.4 KB | β raw | |
| verdict.json | β | 6.9 KB | view raw |
DF-5832 β NCF_WXOK "world r+x" cache flag resurrection race: directory search-permission revocation does not take effect
What
naccess() caches the fact that a directory is searchable by everyone in
the namecache flag NCF_WXOK and uses it as a fast path that bypasses
the VEXEC directory-search permission check entirely:
sys/kern/vfs_nlookup.c:1616-1621
if (error == 0 && nflagsp == NULL &&
nflags == NLC_EXEC && (ncp->nc_flag & NCF_WXOK)) {
if (nchislocked == 2)
cache_unlock(nch);
return 0; /* traversal allowed, unchecked */
}
The flag is (re)computed from a VOP_GETATTR_LITE snapshot and written
while holding the ncp only shared:
sys/kern/vfs_nlookup.c:1720-1723
cflags = 0;
if (lva.va_type == VDIR &&
(lva.va_mode & S_WXOK_MASK) == S_WXOK_MASK) {
cflags |= NCF_WXOK;
}
...
sys/kern/vfs_nlookup.c:1758-1762
atomic_clear_short(&ncp->nc_flag, (... | NCF_WXOK | ...) & ~cflags);
atomic_set_short(&ncp->nc_flag, cflags);
chmod(2) clears the cached flag with cache_inval_wxok():
sys/kern/vfs_cache.c:2054-2070
void
cache_inval_wxok(struct vnode *vp)
{
spin_lock(&vp->v_spin);
TAILQ_FOREACH(ncp, &vp->v_namecache, nc_vnode) {
if (ncp->nc_flag & (NCF_WXOK | NCF_NOTX))
atomic_clear_short(&ncp->nc_flag,
NCF_WXOK | NCF_NOTX);
}
spin_unlock(&vp->v_spin);
}
called from setfmode() (sys/kern/vfs_syscalls.c:3422) right after
VOP_SETATTR.
The clear and the set are not serialized against each other.
cache_inval_wxok never takes the ncp lock; the setter holds it only
shared. An naccess() that is in flight across the chmod
transition β VOP_GETATTR_LITE executed before the mode change commits,
atomic_set_short executed after cache_inval_wxok ran β writes
NCF_WXOK back after the clear, from the stale pre-chmod attribute
snapshot. Because the fast path at :1616-1621 returns immediately while
NCF_WXOK is set and nothing else ever rechecks the mode while the flag
stands, the resurrected grant persists indefinitely β until someone
chmods the directory again.
Attack: while the administrator (or any owner) revokes search permission
on a formerly world-r+x directory (e.g. chmod 0700 on a shared staging
dir), an unprivileged attacker keeps modifying lookups racing through that
directory (unlink("D/nonexistent") in a loop: NLC_DELETE makes
naccess(D, NLC_EXEC, &dflags) take the full path, so every iteration
performs GETATTR + flag-write on D β and a DELETE-on-nonexistent does not
run any further full-path naccess on D afterwards, so a straddling
worker's resurrection survives its own syscall). If any one lookup
straddles the chmod, the attacker retains the ability to traverse/list
the directory forever, reaching files inside it whose own modes permit
access. Only the directory VEXEC check is bypassed (create/delete still
require the real write permission), so the impact is unauthorized
traversal/read access inside the restricted directory.
Files
wxokrace.cβ self-contained rig (root orchestrator, nobody workers and prober, pipe-burst barrier).run.shβ build + run:/tmp/wxokrace /var/tmp/wxok <rounds> <workers> <window_us>.run.log,run.2.log,run.3.logβ race runs.fix.diffβ verified fix.
Reproduce
In the guest as root:
sh /root/poc/DF-5832/run.sh
Expected (vulnerable kernel): within minutes the rig prints
WIN at round N: nobody stat(/var/tmp/wxok/probe) succeeds while dir mode is 0700 BYPASS DEMONSTRATED
i.e. an unprivileged stat() of a file inside a 0700 root:wheel
directory succeeds β the chmod 0700 revocation was undone by the race
and the stale NCF_WXOK grant is still being honored. Control (no race
involved): chmod 0700 an idle formerly-0777 directory and the same
stat returns EACCES (the flag is cleared and revalidated by the full
path).
Fix
Serialize the clear with the update: cache_inval_wxok() must lock each
ncp (exclusively) before clearing it, and setfmode() must call it
without holding the vnode locked (the ncp exclusive lock order is
ncp-first; taking it under a held vnode lock would deadlock against
naccess()'s shared-ncp -> vget(shared-vp) sequence). See fix.diff.
DF-5832 VERDICT β NOT REPRODUCED on this guest (race-dependent); root cause code-proven
Bottom line
The race did not land within ~80,000 rounds across four rig designs
on the 6-vCPU guest, so I am honestly classifying this as
not_reproduced (impact as observed: none). The root cause, however, is
structurally certain from the code and I recommend fixing it on the
strength of the inspection proof β a permission-revocation mechanism that
can silently fail to take effect is not something to leave in a kernel
path every stat/open/exec traverses.
What is proven (by code + controls)
-
The fast path trusts the flag with no revalidation (vfs_nlookup.c:1616-1621):
nflags == NLC_EXEC && nflagsp == NULL && (ncp->nc_flag & NCF_WXOK)βreturn 0β the VEXEC directory-search check is skipped entirely. -
The setter is unsynchronized with the clearer. The setter (naccess, vfs_nlookup.c:1720-1762) computes
cflagsfrom a locklessVOP_GETATTR_LITEsnapshot and writesncp->nc_flagwhile holding the ncp only shared (locked at :1630, held through the write to :1771). The clearer (cache_inval_wxok, vfs_cache.c:2054-2070, called fromsetfmode, vfs_syscalls.c:3422, immediately afterVOP_SETATTR) takes no ncp lock at all β onlyvp->v_spin. There is therefore no ordering between a concurrent clear and set:clear β stale-setis an allowed interleaving, and the stale set wins because nothing rechecks the mode while the flag stands. -
The observable halves of the flag lifecycle work (controls, see README): an unprivileged
statof a file inside a0700 root:wheelformerly-0777 directory correctly returnsEACCESafter a quiet chmod, and succeeds while the directory is 0777. -
Why the straddle is hard to hit. For the resurrection the setter must be stalled (interrupt/preemption) inside its ~150ns
GETATTR β atomic_setspan for longer than the chmod'smode-commit β cache_inval_wxokgap (~0.3β2 Β΅s), with the mode commit landing inside that span. My rigs produced straddle candidates (burst-timed one-shot workers with the commit timed into the in-flight window) and amplified in-span preemption (cross-CPU IPI storm via mmap/munmap churn + ping flood), but the joint probability evidently remained below what ~80k rounds sampled on a virtualized guest.
Rig summary (what was tried)
| Run | Design | Rounds | Result |
|---|---|---|---|
| run.3.log | per-round fork burst, 100 workers, late chmod | ~40,000 | no win |
| run.2.log | persistent pool + pipe burst, 14 workers, chmod at W+4Β΅s (commit landed after the burst β design flaw found and fixed) | ~8,000 | no win |
| run.log | pool + pipe burst, 24 workers, chmod timed mid-window (commit inside the in-flight span window), plus IPI storm + ping flood | ~68,000 | no win |
Worker op: unlink("D/nope") (nonexistent) β a modifying lookup
(NLC_DELETE β naccess(D, NLC_EXEC, &dflags) full path β GETATTR +
flag write), and DELETE-on-nonexistent skips the parent-write
recursion (vfs_nlookup.c:1571-1575 requires nc_vp != NULL for
NLC_DELETE), so a straddling worker's write is the last one in its own
syscall. Probe: stat("D/probe") as nobody β succeeds only via the
resurrected flag (shortcut), EACCES otherwise.
Impact if manifested (theoretical, per the root cause)
Directory search-permission revocation does not take effect: an
unprivileged user retains traversal/list access to a directory after
chmod 0700, indefinitely (until the next chmod of that directory),
reaching files inside it whose own modes permit access. Only the
directory VEXEC check is bypassed (create/delete still check the real
directory write permission).
Fix (proposed; not build-validated since the race did not reproduce)
fix.diff: serialize the clear with the setter β cache_inval_wxok()
locks each ncp exclusively before clearing (restart-safe scan holding
only an ncp reference across the lock), and setfmode() drops the
vnode lock (keeping a reference) before calling it, because taking the
ncp exclusive lock under a held vnode lock would invert the ncp-first
lock order against naccess()'s shared-ncp β vget(shared-vp)
sequence. With this ordering, any setter whose GETATTR predates the
mode change has its write ordered before the clear (it holds the ncp
shared across GETATTRβatomic_set), so the clear is always the last
write.
Kernel references
- sys/kern/vfs_nlookup.c:1616-1621 β fast path trusts NCF_WXOK unchecked
- sys/kern/vfs_nlookup.c:1720-1723 β NCF_WXOK computed from GETATTR_LITE snapshot
- sys/kern/vfs_nlookup.c:1758-1762 β flag write under shared ncp lock
- sys/kern/vfs_nlookup.c:1630-1658 β shared lock spans GETATTRβwrite
- sys/kern/vfs_cache.c:2054-2070 β cache_inval_wxok takes no ncp lock
- sys/kern/vfs_syscalls.c:3411-3424 β setfmode: SETATTR then inval
- sys/vfs/tmpfs/tmpfs_vnops.c:404-431 β lockless GETATTR_LITE (tmpfs)
- sys/vfs/hammer2/hammer2_vnops.c:370-424 β seqlocked GETATTR_LITE (hammer2)
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_testableFix not build-validated: the race itself did not reproduce on this guest within ~80k rounds, so there was no baseline manifestation to verify a patched kernel against. fix.diff is included as the proposed change; its ordering argument is mechanical (any setter whose GETATTR predates the mode change holds the ncp shared across GETATTR->atomic_set, so an exclusive-locked clear is always ordered after that write).
fix.diff (proposed); README.md control results documenting the working halves of the flag lifecycle
Confirmed kernel references
Detail
Exploit chain
Unproven on guest; theoretical chain: (1) attacker keeps modifying lookups (unlink of a nonexistent name) streaming through a world-r+x directory D; (2) the owner/root revokes traversal with chmod D 0700; (3) if any in-flight naccess on D read the pre-chmod attributes and is stalled (interrupt) across the chmod's SETATTR-commit-to-cache_inval_wxok gap, its atomic_set_short resurrects NCF_WXOK after the clear; (4) the attacker's subsequent stats/opens of entries inside D take the naccess fast path (vfs_nlookup.c:1616-1621) and are allowed without any mode check, indefinitely (until the next chmod of D) - unauthorized traversal/read access to files inside the restricted directory whose own modes permit access. Only the directory VEXEC check is bypassed; create/delete still require the real directory write permission.
Evidence (decisive lines)
wxokrace.c (rig with burst barrier + one-shot workers + unprivileged probes); run.log (~68k rounds, IPI-storm assisted, no win), run.2.log (~8k rounds ping-flood assisted, no win), run.3.log (~40k rounds earlier design, no win); README.md control results (quiet chmod: pre-chmod stat OK / post-chmod EACCES); fix.diff (proposed, not build-validated)
PoC changes
Rig authored fresh (no seed); iterated through four designs: (1) per-round fork burst - smeared by fork cost, post-inval workers cleared resurrections; (2) persistent worker pool + pipe-broadcast burst - chmod syscall latency (~5-15us) pushed the mode commit past the entire burst, so no worker was ever in-flight at the commit; (3) chmod re-timed into the middle of the burst window (W=25us, commit at ~12-19us) - straddle candidates present but no stall landed in the ~150ns span; (4) same plus cross-CPU IPI-storm (mmap/munmap churn, 6 threads) and ping flood to amplify in-span preemption - still no win in ~68k rounds.
Verified recommended fix
Serialize the clear with the setter: make cache_inval_wxok() lock each ncp exclusively before clearing NCF_WXOK/NCF_NOTX (restart-safe scan, holding only an ncp reference across the lock), and reorder setfmode() to drop the vnode lock (keeping a reference) before calling it, since taking the ncp exclusive lock under a held vnode lock would invert the ncp-first lock order against naccess()'s shared-ncp->vget(shared-vp) sequence (see fix.diff).
Verdict
Structurally certain by inspection, not reproduced on the guest within ~80,000 race rounds. naccess() writes the cached world-r+x traversal flag NCF_WXOK from a lockless VOP_GETATTR_LITE snapshot while holding the ncp only SHARED (vfs_nlookup.c:1720-1762), and the fast path at vfs_nlookup.c:1616-1621 returns success for the VEXEC directory-search check purely on that flag. chmod(2) clears the flag via cache_inval_wxok() (vfs_cache.c:2054-2070) which takes NO ncp lock (only vp->v_spin) and is not ordered against the setter's read-modify-write, so an naccess() whose GETATTR predates the mode change can commit atomic_set_short(NCF_WXOK) AFTER the clear - resurrecting a revoked search permission indefinitely (the fast path never revalidates while the flag is set; only another chmod of the directory re-clears it). The manifestation requires the setter to be stalled (interrupt/preemption) inside its ~150ns GETATTR->atomic_set span for longer than the chmod's mode-commit-to-cache_inval_wxok gap (~0.3-2us), with the commit landing inside that span. Rig: 60-100 one-shot unprivileged workers (unlink of a nonexistent name: a modifying lookup forces the full-path naccess on D, and DELETE-on-nonexistent runs no later full-path naccess on D, so a straddling worker's write would survive its own syscall) released via a pipe-burst barrier against a racing root chmod 0777->0700, probed after each round by an unprivileged stat(D/probe); four rig designs (hammer2 and tmpfs targets, 6-vCPU guest, including a cross-CPU IPI-storm amplifier via mmap/munmap churn and a ping flood) ran ~80,000 rounds total without a win. Controls verified the flag lifecycle's observable halves: an unprivileged stat inside a 0700 root-owned formerly-0777 directory correctly returns EACCES after a quiet chmod (clear works), and the pre-chmod stat succeeds. Root cause remains real by code proof; win probability on this virtualized guest is simply below what ~80k rounds sampled. Recommend fixing (cheap, protocol-level) rather than waiting for a field manifestation: a permission revocation that silently fails to take effect is exactly the class of bug that surfaces in the wild years later.
No comments yet.