git: 22df19c09af6 - main - chflags: use Capsicum capability mode

From: Alan Somers <asomers_at_FreeBSD.org>
Date: Sun, 23 Aug 2026 19:09:57 UTC
The branch main has been updated by asomers:

URL: https://cgit.FreeBSD.org/src/commit/?id=22df19c09af64dc8711ee62796ba4cf3e7e4824f

commit 22df19c09af64dc8711ee62796ba4cf3e7e4824f
Author:     Jitendra Bhati <bhatijitendra2022@gmail.com>
AuthorDate: 2026-08-18 21:46:01 +0000
Commit:     Alan Somers <asomers@FreeBSD.org>
CommitDate: 2026-08-23 19:02:00 +0000

    chflags: use Capsicum capability mode
    
    Run the file-hierarchy traversal inside Capsicum capability mode
    using the fts_openat(3) API.  This confines chflags to the directory
    hierarchies named on the command line, so a malicious or buggy tree
    cannot redirect it at files elsewhere via a crafted symbolic link.
    
    Because AT_FDCWD is rejected in capability mode, a directory descriptor
    for the parent directory of every argument with an absolute path or a
    path containing ".." is opened once before cap_enter().  Once every
    descriptor is open, cap_enter() is called and the traversal acts through
    fd-relative operations: chflagsat(fts_parent->fts_dirfd, fts_name).
    
    With -L chflags follows symbolic links, which may point outside the
    named hierarchy; chflag now rejects such accesses.  The new
    --dereference-links-unsafely option disables the sandbox to restore the
    historical behavior for the rare callers that rely on it.  But if the
    symbolic was link was named directly on the command line, chflags will
    still follow it (unless -h was given).
    
    Add functional tests covering relative, absolute, "..", recursive
    and mixed path arguments, and the symlink handling in both the
    default and --dereference-links-unsafely modes; they skip on
    filesystems that do not support the uchg flag.
    
    Sponsored by:   Google LLC (GSoC 2026)
    Reviewed by:    asomers
    Relnotes:       yes (for the -L behavior change)
    Pull Request:   https://github.com/freebsd/freebsd-src/pull/2375
---
 bin/chflags/chflags.1             |  31 +++-
 bin/chflags/chflags.c             | 296 ++++++++++++++++++++++++++++++--------
 bin/chflags/tests/chflags_test.sh | 176 +++++++++++++++++++++++
 3 files changed, 440 insertions(+), 63 deletions(-)

diff --git a/bin/chflags/chflags.1 b/bin/chflags/chflags.1
index 26b5bb24d9fe..521fe8370e3e 100644
--- a/bin/chflags/chflags.1
+++ b/bin/chflags/chflags.1
@@ -29,7 +29,7 @@
 .\" OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 .\" SUCH DAMAGE.
 .\"
-.Dd July 9, 2026
+.Dd August 21, 2026
 .Dt CHFLAGS 1
 .Os
 .Sh NAME
@@ -38,6 +38,7 @@
 .Sh SYNOPSIS
 .Nm
 .Op Fl fhvx
+.Op Fl -dereference-links-unsafely
 .Oo
 .Fl R
 .Op Fl H | Fl L | Fl P
@@ -70,11 +71,20 @@ and hence unaffected by the command.
 If the
 .Ar file
 is a symbolic link,
-change the file flags of the link itself rather than the file to which it points.
+change the file flags of the link itself
+rather than the file to which it points.
 .It Fl L
 If the
 .Fl R
-option is specified, all symbolic links are followed.
+option is specified, symbolic links are followed.
+A symbolic link encountered during the traversal that points outside
+the file hierarchy being traversed is not followed unless the
+.Fl -dereference-links-unsafely
+option is also given;
+see the description of that option below.
+A symbolic link named directly on the command line is followed unless
+.Fl h
+is specified.
 .It Fl P
 If the
 .Fl R
@@ -97,6 +107,21 @@ option is specified more than once, the old and new flags of the file
 will also be printed, in octal notation.
 .It Fl x
 Do not cross mount points.
