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

nlookup() misclassifies a leaf component followed by a trailing slash as an intermediate directory, dropping NLC_STICKY/NLC_APPENDONLY feedback β€” sticky-bit and append-only-directory deletion/rename restriction bypass

Field Value
ID DF-2716
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE CWE-863 Incorrect Authorization
File sys/kern/vfs_nlookup.c
Lines 653-660 (gate uses), 1213-1215, 1838-1842, 1902-1905
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket privesc
Reported pending
Known CVE none
CVE match novel

Summary

The dflags-collection condition at :653-660 tests *nptr == '/' to decide whether nd->nl_nch is the leaf's parent (and should feed the directory's NLC_STICKY/NLC_APPENDONLY/NLC_IMMUTABLE flags into naccess at :1704-1711). A trailing slash after the last component (rmdir("/tmp/victim/")) makes nptr point at that '/', so the dflags-collection branch is skipped even though islastelement() correctly reports the component as final. The final leaf check then runs with dflags==0 and naccess_lva's sticky gate (EACCES for non-owner NLC_DELETE|NLC_RENAME_* in a +t dir) and append-only-dir gate never fire. No filesystem re-checks sticky for rmdir/rename: hammer2 has zero VSVTX logic, tmpfs re-checks APPEND/IMMUTABLE but not sticky, UFS's rmdir flag check is #if 0'd "handled by kernel now".

Threat model & preconditions

Any local unprivileged user on a default configuration (/tmp is 1777): delete other users' empty directories in sticky dirs, MOVE other users' directory trees out of sticky dirs into attacker-controlled namespace (rename), and defeat administrator chflags sappnd append-only directories on hammer2. Deterministic, no race, one syscall.

Proof of concept

VERIFIED on the stock INVARIANTS guest on BOTH hammer2 and tmpfs, identically across two independent boots (findings/poc/DF-2716/): uid-1001 rmdir("/df2716h/df2716/victim/")=0 and rename(".../victim/", ".../stolen")=0 in a root-owned 1777 sticky dir (slashless controls EACCES); rmdir of an entry in a chflags sappnd directory on hammer2 =0 (control EPERM). Fix (islastelement-based condition) rebuilt in-guest: all bypasses gone, controls and regression suite unchanged.

--- a/sys/kern/vfs_nlookup.c
+++ b/sys/kern/vfs_nlookup.c
@@ -653,7 +653,12 @@
     * Optimize by passing-in NULL for any prior components, which may
     * allow the code to bypass the naccess() call.
     *
+    * NOTE: Whether the current component is the last one must be
+    * determined with islastelement(), which skips trailing slashes.
+    * Testing *nptr != '/' misclassifies the parent directory of a leaf
+    * followed by a trailing '/' as an intermediate directory and drops
+    * the NLC_STICKY / NLC_APPENDONLY feedback flags, bypassing the
+    * sticky-bit and append-only directory deletion restrictions.
+    *
     * naccess() is optimized to avoid having to lock the nch or get
     * the related vnode if cached perms are sufficient.
     */
    dflags = 0;
