diff --git a/doc/mount.fuse3.8 b/doc/mount.fuse3.8 index 4380c84b..2e587458 100644 --- a/doc/mount.fuse3.8 +++ b/doc/mount.fuse3.8 @@ -222,7 +222,7 @@ These options are interpreted by \fBmount.fuse3\fP and are thus only available w Switch to \fBUSER\fP and its primary group before launching the FUSE file system process. mount.fuse3 must be run as root or with \fBCAP_SETUID\fP and \fBCAP_SETGID\fP for this to work. .TP \fBdrop_privileges\fP -Perform setup of the FUSE file descriptor and mounting the file system before launching the FUSE file system process. \fBmount.fuse3\fP requires privilege to do so, i.e. must be run as root or at least with \fBCAP_SYS_ADMIN\fP and \fBCAP_SETPCAP\fP. It will launch the file system process fully unprivileged, i.e. without \fBcapabilities\fP(7) and \fBprctl\fP(2) flags set up such that privileges can't be reacquired (e.g. via setuid or fscaps binaries). This reduces risk in the event of the FUSE file system process getting compromised by malicious file system data. +Perform setup of the FUSE file descriptor and mounting the file system before launching the FUSE file system process. \fBmount.fuse3\fP requires privilege to do so, i.e. must be run as root or at least with \fBCAP_SYS_ADMIN\fP and \fBCAP_SETPCAP\fP. It will launch the file system process fully unprivileged, i.e. without \fBcapabilities\fP(7) and \fBprctl\fP(2) flags set up such that privileges can't be reacquired (e.g. via setuid or fscaps binaries). This reduces risk in the event of the FUSE file system process getting compromised by malicious file system data. Because the file system program is launched after privileges have been dropped, it and the libraries it links against must reside at a path the unprivileged process can resolve: every directory component must be searchable without elevated privileges. .SH FUSE MODULES (STACKING) Modules are filesystem stacking support to high level API. Filesystem modules can be built into libfuse or loaded from shared object diff --git a/test/test_examples.py b/test/test_examples.py index 6a08cb48..c7c7c8e8 100755 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -74,6 +74,30 @@ def invoke_mount_fuse_drop_privileges(mnt_dir, name, options): if os.getuid() != 0: pytest.skip('drop_privileges requires root, skipping.') + # mount.fuse3 execs the fs binary only after dropping all + # capabilities; the process keeps uid/gid 0 but loses + # CAP_DAC_OVERRIDE, so the binary and every directory on the way to + # it must be executable for it by plain permission bits (a user-owned + # mode-0700 home directory is not). + def executable_without_caps(st): + if st.st_uid == 0: + return st.st_mode & stat.S_IXUSR + if st.st_gid == 0: + return st.st_mode & stat.S_IXGRP + return st.st_mode & stat.S_IXOTH + + if name.startswith('test/'): + path = os.path.realpath(pjoin(basename, name)) + else: + path = os.path.realpath(pjoin(basename, 'example', name)) + while True: + if not executable_without_caps(os.stat(path)): + pytest.skip(f'{path} not reachable by the ' + 'capability-less file system process') + if path == '/': + break + path = os.path.dirname(path) + return invoke_mount_fuse(mnt_dir, name, options + ('drop_privileges',)) class raii_tmpdir: diff --git a/util/mount.fuse.c b/util/mount.fuse.c index c97b3644..c67a0c2b 100644 --- a/util/mount.fuse.c +++ b/util/mount.fuse.c @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -242,6 +243,60 @@ static void drop_and_lock_capabilities(void) exit(1); } } + +/* + * Mirror the PATH lookup /bin/sh is about to do. Returns 0 if the binary is + * reachable and executable, otherwise a negative errno explaining why + * (-EACCES = permission denied, -ENOENT = not found). Must run after + * drop_and_lock_capabilities(): with no capabilities left and + * SECBIT_NO_SETUID_FIXUP set, access(X_OK) is a plain DAC check matching + * the upcoming execve, so this is deny-only -- it cannot grant access the + * exec would be refused, only report it earlier with a better message. + */ +/* access(X_OK), with errno mapped to a negative errno (-ENOENT = "not found"). */ +static int access_executable(const char *path) +{ + if (access(path, X_OK) == 0) + return 0; + return errno == EACCES ? -EACCES : -ENOENT; +} + +static int check_binary_access(const char *name) +{ + const char *path_env, *seg; + char test_path[PATH_MAX]; + int err = -ENOENT; + + /* A name containing '/' is run verbatim by the shell, no PATH search. */ + if (strchr(name, '/')) + return access_executable(name); + + path_env = getenv("PATH"); + if (!path_env) + return -ENOENT; + + /* Walk PATH segment by segment without copying it (no strtok). */ + for (seg = path_env; *seg; ) { + size_t dir_len = strcspn(seg, ":"); + + /* skip empty and oversized entries */ + if (dir_len && + snprintf(test_path, sizeof(test_path), "%.*s/%s", + (int)dir_len, seg, name) < (int)sizeof(test_path)) { + int ret = access_executable(test_path); + if (ret == 0) + return 0; + if (ret == -EACCES) + err = -EACCES; + } + + seg += dir_len; + if (*seg == ':') + seg++; + } + + return err; +} #endif #ifdef HAVE_SERVICEMOUNT @@ -540,7 +595,16 @@ int main(int argc, char *argv[]) #ifdef linux if (drop_privileges) { + int err; + drop_and_lock_capabilities(); + err = check_binary_access(type); + if (err) { + fprintf(stderr, "%s: cannot execute '%s' after dropping privileges (%s)\n", + progname, type, + err == -EACCES ? "permission denied" : "not found"); + exit(1); + } } #endif