+.It Fl -dereference-links-unsafely
+By default,
+.Nm
+runs its traversal inside a
+.Xr capsicum 4
+sandbox, which prevents it from following a symbolic link whose
+target lies outside the file hierarchy named on the command line.
+This option disables the sandbox so that such symbolic links are
+followed, restoring historical behavior.
+It is only meaningful together with
+.Fl L
+or the default logical behavior, and should be used with caution,
+as it allows a crafted symbolic link to redirect
+.Nm
+at files outside the intended hierarchy.
 .El
 .Pp
 The flags are specified as an octal number or a comma separated list
diff --git a/bin/chflags/chflags.c b/bin/chflags/chflags.c
index 44bae3abe43b..7c96df0df520 100644
--- a/bin/chflags/chflags.c
+++ b/bin/chflags/chflags.c
@@ -30,13 +30,20 @@
  */
 
 #include <sys/types.h>
+#include <sys/capsicum.h>
 #include <sys/stat.h>
 
+#include <assert.h>
+#include <capsicum_helpers.h>
 #include <err.h>
 #include <errno.h>
 #include <fcntl.h>
 #include <fts.h>
+#include <getopt.h>
+#include <libgen.h>
+#include <limits.h>
 #include <signal.h>
+#include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
@@ -44,6 +51,8 @@
 
 static volatile sig_atomic_t siginfo;
 
+#define	OPT_DEREF_UNSAFE	(CHAR_MAX + 1)
+
 static void usage(void) __dead2;
 
 static void
@@ -53,19 +62,147 @@ siginfo_handler(int sig __unused)
 	siginfo = 1;
 }
 
