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.
Recommended fix
--- 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)
PoC verification
Evidence pack
findings/poc/DF-2716 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
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βdflagscollection condition*nptr == '/' || (saveflag & NLC_MODIFYING_MASK) == 0. For a leaf component followed by a trailing/,nptrpoints at that/, so the second branch (which passes&dflagstonaccess()) is never taken even thoughislastelement(nptr)(computed at:678) correctly reports the component as last.sys/kern/vfs_nlookup.c:1704-1711βnaccess()feedsNLC_STICKY(sticky bit set on the dir and dir owner != cred) andNLC_APPENDONLY(dir flaggedAPPEND) back through*nflagsp(=&dflags) only.sys/kern/vfs_nlookup.c:1213-1215β final leaf check runsnaccess(..., nd->nl_flags | dflags, ...); withdflags == 0the flags from the leaf's parent are missing.sys/kern/vfs_nlookup.c:1902-1905βnaccess_lva()returnsEACCESforNLC_DELETE | NLC_RENAME_SRC | NLC_RENAME_DSTwhenNLC_STICKYis set (non-owner in sticky dir). Never triggers without the flag.sys/kern/vfs_nlookup.c:1838-1842β same forNLC_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):
- Delete any other user's empty directories in sticky directories
(
rmdir("/tmp/victim/")) β denied without the slash. - 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). - 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
fixedpatched 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
Confirmed kernel references
Detail
Exploit chain
unpriv user -> rmdir("/tmp/
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.
No comments yet.