-   if (*nptr == '/' || (saveflag & NLC_MODIFYING_MASK) == 0) {
+   if (islastelement(nptr) == 0 || (saveflag & NLC_MODIFYING_MASK) == 0) {

Timeline

  • 2026-08-30 Discovered during pass-2 audit of vfs_nlookup.c (GLM 5.3); deterministic unpriv bypass reproduced (hammer2 + tmpfs) + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2716 Β· 15 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 6.1 KB ↓ raw
df2716.c β€” 3.6 KB view raw
run.sh β€” 1.9 KB view raw
build.sh β€” 122 B view raw
fix.diff β€” 994 B view raw
build.log β€” 9 B view raw
run.log β€” 1.9 KB view raw
run.hammer2.log β€” 2.0 KB view raw
run.baseline.log β€” 2.0 KB view raw
run.fixed.log β€” 3.2 KB view raw
kbuild.tail.log β€” 298 B view raw
env.txt β€” 739 B view raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 4.1 KB view raw

DF-2716 β€” nlookup() trailing-slash drops NLC_STICKY / NLC_APPENDONLY dflags

(sticky-bit and append-only directory deletion restriction bypass)

What

sys/kern/vfs_nlookup.c nlookup() decides whether to collect directory feedback flags (NLC_STICKY, NLC_APPENDONLY, NLC_IMMUTABLE) into the local dflags using

if (*nptr == '/' || (saveflag & NLC_MODIFYING_MASK) == 0) {   /* :654 */
        naccess(..., NULL, 0);         /* no dflags */
} else {
        naccess(..., &dflags, 0);      /* collect dflags */
}

nptr points just past the current path component. For a path whose LAST component is followed by a trailing slash β€” rmdir("/tmp/victim/") β€” *nptr == '/' is true even though islastelement(nptr) says this is the final component. The leaf's parent directory is therefore misclassified as an intermediate directory and its feedback flags are never collected. The final leaf naccess() (:1213, nd->nl_flags | dflags) then runs with dflags == 0, so naccess_lva() never sees NLC_STICKY (:1902) or NLC_APPENDONLY (:1838) and the corresponding restrictions are skipped.

No filesystem re-checks the sticky bit for rmdir/rename on hammer2 (the project's root filesystem), and tmpfs only re-checks APPEND/IMMUTABLE β€” not sticky. UFS's own old-API rmdir check is #if 0'd ("handled by kernel now", ufs_vnops.c:1483).

Reproduce (on the guest, as root)

cc -O0 -g -o /tmp/df2716_bin/df2716 df2716.c
sh run.sh /df2716h        # hammer2 root fs  (or: sh run.sh /tmp β€” tmpfs)

Expected (vulnerable stock kernel)

a1 control rmdir  /BASE/victim      => EACCES   (sticky enforced)
a2 PoC     rmdir  /BASE/victim/     => 0  *** sticky BYPASSED ***
a3 control rename /BASE/victim ...  => EACCES
a4 PoC     rename /BASE/victim/ ... => 0  *** sticky BYPASSED ***
a5 PoC     rename own -> victim/    => ENOENT (gate bypassed β€” no
                                         EACCES; FS-internal failure)
b1 control rmdir  sappnd-dir/sub    => EPERM
b2 PoC     rmdir  sappnd-dir/sub/   => 0  *** append-only BYPASSED ***
                                         (hammer2; tmpfs re-checks
                                         APPEND itself)

With fix.diff applied (islastelement-based condition) the same run yields EACCES/EPERM for a2, a4 and b2; controls unchanged; regression suite (non-sticky dir-over-dir rename, plain creates/renames) still passes.

Files

  • df2716.c β€” phase-driven syscall PoC (a1..a5, b1, b2)
  • run.sh β€” root fixture driver + matrix (arg: base dir)
  • fix.diff β€” one-line semantic fix + comment
  • build.log, run.log (tmpfs), run.hammer2.log, run.baseline.log (fresh-boot hammer2), run.fixed.log (patched kernel) β€” full outputs
  • verdict.json / manifest.json / VERDICT.md
VERDICT.md
↓ download raw

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:

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

patched kernel denies a2 (EACCES), a4 (EACCES), b2 (EPERM); controls a1/a3/b1 identical to stock; regression suite (own-dir ops incl. trailing slash in sticky dir, non-sticky dir-over-dir rename with/without slash) all pass

findings/poc/DF-2716/run.fixed.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Aug 31 05:34:49 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user -> rmdir("/tmp//") deletes it; rename("/tmp//", "~/stolen") relocates another user's directory tree out of the sticky-protected namespace; rmdir("//") defeats administrator append-only directories on hammer2. No uid=0 chain β€” authorization bypass class.

Evidence (decisive lines)

['findings/poc/DF-2716/run.baseline.log (stock kernel, fresh with-src boot: a2/a4/b2 SUCCESS)', 'findings/poc/DF-2716/run.hammer2.log (stock kernel pre-reset hammer2 matrix)', 'findings/poc/DF-2716/run.log (stock kernel tmpfs matrix: a2/a4 SUCCESS)', 'findings/poc/DF-2716/run.fixed.log (patched kernel #1: a2/a4 EACCES, b2 EPERM + regression suite OK)', 'findings/poc/DF-2716/fix.diff (islastelement-based condition)']

PoC changes

phase-driven rewrite of the seed sketch (single syscall per exec so root can re-create fixtures between phases); env-parametrized base dir so the same binary runs on hammer2 (/df2716h) and tmpfs (/tmp); build on guest with cc; su -m maxx executes each phase.

Verified recommended fix

replace '*nptr == 0x2f' dflags-collection test with islastelement(nptr) == 0 in nlookup() (see fix.diff)

Verdict

REPRODUCED on stock INVARIANTS kernel #0 (hammer2 root and tmpfs): rmdir("stickydir/victim/") and rename("stickydir/victim/", ...) by uid 1001 succeed where the slashless controls return EACCES, and rmdir("sappnd-dir/sub/") succeeds on hammer2 (EPERM control). Root cause: sys/kern/vfs_nlookup.c:654 tests *nptr=='/' to decide whether the current directory is the leaf's parent, misclassifying a leaf followed by a trailing slash as an intermediate component, so NLC_STICKY/NLC_APPENDONLY feedback (naccess vfs_nlookup.c:1704-1711) is never collected and naccess_lva's sticky/append-only gates (vfs_nlookup.c:1902/1838) never fire. FIX VALIDATED: one-line islastelement(nptr)==0 condition rebuilt in-guest (kernel #1), all PoC phases now denied (a2/a4 EACCES, b2 EPERM), controls and regression suite unchanged.