-int
-main(int argc, char *argv[])
+/*
+ * A path needs its own pre-opened directory descriptor unless it is a
+ * single path component that can be resolved directly relative to the
+ * base directory descriptor.  Anything containing a '/' (an absolute
+ * path, or a relative path with a directory component) has its parent
+ * directory opened separately, so that in capability mode the final
+ * component is always reached relative to its immediate parent.
+ */
+static bool
+needs_own_fd(const char *path)
+{
+
+	return (strchr(path, '/') != NULL);
+}
+
+/*
+ * Open a directory descriptor for the parent of "path", and return in
+ * "*base" a pointer to the final path component (relative to that
+ * descriptor).  "*base" points into the storage of "path".
+ */
+static int
+open_base(char *path, char **base)
+{
+	char *dir, *bn, *pathcopy;
+	int fd;
+
+	/*
+	 * dirname() and basename() may modify their argument and may
+	 * return a pointer to internal storage, so operate on copies and
+	 * duplicate basename()'s result for the caller.
+	 */
+	if ((pathcopy = strdup(path)) == NULL)
+		err(1, "strdup");
+	dir = dirname(pathcopy);
+	fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+	free(pathcopy);
+
+	if ((pathcopy = strdup(path)) == NULL)
+		err(1, "strdup");
+	bn = basename(pathcopy);
+	if ((*base = strdup(bn)) == NULL)
+		err(1, "strdup");
+	free(pathcopy);
+
+	return (fd);
+}
+
+static int
+chflags_fts(int dirfd, char **paths, int fts_options, u_long set, u_long clear,
+    int oct, int Rflag, int fflag, int vflag)
 {
 	FTS *ftsp;
 	FTSENT *p;
-	u_long clear, newflags, set;
+	u_long newflags;
+	int e, rval;
+
+	if ((ftsp = fts_openat(dirfd, paths, fts_options, NULL)) == NULL)
+		err(1, NULL);
+
+	for (rval = 0; errno = 0, (p = fts_read(ftsp)) != NULL;) {
+		int atflag;
+
+		if ((fts_options & FTS_LOGICAL) ||
+		    ((fts_options & FTS_COMFOLLOW) &&
+		    p->fts_level == FTS_ROOTLEVEL))
+			atflag = 0;
+		else
+			atflag = AT_SYMLINK_NOFOLLOW;
+
+		switch (p->fts_info) {
+		case FTS_D:		/* Change it at FTS_DP if we're recursive. */
+			if (!Rflag)
+				fts_set(ftsp, p, FTS_SKIP);
+			continue;
+		case FTS_DNR:			/* Warn, chflags. */
+			warnx("%s: %s", p->fts_path, strerror(p->fts_errno));
+			rval = 1;
+			break;
+		case FTS_ERR:			/* Warn, continue. */
+		case FTS_NS:
+			warnx("%s: %s", p->fts_path, strerror(p->fts_errno));
+			rval = 1;
+			continue;
+		default:
+			break;
+		}
+		if (oct)
+			newflags = set;
+		else
+			newflags = (p->fts_statp->st_flags | set) & clear;
+		if (newflags == p->fts_statp->st_flags)
+			continue;
+		if (chflagsat(p->fts_parent->fts_dirfd, p->fts_name, newflags,
+		    atflag) == -1) {
+			e = errno;
+			if (!fflag) {
+				warnc(e, "%s", p->fts_path);
+				rval = 1;
+			}
+			if (siginfo) {
+				(void)printf("%s: %s\n", p->fts_path,
+				    strerror(e));
+				siginfo = 0;
+			}
+		} else if (vflag || siginfo) {
+			(void)printf("%s", p->fts_path);
+			if (vflag > 1 || siginfo)
+				(void)printf(": 0%lo -> 0%lo",
+				    (u_long)p->fts_statp->st_flags,
+				    newflags);
+			(void)printf("\n");
+			siginfo = 0;
+		}
+	}
+	if (errno)
+		err(1, "fts_read");
+	(void)fts_close(ftsp);
+	return (rval);
+}
+
+int
+main(int argc, char *argv[])
+{
+	static const struct option longopts[] = {
+		{ "dereference-links-unsafely", no_argument, NULL,
+		    OPT_DEREF_UNSAFE },
+		{ NULL, 0, NULL, 0 }
+	};
+	u_long clear, set;
 	long val;
-	int Hflag, Lflag, Rflag, fflag, hflag, vflag, xflag;
-	int ch, e, fts_options, oct, rval;
+	int Hflag, Lflag, Rflag, fflag, hflag, vflag, xflag, unsafe;
+	int ch, fts_options, oct, rval;
+	int cwd_fd, i, nrel, nown;
+	int *ownfd;
+	char **ownbase;
 	char *flags, *ep;
+	char **relpaths, *twopath[2];
 
-	Hflag = Lflag = Rflag = fflag = hflag = vflag = xflag = 0;
-	while ((ch = getopt(argc, argv, "HLPRfhvx")) != -1)
+	Hflag = Lflag = Rflag = fflag = hflag = vflag = xflag = unsafe = 0;
+	while ((ch = getopt_long(argc, argv, "HLPRfhvx", longopts,
+	    NULL)) != -1)
 		switch (ch) {
 		case 'H':
 			Hflag = 1;
@@ -93,6 +230,9 @@ main(int argc, char *argv[])
 		case 'x':
 			xflag = 1;
 			break;
+		case OPT_DEREF_UNSAFE:
+			unsafe = 1;
+			break;
 		case '?':
 		default:
 			usage();
@@ -145,66 +285,101 @@ main(int argc, char *argv[])
 		oct = 0;
 	}
 
-	if ((ftsp = fts_open(++argv, fts_options , 0)) == NULL)
-		err(1, NULL);
+	argv++;
+	argc--;
 
-	for (rval = 0; errno = 0, (p = fts_read(ftsp)) != NULL;) {
-		int atflag;
+	/*
+	 * Pre-open a directory descriptor for every path argument, so the
+	 * traversal runs through fd-relative operations.  Plain relative
+	 * arguments share a descriptor for the current directory; absolute
+	 * paths and paths containing ".." cannot be resolved relative to
+	 * another descriptor in capability mode, so each gets its own
+	 * parent descriptor.
+	 */
+	if ((cwd_fd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC)) < 0)
+		err(1, ".");
 
-		if ((fts_options & FTS_LOGICAL) ||
-		    ((fts_options & FTS_COMFOLLOW) &&
-		    p->fts_level == FTS_ROOTLEVEL))
-			atflag = 0;
-		else
-			atflag = AT_SYMLINK_NOFOLLOW;
+	relpaths = calloc(argc + 1, sizeof(*relpaths));
+	ownfd = calloc(argc, sizeof(*ownfd));
+	ownbase = calloc(argc, sizeof(*ownbase));
+	if (relpaths == NULL || ownfd == NULL || ownbase == NULL)
+		err(1, "calloc");
+	nrel = 0;
+	nown = 0;
+	rval = 0;
 
-		switch (p->fts_info) {
-		case FTS_D:	/* Change it at FTS_DP if we're recursive. */
-			if (!Rflag)
-				fts_set(ftsp, p, FTS_SKIP);
-			continue;
-		case FTS_DNR:			/* Warn, chflags. */
-			warnx("%s: %s", p->fts_path, strerror(p->fts_errno));
-			rval = 1;
-			break;
-		case FTS_ERR:			/* Warn, continue. */
-		case FTS_NS:
-			warnx("%s: %s", p->fts_path, strerror(p->fts_errno));
-			rval = 1;
-			continue;
-		default:
-			break;
-		}
-		if (oct)
-			newflags = set;
-		else
-			newflags = (p->fts_statp->st_flags | set) & clear;
-		if (newflags == p->fts_statp->st_flags)
-			continue;
-		if (chflagsat(AT_FDCWD, p->fts_accpath, newflags,
-		    atflag) == -1) {
-			e = errno;
-			if (!fflag) {
-				warnc(e, "%s", p->fts_path);
+	for (i = 0; i < argc; i++) {
+		char *arg = argv[i];
+		char resolved[PATH_MAX];
+		struct stat sb;
+
+		/*
+		 * A symbolic link named on the command line is followed
+		 * unless -h was given (or a purely physical walk was
+		 * requested).  Its target may lie outside the link's parent
+		 * directory, which capability mode could not reach, so
+		 * resolve the link now and operate relative to the target's
+		 * own parent.
+		 */
+		if ((fts_options & (FTS_LOGICAL | FTS_COMFOLLOW)) &&
+		    lstat(arg, &sb) == 0 && S_ISLNK(sb.st_mode)) {
+			if (realpath(arg, resolved) == NULL) {
+				warn("%s", arg);
 				rval = 1;
+				continue;
 			}
-			if (siginfo) {
-				(void)printf("%s: %s\n", p->fts_path,
-				    strerror(e));
-				siginfo = 0;
+			arg = resolved;
+		}
+
+		if (needs_own_fd(arg)) {
+			int fd = open_base(arg, &ownbase[nown]);
+			if (fd < 0) {
+				warn("%s", argv[i]);
+				rval = 1;
+				continue;
 			}
-		} else if (vflag || siginfo) {
-			(void)printf("%s", p->fts_path);
-			if (vflag > 1 || siginfo)
-				(void)printf(": 0%lo -> 0%lo",
-				    (u_long)p->fts_statp->st_flags,
-				    newflags);
-			(void)printf("\n");
-			siginfo = 0;
+			ownfd[nown] = fd;
+			nown++;
+		} else {
+			/*
+			 * A resolved symlink is always absolute and thus
+			 * takes the branch above; only an unmodified argument
+			 * reaches this point.  Store argv[i], which outlives
+			 * the on-stack resolved[] buffer.
+			 */
+			assert(arg != resolved);
+			relpaths[nrel++] = argv[i];
 		}
 	}
-	if (errno)
-		err(1, "fts_read");
+	relpaths[nrel] = NULL;
+
+	if (caph_limit_stdio() < 0)
+		err(1, "caph_limit_stdio");
+	/*
+	 * With --dereference-links-unsafely the traversal may follow a
+	 * symlink to a file outside the hierarchy named on the command
+	 * line, which capability mode would block, so skip caph_enter() in
+	 * that case.
+	 */
+	if (!unsafe && caph_enter() < 0)
+		err(1, "caph_enter");
+
+	/* Process all plain relative paths together under cwd_fd. */
+	if (nrel > 0)
+		rval |= chflags_fts(cwd_fd, relpaths, fts_options, set, clear,
+		    oct, Rflag, fflag, vflag);
+
+	/* Process each absolute / ".."-containing path under its own fd. */
+	for (i = 0; i < nown; i++) {
+		twopath[0] = ownbase[i];
+		twopath[1] = NULL;
+		rval |= chflags_fts(ownfd[i], twopath, fts_options, set,
+		    clear, oct, Rflag, fflag, vflag);
+		free(ownbase[i]);
+	}
+	free(relpaths);
+	free(ownfd);
+	free(ownbase);
 	exit(rval);
 }
 
@@ -212,6 +387,7 @@ static void
 usage(void)
 {
 	(void)fprintf(stderr,
-	    "usage: chflags [-fhvx] [-R [-H | -L | -P]] flags file ...\n");
+	    "usage: chflags [-fhvx] [-R [-H | -L | -P]] "
+	    "[--dereference-links-unsafely] flags file ...\n");
 	exit(1);
 }
diff --git a/bin/chflags/tests/chflags_test.sh b/bin/chflags/tests/chflags_test.sh
index 0fb3f31259b0..29c489b9a755 100644
--- a/bin/chflags/tests/chflags_test.sh
+++ b/bin/chflags/tests/chflags_test.sh
@@ -1,5 +1,6 @@
 #
 # Copyright 2017 Shivansh Rai
+# Copyright (c) 2026 Jitendra Bhati
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -27,6 +28,18 @@
 
 usage_output='usage: chflags'
 
+# Skip the calling test if the work filesystem does not support
+# setting the nodump file flag (e.g. some ZFS configurations).
+require_chflags()
+{
+	touch .chflags_probe
+	if ! chflags nodump .chflags_probe 2>/dev/null; then
+		rm -f .chflags_probe
+		atf_skip "filesystem does not support the nodump flag"
+	fi
+	rm -f .chflags_probe
+}
+
 atf_test_case invalid_usage
 invalid_usage_head()
 {
@@ -55,8 +68,171 @@ no_arguments_body()
 	atf_check -s not-exit:0 -e match:"$usage_output" chflags
 }
 
+atf_test_case relative_path
+relative_path_head()
+{
+	atf_set "descr" "chflags sets flags on a relative path"
+}
+relative_path_body()
+{
+	require_chflags
+	touch file
+	atf_check chflags nodump file
+	atf_check -o match:nodump stat -f "%Sf" file
+}
+
+atf_test_case absolute_path
+absolute_path_head()
+{
+	atf_set "descr" "chflags sets flags on an absolute path"
+}
+absolute_path_body()
+{
+	require_chflags
+	touch file
+	atf_check chflags nodump "$(pwd)/file"
+	atf_check -o match:nodump stat -f "%Sf" file
+}
+
+atf_test_case dotdot_path
+dotdot_path_head()
+{
+	atf_set "descr" "chflags sets flags on a path containing .."
+}
+dotdot_path_body()
+{
+	require_chflags
+	mkdir dir
+	touch file
+	cd dir
+	atf_check chflags nodump ../file
+	atf_check -o match:nodump stat -f "%Sf" ../file
+}
+
+atf_test_case recursive
+recursive_head()
+{
+	atf_set "descr" "chflags -R sets flags on a directory tree"
+}
+recursive_body()
+{
+	require_chflags
+	mkdir -p dir/sub
+	touch dir/file dir/sub/file
+	atf_check chflags -R nodump dir
+	atf_check -o match:nodump stat -f "%Sf" dir/file
+	atf_check -o match:nodump stat -f "%Sf" dir/sub/file
+}
+
+atf_test_case mixed_paths
+mixed_paths_head()
+{
+	atf_set "descr" "chflags handles relative, absolute and .. paths together"
+}
+mixed_paths_body()
+{
+	require_chflags
+	mkdir dir
+	touch a dir/b c
+	cd dir
+	atf_check chflags nodump b "$(pwd)/../a" ../c
+	atf_check -o match:nodump stat -f "%Sf" b
+	atf_check -o match:nodump stat -f "%Sf" ../a
+	atf_check -o match:nodump stat -f "%Sf" ../c
+}
+
+atf_test_case outside_symlink_rejected
+outside_symlink_rejected_head()
+{
+	atf_set "descr" "chflags -RL does not follow a symlink pointing " \
+	    "outside the traversal by default"
+}
+outside_symlink_rejected_body()
+{
+	require_chflags
+	mkdir -p foo/bar/baz
+	touch target
+	ln -s ../../../target foo/bar/baz/link
+	atf_check -s not-exit:0 -e ignore chflags -RL nodump foo/bar
+	atf_check -o match:"target[[:space:]]*-" stat -f "%N %Sf" target
+}
+
+atf_test_case outside_symlink_unsafe
+outside_symlink_unsafe_head()
+{
+	atf_set "descr" "chflags -RL --dereference-links-unsafely follows " \
+	    "a symlink pointing outside the traversal"
+}
+outside_symlink_unsafe_body()
+{
+	require_chflags
+	mkdir -p foo/bar/baz
+	touch target
+	ln -s ../../../target foo/bar/baz/link
+	atf_check chflags -RL --dereference-links-unsafely nodump foo/bar
+	atf_check -o match:nodump stat -f "%Sf" target
+}
+
+atf_test_case symlink_no_h
+symlink_no_h_head()
+{
+	atf_set "descr" "chflags on a symlink without -h changes the target"
+}
+symlink_no_h_body()
+{
+	require_chflags
+	mkdir dir
+	touch target
+	ln -s ../target dir/link
+	atf_check chflags nodump dir/link
+	atf_check -o match:nodump stat -f "%Sf" target
+	atf_check -o not-match:nodump stat -f "%Sf" dir/link
+}
+
+atf_test_case symlink_rootlevel_H
+symlink_rootlevel_H_head()
+{
+	atf_set "descr" "chflags -RH follows a symbolic link to a" \
+	    " directory named on the command line"
+}
+symlink_rootlevel_H_body()
+{
+	require_chflags
+	mkdir dir realdir
+	touch realdir/file
+	ln -s ../realdir dir/dlink
+	atf_check chflags -RH nodump dir/dlink
+	atf_check -o match:nodump stat -f "%Sf" realdir/file
+	atf_check -o not-match:nodump stat -f "%Sf" dir/dlink
+}
+
+atf_test_case symlink_rootlevel_H_dead
+symlink_rootlevel_H_dead_head()
+{
+	atf_set "descr" "chflags -RH on a broken symbolic link fails" \
+	    " gracefully"
+}
+symlink_rootlevel_H_dead_body()
+{
+	require_chflags
+	mkdir dir
+	ln -s ../nonexistent dir/dlink
+	atf_check -s not-exit:0 -e match:"No such file or directory" \
+		chflags -RH nodump dir/dlink
+}
+
 atf_init_test_cases()
 {
 	atf_add_test_case invalid_usage
 	atf_add_test_case no_arguments
+	atf_add_test_case relative_path
+	atf_add_test_case absolute_path
+	atf_add_test_case dotdot_path
+	atf_add_test_case recursive
+	atf_add_test_case mixed_paths
+	atf_add_test_case symlink_no_h
+	atf_add_test_case symlink_rootlevel_H
+	atf_add_test_case symlink_rootlevel_H_dead
+	atf_add_test_case outside_symlink_rejected
+	atf_add_test_case outside_symlink_unsafe
 }