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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5832 Β· 9 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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)

  1. 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.

  2. The setter is unsynchronized with the clearer. The setter (naccess, vfs_nlookup.c:1720-1762) computes cflags from a lockless VOP_GETATTR_LITE snapshot and writes ncp->nc_flag while 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 from setfmode, vfs_syscalls.c:3422, immediately after VOP_SETATTR) takes no ncp lock at all β€” only vp->v_spin. There is therefore no ordering between a concurrent clear and set: clear β†’ stale-set is an allowed interleaving, and the stale set wins because nothing rechecks the mode while the flag stands.

  3. The observable halves of the flag lifecycle work (controls, see README): an unprivileged stat of a file inside a 0700 root:wheel formerly-0777 directory correctly returns EACCES after a quiet chmod, and succeeds while the directory is 0777.

  4. Why the straddle is hard to hit. For the resurrection the setter must be stalled (interrupt/preemption) inside its ~150ns GETATTR β†’ atomic_set span for longer than the chmod's mode-commit β†’ cache_inval_wxok gap (~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

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
baseline no→ patch + rebuild →patched clean

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

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.