# 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**:

```c
	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**:

```c
	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()`:

```c
	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
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`.
