Skip to content
Merged
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
13 changes: 12 additions & 1 deletion mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n
test-config \
test-mremap-tail-emfile \
test-proctitle-host test-proctitle-low-stack \
test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \
test-sysroot-procfs-exec test-sysroot-fd-magiclink \
test-timeout-disable test-fuse-alpine \
test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \
test-sysroot-dotdot test-sysroot-openat2-walk \
test-sysroot-inotify-names test-sysroot-exec-names \
Expand Down Expand Up @@ -212,6 +213,7 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage test-config \
$(call run-lane,test-proctitle-low-stack,proctitle low-stack regression)
$(call run-lane,test-busybox,busybox applet validation)
$(call run-lane,test-sysroot-procfs-exec,sysroot procfs exec validation)
$(call run-lane,test-sysroot-fd-magiclink,fd magic link resolution)
$(call run-lane,test-getdents64-overlong,getdents64 overlong-UTF-8 dirent skip)
$(call run-lane,test-sysroot-host-fallback,sysroot host-fallback validation)
$(call run-lane,test-sysroot-tmp-remove,sysroot /tmp remove/rename consistency)
Expand Down Expand Up @@ -1010,6 +1012,15 @@ test-sysroot-procfs-exec: $(ELFUSE_BIN) $(BUILD_DIR)/test-procfs-exec
cp $(BUILD_DIR)/test-procfs-exec "$$tmpdir/bin/test-procfs-exec"; \
$(ELFUSE_BIN) --sysroot "$$tmpdir" "$$tmpdir/bin/test-procfs-exec"

## Magic link (/proc/self/fd/<n>, /dev/fd/<n>) resolution. Runs in a throwaway
## sysroot so the fixtures it creates at the guest root land in the tmpdir.
test-sysroot-fd-magiclink: $(ELFUSE_BIN) $(BUILD_DIR)/test-fd-magiclink
@tmpdir=$$(mktemp -d); \
trap 'rm -rf "$$tmpdir"' EXIT; \
mkdir -p "$$tmpdir/bin"; \
cp $(BUILD_DIR)/test-fd-magiclink "$$tmpdir/bin/test-fd-magiclink"; \
$(ELFUSE_BIN) --sysroot "$$tmpdir" "$$tmpdir/bin/test-fd-magiclink"

test-timeout-disable: $(ELFUSE_BIN) $(TEST_HELLO_DEP)
@$(ELFUSE_BIN) --timeout 0 $(TEST_DIR)/test-hello > /dev/null

Expand Down
8 changes: 3 additions & 5 deletions src/runtime/procemu.c
Original file line number Diff line number Diff line change
Expand Up @@ -814,14 +814,12 @@ static int proc_parse_fd_index(const char *path,
size_t prefix_len,
int errno_on_invalid)
{
char *endp;
long n = strtol(path + prefix_len, &endp, 10);
if (endp == path + prefix_len || *endp != '\0' || n < 0 ||
n >= FD_TABLE_SIZE) {
int n = path_parse_proc_name(path + prefix_len);
if (n < 0 || n >= FD_TABLE_SIZE) {
errno = errno_on_invalid;
return -1;
}
return (int) n;
return n;
}

/* Map a guest /dev/shm/<name> path to its host backing path, and gate the name.
Expand Down
44 changes: 44 additions & 0 deletions src/syscall/fs.c
Original file line number Diff line number Diff line change
Expand Up @@ -2734,6 +2734,19 @@ int64_t sys_fchmodat(guest_t *g,
return 0;
}

/* An fd magic link names the descriptor's file, and Linux resolves it
* inside the syscall. Act on the descriptor so nothing can redirect the
* chmod between resolution and use; see path_fd_magiclink_dup().
*/
if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) {
int magic_fd = path_fd_magiclink_dup(path);
if (magic_fd >= 0) {
int mrc = fchmod(magic_fd, mode);
close_keep_errno(magic_fd);
return mrc < 0 ? linux_errno() : 0;
}
}

