mount.fuse: clear error and test skip when binary unreachable after privilege drop

With drop_privileges, mount.fuse3 execs the file system binary only
after dropping all capabilities (CAP_DAC_OVERRIDE included) while
keeping uid 0. The kernel then applies plain DAC checks to the exec, so
a binary under a directory the capability-less process cannot traverse
(e.g. a user-owned mode-0700 home) fails with EACCES and /bin/sh prints
a bare 'Permission denied', surfacing in the tests as 'file system
process terminated prematurely'.

Resolving and opening the binary before the drop was rejected: it would
perform a privileged path resolution and open() on invoker-influenced
input (confused-deputy / privilege-escalation risk). The drop-first,
resolve-after order is preserved.

Instead, pre-flight the upcoming PATH lookup after the drop and fail
with a diagnostic that names the binary and points at mount.fuse3(8).
The check runs with the final credentials, so it is deny-only. The test
detects the same situation up front (uid/gid-aware traversal check) and
skips with a precise reason; root-owned CI checkouts keep running.

Signed-off-by: Bernd Schubert <bernd@bsbernd.com>
This commit is contained in:
Bernd Schubert
2026-06-11 19:53:41 +02:00
parent 4633c30df6
commit 3fedbac9b6
3 changed files with 89 additions and 1 deletions
+1 -1
View File
@@ -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
+24
View File
@@ -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:
+64
View File
@@ -16,6 +16,7 @@
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdint.h>
#include <fcntl.h>
#include <pwd.h>
@@ -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