Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 78 additions & 16 deletions src/syscall/fs.c
Original file line number Diff line number Diff line change
Expand Up @@ -2410,16 +2410,40 @@ int64_t sys_fchmodat(guest_t *g,
int flags)
{
char path[LINUX_PATH_MAX];
if (!validate_at_flags(flags, LINUX_AT_SYMLINK_NOFOLLOW))
if (!validate_at_flags(flags,
LINUX_AT_SYMLINK_NOFOLLOW | LINUX_AT_EMPTY_PATH))
return -LINUX_EINVAL;
if (guest_read_str(g, path_gva, path, sizeof(path)) < 0)
return -LINUX_EFAULT;

/* AT_EMPTY_PATH with an empty path chmods dirfd itself; see the identical
* branch in sys_fchownat above for the O_PATH/AT_FDCWD rationale.
*/
if ((flags & LINUX_AT_EMPTY_PATH) && path[0] == '\0') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty-path branch bypasses FUSE interception (mirror at :2519 in fchownat). It returns before path_translate_at + reject_unsupported_fuse_path_op, so it diverges from the named path in two ways:

  • A dirfd inside a FUSE mount has host_fd == -1 (fuse.c:1893), so host_dirfd_ref_open returns EBADF here, whereas the named path returns ENOSYS.
  • If cwd is inside a FUSE mount, the AT_FDCWD sub-case calls host fchmodat(".", ...) directly, mutating the host backing path instead of returning ENOSYS.

Suggested fix: snapshot dirfd first and return -LINUX_ENOSYS for FD_FUSE_* to match reject_unsupported_fuse_path_op.

if (dirfd == LINUX_AT_FDCWD) {
if (fchmodat(AT_FDCWD, ".", mode, translate_at_flags(flags)) < 0)
return linux_errno();
return 0;
}

host_fd_ref_t ref;
if (host_dirfd_ref_open(dirfd, &ref) < 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proc-intercept divergence from the mirrored stat_at_path (mirror at :2533 in fchownat). stat_at_path checks snap.type == FD_PATH && snap.proc_path[0] and routes to proc_intercept_stat; this branch skips that and fchmod/fchowns the backing host temp fd directly.

A guest that opens a synthesized /proc path O_PATH then calls fchownat(fd, "", ..., AT_EMPTY_PATH) silently mutates elfuse's internal temp file and reports success (Linux would return EPERM). chown_result's EPERM-to-fakeroot-success mapping hides it further.

Suggested fix: snapshot the fd before host_dirfd_ref_open; for FD_PATH with proc_path set, return EPERM instead of touching the backing file.

return -LINUX_EBADF;
if (fchmod(ref.fd, mode) < 0) {
host_fd_ref_close(&ref);
return linux_errno();
}
host_fd_ref_close(&ref);
return 0;
}

path_translation_t tx;
int64_t rc = read_translated_path(
g, dirfd, path_gva,
(flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE,
path, &tx);
if (rc < 0)
return rc;
rc = reject_unsupported_fuse_path_op(&tx);
if (path_translate_at(dirfd, path,
(flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW
: PATH_TR_NONE,
&tx) < 0)
return linux_errno();
int64_t rc = reject_unsupported_fuse_path_op(&tx);
if (rc != INT64_MIN)
return rc;

Expand Down Expand Up @@ -2479,16 +2503,54 @@ int64_t sys_fchownat(guest_t *g,
int flags)
{
char path[LINUX_PATH_MAX];
if (!validate_at_flags(flags, LINUX_AT_SYMLINK_NOFOLLOW))
if (!validate_at_flags(flags,
LINUX_AT_SYMLINK_NOFOLLOW | LINUX_AT_EMPTY_PATH))
return -LINUX_EINVAL;
if (guest_read_str(g, path_gva, path, sizeof(path)) < 0)
return -LINUX_EFAULT;

/* AT_EMPTY_PATH with an empty path chowns dirfd itself rather than a name
* beneath it. This is the only way to chown an O_PATH fd (plain fchown()
* rejects FD_PATH, matching Linux's EBADF there), so unlike the other
* *at() flag validation here it has to actually be handled, not just
* accepted. dirfd == AT_FDCWD resolves to the current directory, mirroring
* stat_at_path's identical AT_EMPTY_PATH branch in fs-stat.c.
*/
if ((flags & LINUX_AT_EMPTY_PATH) && path[0] == '\0') {
int mac_flags = translate_at_flags(flags);
if (dirfd == LINUX_AT_FDCWD) {
int host_rc = fchownat(AT_FDCWD, ".", owner, group, mac_flags);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU on the AT_FDCWD overlay update. fchownat(AT_FDCWD, ".") here and the follow-up fstatat(AT_FDCWD, ".") at :2526 resolve "." twice. A concurrent chdir() in another guest thread between them records the requested uid/gid overlay against a different directory's dev/ino. The dirfd sub-case is immune because it fchown+fstat the same fd.

Suggested fix: open "." once and fchown(fd)+fstat(fd) on that single descriptor, like the dirfd branch.

int saved_errno = errno;
struct stat host_st;
const struct stat *st_ptr =
fstatat(AT_FDCWD, ".", &host_st, mac_flags) == 0 ? &host_st
: NULL;
errno = saved_errno;
return chown_result(host_rc, st_ptr, owner, group);
}

host_fd_ref_t ref;
if (host_dirfd_ref_open(dirfd, &ref) < 0)
return -LINUX_EBADF;

int host_rc = fchown(ref.fd, owner, group);
int saved_errno = errno;
struct stat host_st;
const struct stat *st_ptr =
fstat(ref.fd, &host_st) == 0 ? &host_st : NULL;
errno = saved_errno;
int64_t out = chown_result(host_rc, st_ptr, owner, group);
host_fd_ref_close(&ref);
return out;
}

path_translation_t tx;
int64_t rc = read_translated_path(
g, dirfd, path_gva,
(flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE,
path, &tx);
if (rc < 0)
return rc;
rc = reject_unsupported_fuse_path_op(&tx);
if (path_translate_at(dirfd, path,
(flags & LINUX_AT_SYMLINK_NOFOLLOW) ? PATH_TR_NOFOLLOW
: PATH_TR_NONE,
&tx) < 0)
return linux_errno();
int64_t rc = reject_unsupported_fuse_path_op(&tx);
if (rc != INT64_MIN)
return rc;

Expand Down
166 changes: 166 additions & 0 deletions tests/test-fchmodat-empty-path.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* fchmodat AT_EMPTY_PATH tests
*
* Copyright 2026 elfuse contributors
* SPDX-License-Identifier: Apache-2.0
*
* fchmodat(fd, "", mode, AT_EMPTY_PATH) chmods fd itself instead of a name
* beneath it. This is the only way to chmod an O_PATH descriptor -- plain
* fchmod() rejects FD_PATH with EBADF, matching Linux -- and AT_FDCWD
* resolves to the current directory rather than being an error.
*
* The classic fchmodat(2) syscall predates any flags argument, so libc's
* fchmodat() wrapper (glibc and musl both) rejects any flag other than
* AT_SYMLINK_NOFOLLOW in userspace without ever making a syscall. Reaching
* AT_EMPTY_PATH support requires the flags-aware fchmodat2(2) (Linux 6.6),
* invoked here directly via syscall() since libc has no wrapper for it yet.
*/

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>

#include "test-harness.h"

#ifndef O_PATH
#define O_PATH 010000000
#endif
#ifndef AT_EMPTY_PATH
#define AT_EMPTY_PATH 0x1000
#endif
#ifndef SYS_fchmodat2
#define SYS_fchmodat2 452
#endif

static int fchmodat2(int dirfd, const char *path, mode_t mode, int flags)
{
return (int) syscall(SYS_fchmodat2, dirfd, path, (long) mode, (long) flags);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No ENOSYS skip guard for fchmodat2. This issues raw fchmodat2(2) (SYS 452, Linux 6.6+) with no fallback. On a qemu fixture running a <6.6 kernel it fails hard rather than skipping. Worth confirming the fixture kernel is >= 6.6 (the count bump to 217 assumes it).

}

int passes = 0, fails = 0;

static char tmp_file[256];
static char tmp_dir[256];

static int setup_fixtures(void)
{
snprintf(tmp_file, sizeof(tmp_file), "/tmp/elfuse-fchmodat-empty-%d.txt",
(int) getpid());
int fd = open(tmp_file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("setup: open tmp_file");
return -1;
}
close(fd);

/* The AT_FDCWD case below needs a writable cwd; the process's inherited
* cwd may not be (e.g. a read-only rootfs in a VM test image).
*/
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/elfuse-fchmodat-empty-dir-%d",
(int) getpid());
if (mkdir(tmp_dir, 0755) < 0) {
perror("setup: mkdir tmp_dir");
return -1;
}
if (chdir(tmp_dir) < 0) {
perror("setup: chdir tmp_dir");
return -1;
}
return 0;
}

static void teardown_fixtures(void)
{
unlink(tmp_file);
chdir("/tmp");
rmdir(tmp_dir);
}

static void test_empty_path_via_opath_fd(void)
{
TEST("fchmodat(O_PATH fd, \"\", AT_EMPTY_PATH) chmods the file");
int fd = open(tmp_file, O_PATH);
if (fd < 0) {
FAIL("open O_PATH");
return;
}
if (fchmodat2(fd, "", 0600, AT_EMPTY_PATH) != 0) {
FAIL("fchmodat");
close(fd);
return;
}
struct stat st;
int ok = fstat(fd, &st) == 0 && (st.st_mode & 0777) == 0600;
close(fd);
EXPECT_TRUE(ok, "mode mismatch");
}

static void test_plain_fchmod_still_rejects_opath(void)
{
TEST("fchmod(O_PATH fd) still returns EBADF");
int fd = open(tmp_file, O_PATH);
if (fd < 0) {
FAIL("open O_PATH");
return;
}
int rc = fchmod(fd, 0644);
int err = errno;
close(fd);
EXPECT_TRUE(rc == -1 && err == EBADF, "fchmod should reject O_PATH");
}

static void test_empty_path_via_regular_fd(void)
{
TEST("fchmodat(regular fd, \"\", AT_EMPTY_PATH) chmods the file");
int fd = open(tmp_file, O_RDONLY);
if (fd < 0) {
FAIL("open");
return;
}
if (fchmodat2(fd, "", 0640, AT_EMPTY_PATH) != 0) {
FAIL("fchmodat");
close(fd);
return;
}
struct stat st;
int ok = fstat(fd, &st) == 0 && (st.st_mode & 0777) == 0640;
close(fd);
EXPECT_TRUE(ok, "mode mismatch");
}

static void test_empty_path_at_fdcwd_targets_cwd(void)
{
TEST("fchmodat(AT_FDCWD, \"\", AT_EMPTY_PATH) chmods cwd");
if (fchmodat2(AT_FDCWD, "", 0700, AT_EMPTY_PATH) != 0) {
FAIL("fchmodat");
return;
}
struct stat st;
int ok = stat(".", &st) == 0 && (st.st_mode & 0777) == 0700;
/* Restore a mode that still lets teardown_fixtures rmdir it. */
chmod(".", 0755);
EXPECT_TRUE(ok, "mode mismatch");
}

int main(void)
{
printf("test-fchmodat-empty-path: AT_EMPTY_PATH chmod-by-fd semantics\n");

if (setup_fixtures() < 0) {
printf("test-fchmodat-empty-path: fixture setup failed\n");
return 1;
}

test_empty_path_via_opath_fd();
test_plain_fchmod_still_rejects_opath();
test_empty_path_via_regular_fd();
test_empty_path_at_fdcwd_targets_cwd();

teardown_fixtures();

SUMMARY("test-fchmodat-empty-path");
return fails == 0 ? 0 : 1;
}
Loading
Loading