path_translation_t tx;
if (path_translate_at(dirfd, path,
path_tr_nofollow(flags & LINUX_AT_SYMLINK_NOFOLLOW),
Expand Down Expand Up @@ -2898,6 +2911,24 @@ int64_t sys_fchownat(guest_t *g,
return out;
}

/* Same reasoning as the fd magic link branch in sys_fchmodat: act on the
* descriptor, not on a pathname resolved from it a moment earlier.
*/
if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) {
int magic_fd = path_fd_magiclink_dup(path);
if (magic_fd >= 0) {
int host_rc = fchown(magic_fd, owner, group);
int saved_errno = errno;
struct stat host_st;
const struct stat *st_ptr =
fstat(magic_fd, &host_st) == 0 ? &host_st : NULL;
errno = saved_errno;
int64_t out = chown_result(host_rc, st_ptr, owner, group);
close_keep_errno(magic_fd);
return out;
}
}

path_translation_t tx;
if (path_translate_at(dirfd, path,
path_tr_nofollow(flags & LINUX_AT_SYMLINK_NOFOLLOW),
Expand Down Expand Up @@ -3040,6 +3071,19 @@ int64_t sys_utimensat(guest_t *g,
host_fd_ref_close(&dir_ref);
return rc;
}

/* Same reasoning as the fd magic link branch in sys_fchmodat: act on
* the descriptor, not on a pathname resolved from it a moment earlier.
*/
if (!(flags & LINUX_AT_SYMLINK_NOFOLLOW)) {
int magic_fd = path_fd_magiclink_dup(path);
if (magic_fd >= 0) {
int mrc = futimens(magic_fd, times_gva ? ts : NULL);
close_keep_errno(magic_fd);
host_fd_ref_close(&dir_ref);
return mrc < 0 ? linux_errno() : 0;
}
}
rc = reject_unsupported_fuse_path_op(&tx);
if (rc != INT64_MIN) {
host_fd_ref_close(&dir_ref);
Expand Down
174 changes: 174 additions & 0 deletions src/syscall/path.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "syscall/fuse.h"
#include "proved/pathdepth.h"

#include "syscall/internal.h" /* fd_to_host_dup */
#include "syscall/path.h"
#include "syscall/proc.h"

Expand Down Expand Up @@ -200,6 +201,146 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
char *host_out,
size_t host_outsz);

int path_parse_proc_name(const char *name)
{
if (!name || !*name)
return -1;
/* Linux rejects a leading zero on any name longer than one character, so
* "0" names descriptor 0 but "00" and "03" name nothing.
*/
if (name[0] == '0' && name[1] != '\0')
return -1;

long n = 0;
for (const char *p = name; *p; p++) {
if (*p < '0' || *p > '9')
return -1;
n = n * 10 + (*p - '0');
if (n > INT_MAX)
return -1;
}
return (int) n;
}

/* Parse an absolute fd magic link to the guest descriptor it names. This
* accepts
* "/proc/self/fd/<n>", the equivalent spelling with this process's own pid, and
* the /dev aliases Linux exposes as symlinks to procfs.
*
* Linux makes that a magic symlink, so a path-based syscall against it acts on
* the file the descriptor holds. It is the standard way to reach a file through
* an fd when no f*() variant applies -- systemd's fchmod_opath() chmods
* /proc/self/fd/<n> precisely because fchmod() rejects O_PATH descriptors, and
* reads ENOENT there as "this fd is not valid" (reporting EBADF) rather than as
* a missing file.
*
* Returns the guest descriptor, or -1 when the path is not that shape.
*/
static int parse_fd_magiclink(const char *path)
{
const char *rest = NULL;

if (strncmp(path, "/proc/", 6) == 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The /proc magic-link shape is matched with exact strncmp against fixed prefixes, so non-canonical spellings that Linux normalizes — /proc/self//fd/3, /proc//self/fd/3, /proc/self/fd//3 — are not recognized and fall through to generic resolution, which fails on the host with ENOENT. This only affects redundant-separator spellings (glibc usually normalizes before syscalls, but raw syscalls can pass them), so it is low impact, but the fix leaves those spellings broken while the canonical form works.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 246:

<comment>The /proc magic-link shape is matched with exact strncmp against fixed prefixes, so non-canonical spellings that Linux normalizes — /proc/self//fd/3, /proc//self/fd/3, /proc/self/fd//3 — are not recognized and fall through to generic resolution, which fails on the host with ENOENT. This only affects redundant-separator spellings (glibc usually normalizes before syscalls, but raw syscalls can pass them), so it is low impact, but the fix leaves those spellings broken while the canonical form works.</comment>

<file context>
@@ -200,6 +201,106 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
+{
+    const char *rest = NULL;
+
+    if (strncmp(path, "/proc/", 6) == 0) {
+        rest = path + 6;
+        if (!strncmp(rest, "self/", 5)) {
</file context>

rest = path + 6;
if (!strncmp(rest, "self/", 5)) {
rest += 5;
} else {
/* The pid component gets the same strict rules as the fd leaf:
* Linux resolves /proc/<pid> through name_to_int as well, so
* "/proc/+1234/fd/3" names nothing there even when 1234 is this
* process. A component too long for the buffer is not a pid either.
*/
const char *slash = strchr(rest, '/');
if (!slash)
return -1;
char pid_name[16];
if (path_component_copy(pid_name, sizeof(pid_name), rest,
(size_t) (slash - rest)) < 0)
return -1;
if (path_parse_proc_name(pid_name) != (int) proc_get_pid())
return -1;
rest = slash + 1;
}

if (strncmp(rest, "fd/", 3) != 0)
return -1;
rest += 3;
} else if (strncmp(path, "/dev/fd/", 8) == 0) {
rest = path + 8;
} else if (!strcmp(path, "/dev/stdin")) {
rest = "0";
} else if (!strcmp(path, "/dev/stdout")) {
rest = "1";
} else if (!strcmp(path, "/dev/stderr")) {
rest = "2";
} else {
return -1;
}

/* Only a bare descriptor number names the file itself. Anything trailing
* ("/proc/self/fd/3/x" or "/dev/fd/3/x") walks through it, which the host
* path cannot express here, and a leaf Linux would not accept as a procfs
* fd name is not this shape at all.
*/
return path_parse_proc_name(rest);
}

int path_fd_magiclink_dup(const char *path)
{
int fd = parse_fd_magiclink(path);
if (fd < 0)
return -1;

/* Only descriptors whose host fd is the object itself. A FUSE or synthetic
* fd is served by an emulation layer rather than by the host file behind
* it, so an f*() call would act on the wrong thing; those keep the path
* form and the intercepts that go with it.
*/
fd_entry_t snap;
if (!fd_snapshot(fd, &snap))
return -1;
if (snap.type != FD_REGULAR && snap.type != FD_DIR &&
snap.type != FD_PATH && snap.type != FD_STDIO)
return -1;

/* dup under fd_lock: a sibling vCPU closing this slot would otherwise
* leave the number free for the next open to claim.
*/
return fd_to_host_dup(fd);
}

/* Resolve an absolute fd magic link to the host path its descriptor is open on.
*
* Returns 1 and fills out on success, 0 when the path is not that shape or the
* descriptor has no host path (a pipe, socket, or anonymous fd, where F_GETPATH
* fails and the caller's own /proc intercepts remain the right answer).
*
* Callers that can act on a descriptor should prefer path_fd_magiclink_dup():
* a pathname taken here and used later is a TOCTOU, since a rename or an
* unlink-and-recreate in between leaves it naming a different inode, where
* Linux resolves the link inside the syscall and cannot be redirected.
*/
static int resolve_fd_magiclink_host_path(const char *path,
char *out,
size_t outsz)
{
int host_fd = path_fd_magiclink_dup(path);
if (host_fd < 0)
return 0;

char resolved[MAXPATHLEN];
int rc = fcntl(host_fd, F_GETPATH, resolved);
close(host_fd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When the fd's file is renamed, unlinked, or replaced after F_GETPATH returns, the later path-based syscall uses a stale pathname and can fail with ENOENT or modify a different inode instead of the file held by the fd. Keep the duplicated descriptor alive through the operation or use an identity-preserving fd-backed operation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 258:

<comment>When the fd's file is renamed, unlinked, or replaced after `F_GETPATH` returns, the later path-based syscall uses a stale pathname and can fail with `ENOENT` or modify a different inode instead of the file held by the fd. Keep the duplicated descriptor alive through the operation or use an identity-preserving fd-backed operation.</comment>

<file context>
@@ -200,6 +201,71 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
+
+    char resolved[MAXPATHLEN];
+    int rc = fcntl(host_fd, F_GETPATH, resolved);
+    close(host_fd);
+    if (rc < 0)
+        return 0;
</file context>

@maxliu04002 maxliu04002 Aug 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed — the metadata syscalls now act on the descriptor

One correction to the suggested remedy, since it shaped the fix: keeping the dup alive through the operation would not have fixed this. Holding the descriptor keeps the old inode from being reclaimed, but it does nothing to the pathname — if the file is renamed, or unlinked and recreated at the same name, the path we handed the caller resolves to a different inode whether or not we still hold the old one. Only the second half of your suggestion, an identity-preserving fd-backed operation, actually closes it. That's what I implemented.

path_fd_magiclink_dup() now returns an owned dup of the descriptor a magic link names, and sys_fchmodat, sys_fchownat and sys_utimensat use fchmod / fchown / futimens on it instead of the translated pathname. resolve_fd_magiclink_host_path() is refactored on top of the same parse and kept for the follow-style operations with no fd form (truncate, access), with a note pointing at the fd variant.

This works here for a reason worth recording: elfuse types O_PATH descriptors FD_PATH, but macOS has no O_PATH, so the host fd is a real open descriptor and fchmod() on it succeeds — the very thing that fails on Linux and sent fchmod_opath() to /proc/self/fd/<n> in the first place.

Two guards on the dup:

  • Only FD_REGULAR, FD_DIR, FD_PATH, FD_STDIO. A FUSE or synthetic descriptor is served by an emulation layer rather than the host object behind it, so an f*() call would hit the wrong thing; those keep the path form and its intercepts.
  • fchownat routes through the existing chown_result() reconciliation rather than calling fchown bare, matching its AT_EMPTY_PATH branch under fakeroot.

Proof it follows the descriptor, not a name

The file is unlinked while held, so no pathname can reach it:

printf hi > /tmp/toctou.txt; chmod 600 /tmp/toctou.txt
exec 9<>/tmp/toctou.txt
rm /tmp/toctou.txt                    # path gone, descriptor still holds the inode
chmod 0644 /proc/self/fd/9            -> OK
stat -c "mode=%a links=%h"            -> mode=644 links=0

links=0 confirms it acted on the unlinked inode. The pathname form could only have returned ENOENT there.

Still correct on the rest

   
chmod via magic link 600 → 644
utimensat via magic link mtime advanced from 2020 to now
unlink via magic link still refused, target intact
10 sysroot + procfs suites 72 assertions, all pass
make indent no diff

if (rc < 0)
return 0;

size_t len = strlen(resolved);
if (len >= outsz)
return 0;
memcpy(out, resolved, len + 1);
return 1;
}

int path_translate_at(guest_fd_t dirfd,
const char *path,
unsigned int flags,
Expand Down Expand Up @@ -265,6 +406,39 @@ int path_translate_at(guest_fd_t dirfd,
return 0;
}

/* Only host_path moves; guest_path and intercept_path keep the /proc
* spelling. open, stat and readlink never reach host_path for these paths:
* proc_intercept_open dups the descriptor, proc_intercept_stat fstats it,
* and proc_intercept_readlink reports its path, and none of the three fall
* through to the host on a fd magic link that names an open slot (a
* closed one fails as EBADF rather than falling through). What this changes
* is every other follow-style operation -- chmod, chown, utimensat,
* truncate, access -- which now acts on the file the descriptor holds, the
* way Linux does when it resolves the magic link.
*
* Returning before sysroot resolution is not a containment claim about the
* path: F_GETPATH reports where the descriptor's file actually lives, which
* is regularly outside the sysroot -- an emulated character device, a
* /dev/shm backing file, inherited stdio. Re-resolving one of those as a
* guest path would be wrong, since it is already a host path. Nothing is
* widened by it either: the guest holds the descriptor, so this reaches
* only what it could already reach through it.
*
* Follow-style only. Linux resolves the link for an operation that follows
* the final component and acts on the link itself otherwise, so a no-follow
* or create-style caller -- unlinkat, renameat, chmod with
* AT_SYMLINK_NOFOLLOW -- must not be handed the descriptor's file, or
* unlinkat("/proc/self/fd/<n>") would delete it instead of failing on the
* /proc entry.
*/
if (tx->guest_path[0] == '/' &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This early return rewrites host_path for every follow-style absolute /proc/self/fd/ translation, not just the chmod/chown/utimensat family the PR targets. When the /proc open intercept does not serve the path (sys_openat_path falls through to open(tx.host_path) at fs.c:573), open/stat behavior changes from ENOENT to acting on the resolved file, contradicting the stated invariant that open/stat/readlink stay unchanged. Verify the /proc intercept always shadows these paths before relying on host_path-only scoping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/path.c, line 348:

<comment>This early return rewrites host_path for every follow-style absolute /proc/self/fd/<n> translation, not just the chmod/chown/utimensat family the PR targets. When the /proc open intercept does not serve the path (sys_openat_path falls through to open(tx.host_path) at fs.c:573), open/stat behavior changes from ENOENT to acting on the resolved file, contradicting the stated invariant that open/stat/readlink stay unchanged. Verify the /proc intercept always shadows these paths before relying on host_path-only scoping.</comment>

<file context>
@@ -265,6 +331,28 @@ int path_translate_at(guest_fd_t dirfd,
+     * file, or unlinkat("/proc/self/fd/<n>") would delete it instead of failing
+     * on the /proc entry.
+     */
+    if (tx->guest_path[0] == '/' &&
+        !(flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)) &&
+        resolve_proc_fd_host_path(tx->guest_path, tx->host_buf,
</file context>

!(flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)) &&
resolve_fd_magiclink_host_path(tx->guest_path, tx->host_buf,
sizeof(tx->host_buf))) {
tx->host_path = tx->host_buf;
return 0;
}

unsigned int lookup_flags = flags;
if (path_has_trailing_slash(tx->guest_path))
lookup_flags &= ~PATH_TR_NOFOLLOW;
Expand Down
26 changes: 26 additions & 0 deletions src/syscall/path.h
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,29 @@ int path_openat2_crosses_mount(guest_fd_t dirfd,
* symlink-driven crossings that the string-only precheck misses by design.
*/
int path_openat2_check_fd_xdev(int guest_fd, int start_class);

/* Parse a numeric procfs component the way Linux's name_to_int() does: decimal
* digits only, so no sign, no leading whitespace, and no leading zero unless
* the name is "0" itself. The kernel runs both the pid and the fd component
* through it, so both get the same rules here. strtol() accepts all three
* spellings, which made "/proc/self/fd/+3", "/proc/self/fd/03", "/proc/self/fd/
* 3" and the matching pid forms resolve here while Linux reports ENOENT for
* each.
*
* Returns the value, or -1 when the name is not that shape. The caller applies
* its own upper bound and errno.
*/
int path_parse_proc_name(const char *name);

/* An owned dup of the descriptor an absolute fd magic link names
* ("/proc/self/fd/<n>", the own-pid spelling, "/dev/fd/<n>", "/dev/std*"), or
* -1 when the path is not that shape or its descriptor is not backed by a plain
* host object. The caller closes it.
*
* Metadata syscalls should act on this rather than on the translated pathname.
* Linux resolves the magic link inside the syscall, so nothing can redirect it;
* resolving to a pathname and operating on it a moment later can land on a
* different inode if the file is renamed, or unlinked and recreated, in
* between.
*/
int path_fd_magiclink_dup(const char *path);
Loading
Loading