# DF-2716 VERDICT

**Status: reproduced (uid-gated access-control bypass; not memory corruption)**
**Impact: sticky-bit / append-only-directory deletion restriction bypass**
(unprivileged user deletes/renames other users' entries in `+t` directories
and deletes entries in `sappnd` directories) — kernel-verified, fix-verified.

## Root cause (path:line, this tree)

* `sys/kern/vfs_nlookup.c:653-660` — `dflags` collection condition
  `*nptr == '/' || (saveflag & NLC_MODIFYING_MASK) == 0`.  For a leaf
  component followed by a trailing `/`, `nptr` points at that `/`, so the
  second branch (which passes `&dflags` to `naccess()`) is never taken even
  though `islastelement(nptr)` (computed at `:678`) correctly reports the
  component as last.
* `sys/kern/vfs_nlookup.c:1704-1711` — `naccess()` feeds `NLC_STICKY`
  (sticky bit set on the dir and dir owner != cred) and `NLC_APPENDONLY`
  (dir flagged `APPEND`) back through `*nflagsp` (= `&dflags`) only.
* `sys/kern/vfs_nlookup.c:1213-1215` — final leaf check runs
  `naccess(..., nd->nl_flags | dflags, ...)`; with `dflags == 0` the flags
  from the leaf's parent are missing.
* `sys/kern/vfs_nlookup.c:1902-1905` — `naccess_lva()` returns `EACCES` for
  `NLC_DELETE | NLC_RENAME_SRC | NLC_RENAME_DST` when `NLC_STICKY` is set
  (non-owner in sticky dir).  Never triggers without the flag.
* `sys/kern/vfs_nlookup.c:1838-1842` — same for `NLC_APPENDONLY` (governing
  dir append-only implies NOUNLINK for its entries).

Downstream, nothing re-checks sticky on the paths that matter:
hammer2 has no `VSVTX`/sticky logic at all (`rg VSVTX|S_ISTXT
sys/vfs/hammer2/` → nothing), tmpfs re-checks `APPEND|IMMUTABLE|NOUNLINK`
(`tmpfs_vnops.c:1132`) but not sticky, and UFS's rmdir flag check is
`#if 0`'d with the comment "handled by kernel now" (`ufs_vnops.c:1483`).

## How it was reproduced

Guest: `DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026`
(stock INVARIANTS kernel), root fs hammer2, `/tmp` tmpfs.  Unprivileged user
`maxx` (uid 1001, not in wheel).

Root driver `run.sh` creates, per phase, a fresh fixture: a 1777 root-owned
sticky directory containing a root-owned empty `victim` dir, and (for b*) a
1777 root-owned `sappnd` directory containing `sub`.  The PoC binary then
performs exactly one syscall as `maxx` per phase.

Fresh-boot baseline (`run.baseline.log`, kernel #0 after `vm.sh reset
with-src`; identical to the first run `run.hammer2.log` before the reset):

| phase | syscall (as uid 1001)            | stock kernel      |
|-------|----------------------------------|-------------------|
| a1    | `rmdir("/df2716h/df2716/victim")`   | EACCES (control)  |
| a2    | `rmdir("/df2716h/df2716/victim/")`  | **0 — BYPASS**    |
| a3    | `rename(".../victim", ".../df2716_stolen")` | EACCES (control) |
| a4    | `rename(".../victim/", ".../df2716_stolen")`| **0 — BYPASS** (root-owned dir moved out of sticky dir) |
| a5    | `rename(own_dir, ".../victim/")`    | ENOENT — sticky gate was bypassed (no EACCES); op then fails inside kern_rename/FS for dir-over-dir semantics (`vfs_syscalls.c:4389-4398` region) |
| b1    | `rmdir(".../df2716b/sub")`          | EPERM (control)   |
| b2    | `rmdir(".../df2716b/sub/")`         | **0 — BYPASS** (hammer2; tmpfs independently blocks via its own APPEND recheck) |

The same a1..a4 matrix on **tmpfs** (`run.log`) reproduces identically
(a2/a4 succeed), proving it is the generic VFS gate, not an FS quirk.

Control experiment: on a *non-sticky* 777 dir, dir-over-dir rename with
trailing slash succeeds normally (`slash OK`), i.e. the trailing slash is
not independently rejected anywhere — the only difference in the sticky
fixture is the missing `NLC_STICKY`.

## Exploit chain (what an attacker gains)

Unprivileged local user, default config (`/tmp` is 1777):

1. **Delete** any other user's empty directories in sticky directories
   (`rmdir("/tmp/victim/")`) — denied without the slash.
2. **Move** any other user's directories out of a sticky directory
   (`rename("/tmp/victim/", "/home/attacker/stolen")`) — the directory
   (and everything below it) is relocated into attacker-controlled
   namespace; availability loss for the victim and subsequent
   rename-over/delete games on the moved tree's *parent* (contents stay
   protected by their own modes).
3. **Defeat administrator append-only (`chflags sappnd`) directories** on
   hammer2: entries can be deleted with a trailing slash — the flag is
   documented as implying NOUNLINK for entries.

No memory corruption is involved; this is a pure kernel authorization
bypass (CWE-863), so there is no uid=0 chain — the primitives above are
the end state.

## Fix validation

`fix.diff` (authored after reproduction; applied only inside the guest's
`/usr/src`, never on the audit tree) replaces the misclassification test
with `islastelement(nptr) == 0`:

```diff
-	if (*nptr == '/' || (saveflag & NLC_MODIFYING_MASK) == 0) {
+	if (islastelement(nptr) == 0 || (saveflag & NLC_MODIFYING_MASK) == 0) {
```

`islastelement()` skips trailing slashes (`:456-461`), so the leaf's parent
is now always the component that collects `dflags`.  Semantics are
otherwise identical: non-modifying lookups still take the NULL branch, and
true intermediate components still skip collection.

Built with `make nativekernel KERNCONF=X86_64_GENERIC` in-guest, installed,
rebooted, and the exact same matrix re-run:

* a2 → EACCES, a4 → EACCES, b2 → EPERM (bypasses **gone**)
* a1/a3/b1 controls unchanged (still denied, same errnos)
* regression: non-sticky dir-over-dir rename with and without trailing
  slash still succeeds; plain rmdir/rename of the user's own dirs in 1777
  dirs still succeeds (see run.fixed.log tail).

## Notes / limits

* `unlink("victim/")` on a regular *file* is stopped earlier by the
  ENOTDIR check at `:1127-1131` (leaf ncp is not NCF_ISDIR) — file
  deletion via this bug is not reachable, only directory rmdir/rename.
* a5 (rename-over) bypassed the gate but did not complete for unrelated
  kern_rename/FS reasons; not counted as a weaponized vector.
* Reproduced across two independent boots of the stock kernel (before and
  after `vm.sh reset with-src`) — deterministic, not a race.
