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
58 changes: 57 additions & 1 deletion mk/tests.mk
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
test-sysroot-create-paths test-fork-ipc-protocol-host test-identity-override-host \
test-proctitle-host test-proctitle-low-stack \
test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \
test-sysroot-nofollow test-sysroot-chdir perf
test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \
test-linkat-symlink-fallback perf

## Build and run the assembly hello world test
test-hello: $(ELFUSE_BIN) $(TEST_HELLO_DEP)
Expand Down Expand Up @@ -113,6 +114,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \
@$(MAKE) --no-print-directory test-sysroot-host-fallback
@printf "\n$(BLUE)━━━ sysroot byte-exact lookup validation ━━━$(RESET)\n"
@$(MAKE) --no-print-directory test-sysroot-case-exact
@printf "\n$(BLUE)━━━ sysroot relative-dirfd symlink escape validation ━━━$(RESET)\n"
@$(MAKE) --no-print-directory test-sysroot-symlink-escape
@printf "\n$(BLUE)━━━ Alpine sysroot FUSE validation ━━━$(RESET)\n"
@$(MAKE) --no-print-directory test-fuse-alpine
@printf "\n$(BLUE)━━━ timeout=0 validation ━━━$(RESET)\n"
Expand Down Expand Up @@ -172,6 +175,59 @@ test-sysroot-chdir: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-chdir
mkdir -p "$$tmpdir/bin" "$$tmpdir/lib" "$$tmpdir/lib/elfuse-sysroot-shadow"; \
$(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-chdir

## A symlink reachable through a sysroot-contained dirfd must not escape via
## openat(dirfd, name): stages both an absolute-target and a deep-".."
## relative-target symlink under $sysroot/d1, each pointing at a secret file
## in a second tmpdir well outside the sysroot.
test-sysroot-symlink-escape: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-escape
@set -e; \
tmpdir=$$(mktemp -d); \
secret_dir=$$(mktemp -d); \
trap 'rm -rf "$$tmpdir" "$$secret_dir"' EXIT; \
mkdir -p "$$tmpdir/d1"; \
printf 'inside-sysroot\n' > "$$tmpdir/d1/normal.txt"; \
printf 'SECRET-HOST-FILE\n' > "$$secret_dir/secret.txt"; \
ln -sf "$$secret_dir/secret.txt" "$$tmpdir/d1/abs-link"; \
depth=$$(printf '%s' "$$tmpdir/d1" | tr -cd '/' | wc -c | tr -d ' '); \
relback=""; i=0; \
while [ "$$i" -lt "$$depth" ]; do relback="../$$relback"; i=$$((i + 1)); done; \
ln -sf "$${relback}$${secret_dir#/}/secret.txt" "$$tmpdir/d1/rel-link"; \
$(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-symlink-escape

## sys_linkat() falls back to symlinkat() when the host linkat() rejects a
## hard link to a symlink source. APFS accepts that call directly and would
## never exercise the fallback, so this builds a scratch Case-sensitive HFS+
## disk image via hdiutil (same tool sysroot.c uses for --create-sysroot
## sparsebundles) and uses it as --sysroot. Not wired into `check`: like
## test-case-collision, disk-image creation is heavier than the plain-tmpdir
## sysroot tests above, so this stays a standalone, manually-run target.
## Skips (exit 0) when hdiutil/HFS+ is unavailable, or when this particular
## volume happens to allow linkat-to-symlink directly and so cannot exercise
## the fallback.
test-linkat-symlink-fallback: $(ELFUSE_BIN) $(BUILD_DIR)/test-linkat-symlink-fallback
@dmg=$$(mktemp -u).dmg; mnt=""; \
trap '[ -n "$$mnt" ] && hdiutil detach "$$mnt" -quiet >/dev/null 2>&1; rm -f "$$dmg"' EXIT; \
if ! hdiutil create -size 16m -fs "Case-sensitive HFS+" \
-volname elfuselinkfb -quiet "$$dmg" >/dev/null 2>&1; then \
printf "$(YELLOW)SKIP$(RESET) test-linkat-symlink-fallback (hdiutil create failed)\n"; \
exit 0; \
fi; \
mnt=$$(hdiutil attach "$$dmg" -nobrowse | awk '/\/Volumes\//{print $$NF}'); \
if [ -z "$$mnt" ]; then \
printf "$(YELLOW)SKIP$(RESET) test-linkat-symlink-fallback (hdiutil attach failed)\n"; \
exit 0; \
fi; \
sysroot="$$mnt/sysroot"; \
mkdir -p "$$sysroot/d1"; \
printf 'hello\n' > "$$sysroot/d1/target.txt"; \
ln -s target.txt "$$sysroot/d1/sym"; \
if ln -P "$$sysroot/d1/sym" "$$sysroot/d1/probe" 2>/dev/null; then \
rm -f "$$sysroot/d1/probe"; \
printf "$(YELLOW)SKIP$(RESET) test-linkat-symlink-fallback (volume allows linkat-to-symlink directly; fallback not exercised)\n"; \
exit 0; \
fi; \
$(ELFUSE_BIN) --sysroot "$$sysroot" $(BUILD_DIR)/test-linkat-symlink-fallback

test-case-collision: $(ELFUSE_BIN) $(BUILD_DIR)/test-case-collision
@tmpdir=$$(mktemp -d); \
trap 'rm -rf "$$tmpdir"' EXIT; \
Expand Down
39 changes: 36 additions & 3 deletions src/syscall/fs.c
Original file line number Diff line number Diff line change
Expand Up @@ -2148,9 +2148,42 @@ int64_t sys_linkat(guest_t *g,
int mac_flags = translate_at_flags(flags);
if (linkat(olddir_ref.fd, old_tx.host_path, newdir_ref.fd, new_tx.host_path,
mac_flags) < 0) {
host_fd_ref_close(&olddir_ref);
host_fd_ref_close(&newdir_ref);
return linux_errno();
/* Darwin's linkat(2) man page: without AT_SYMLINK_FOLLOW, hard-linking
* a symlink itself (rather than its target) "may result in some file
* systems returning an error" -- reproduced here as ENOTSUP on
* Case-sensitive HFS+ (EPERM has also been reported on other
* filesystems/macOS versions for the same condition), unlike APFS
* which allows it. Linux allows it unconditionally, so recreate the
* same effect with a plain symlink to the same target: a new
* directory entry that resolves identically, even though it is a
* distinct inode rather than a second link to the original.
*/
if ((errno != EPERM && errno != ENOTSUP) ||
(flags & LINUX_AT_SYMLINK_FOLLOW)) {
host_fd_ref_close(&olddir_ref);
host_fd_ref_close(&newdir_ref);
return linux_errno();
}

struct stat old_st;
char target[LINUX_PATH_MAX];
ssize_t target_len;
if (fstatat(olddir_ref.fd, old_tx.host_path, &old_st,
AT_SYMLINK_NOFOLLOW) < 0 ||
!S_ISLNK(old_st.st_mode) ||
(target_len = readlinkat(olddir_ref.fd, old_tx.host_path, target,
sizeof(target) - 1)) < 0) {
host_fd_ref_close(&olddir_ref);
host_fd_ref_close(&newdir_ref);
return -LINUX_EPERM;
}
target[target_len] = '\0';

if (symlinkat(target, newdir_ref.fd, new_tx.host_path) < 0) {
host_fd_ref_close(&olddir_ref);
host_fd_ref_close(&newdir_ref);
return linux_errno();
}
}

host_fd_ref_close(&olddir_ref);
Expand Down
61 changes: 61 additions & 0 deletions src/syscall/path.c
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ int path_check_intercept_access(const struct stat *st, int mode, int flags)
return -1;
}

/* Forward-declared: defined below dirfd_guest_base_path(), which it needs. */
static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
const char *path,
unsigned int flags);

int path_translate_at(guest_fd_t dirfd,
const char *path,
unsigned int flags,
Expand Down Expand Up @@ -202,6 +207,27 @@ int path_translate_at(guest_fd_t dirfd,
sizeof(tx->host_buf));
}

/* The resolvers above key off guest_path[0] == '/': a relative path is
* handed back untouched because they have no dirfd context to rebuild a
* host location from, so it never gets the sysroot-prefix + realpath()
* containment check that absolute paths get. That leaves the actual
* openat(dirfd, name) to the host kernel's own resolution, which is not
* confined to dirfd's subtree -- a symlink reachable through dirfd (a
* relative target with enough ".." components, or an absolute target)
* walks straight out of the sysroot with no check at all. Reconstruct the
* equivalent absolute guest path from dirfd's guest base path and run it
* back through the same containment-checked resolver; its realpath() call
* collapses ".." and any symlink indirection, including an absolute
* target, before the prefix check runs.
*/
if (tx->host_path && tx->guest_path[0] != '/' && proc_get_sysroot() &&
path_check_relative_sysroot_containment(dirfd, tx->guest_path, flags) <
0) {
tx->host_path = NULL;
if (errno == 0)
errno = ELOOP;
}

/* Sidecar only runs after sysroot resolution succeeds. If the resolver
* rejected the path (e.g. nofollow containment violation), sidecar must not
* be allowed to walk an alternate index and resurrect the rejected target.
Expand Down Expand Up @@ -889,6 +915,41 @@ static int dirfd_guest_base_path(guest_fd_t dirfd, char *out, size_t outsz)
return 0;
}

static int path_check_relative_sysroot_containment(guest_fd_t dirfd,
const char *path,
unsigned int flags)
{
char base[LINUX_PATH_MAX];
if (dirfd_guest_base_path(dirfd, base, sizeof(base)) < 0)
return -1;

char abs_path[LINUX_PATH_MAX];
int n = snprintf(abs_path, sizeof(abs_path), "%s/%s", base, path);
if (n < 0 || (size_t) n >= sizeof(abs_path)) {
errno = ENAMETOOLONG;
return -1;
}

char host_buf[LINUX_PATH_MAX];
const char *checked;
if (flags & PATH_TR_NOFOLLOW) {
checked = path_resolve_sysroot_nofollow_path(abs_path, host_buf,
sizeof(host_buf));
} else if (flags & PATH_TR_CREATE) {
/* create_parents=false regardless of the caller's actual flags: this
* pass only checks whether the resolution is contained, and must not
* mkdir() anything on the reconstructed path as a side effect.
*/
checked = path_resolve_sysroot_create_path(abs_path, host_buf,
sizeof(host_buf), false);
} else {
checked =
path_resolve_sysroot_path(abs_path, host_buf, sizeof(host_buf));
}

return checked ? 0 : -1;
}

static bool normalized_proc_self_fd_anchor(const char *path)
{
if (!strncmp(path, "proc/self/fd/", 13))
Expand Down
68 changes: 68 additions & 0 deletions tests/test-linkat-symlink-fallback.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* linkat() hard-link-to-symlink fallback on non-APFS filesystems
*
* Copyright 2026 elfuse contributors
* SPDX-License-Identifier: Apache-2.0
*
* Per linkat(2) on Darwin: without AT_SYMLINK_FOLLOW, hard-linking a symlink
* itself (rather than its target) "may result in some file systems returning
* an error" -- reproduced on Case-sensitive HFS+ as ENOTSUP, where the same
* call against a regular file succeeds. Linux allows hard-linking a symlink
* unconditionally. sys_linkat() falls back to symlinkat() with the same
* target when the host linkat() fails with EPERM/ENOTSUP and the source is a
* symlink, so the guest sees the Linux-compatible outcome regardless of the
* host filesystem.
*
* The Makefile target only runs this under a --sysroot backed by a
* Case-sensitive HFS+ disk image (created via hdiutil); it skips with exit 77
* when hdiutil/HFS+ is unavailable in the current environment.
*/

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

#include "test-harness.h"

int passes = 0, fails = 0;

int main(void)
{
printf("test-linkat-symlink-fallback: linkat() on a symlink source\n");

TEST("linkat(flags=0) on a symlink source succeeds");
EXPECT_TRUE(linkat(AT_FDCWD, "/d1/sym", AT_FDCWD, "/d1/hardlink", 0) == 0,
"linkat failed");

TEST("result names a symlink (not the followed target)");
{
struct stat st;
EXPECT_TRUE(lstat("/d1/hardlink", &st) == 0 && S_ISLNK(st.st_mode),
"hardlink is not a symlink");
}

TEST("symlink target matches the original");
{
char buf[64] = {0};
ssize_t n = readlink("/d1/hardlink", buf, sizeof(buf) - 1);
EXPECT_TRUE(n > 0 && !strncmp(buf, "target.txt", (size_t) n),
"target mismatch");
}

TEST("reading through it reaches the same file content");
{
int fd = open("/d1/hardlink", O_RDONLY);
char buf[64] = {0};
ssize_t n = fd >= 0 ? read(fd, buf, sizeof(buf) - 1) : -1;
if (fd >= 0)
close(fd);
EXPECT_TRUE(n > 0 && !strncmp(buf, "hello\n", (size_t) n),
"content mismatch");
}

SUMMARY("test-linkat-symlink-fallback");
return fails > 0 ? 1 : 0;
}
101 changes: 101 additions & 0 deletions tests/test-sysroot-symlink-escape.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Sysroot containment regression: relative-path symlink escape
*
* Copyright 2026 elfuse contributors
* SPDX-License-Identifier: Apache-2.0
*
* proc_resolve_sysroot_path_flags() only runs its sysroot-prefix + realpath()
* containment check on absolute guest paths, since it has no dirfd context to
* rebuild a host location from a relative one. That left openat(dirfd, name)
* to the host kernel's own resolution, unconfined to dirfd's subtree: a
* symlink reachable through a sysroot-contained dirfd -- with a relative
* target holding enough ".." components, or with an absolute target -- could
* walk straight out of the sysroot with no check at all, even though the
* exact same escape through an absolute guest path was already rejected with
* ELOOP. path_translate_at() now reconstructs the absolute guest path from
* the dirfd's guest base path and re-validates it through the same
* containment-checked resolver the absolute-path surface uses.
*
* The Makefile target stages an absolute-target symlink and a relative-target
* (deep "..") symlink under $sysroot/d1, both pointing at a secret file
* outside the sysroot, plus a normal in-sysroot file. This guest program only
* exercises the relative-dirfd surface (openat(dirfd, name, ...)); the
* absolute-path surface is already covered by test-sysroot-nofollow.
*/

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

#include "test-harness.h"

int passes = 0, fails = 0;

int main(void)
{
printf("test-sysroot-symlink-escape: relative-dirfd symlink containment\n");

int dirfd = open("/d1", O_RDONLY | O_DIRECTORY);
if (dirfd < 0) {
FAIL("open /d1 failed");
SUMMARY("test-sysroot-symlink-escape");
return 1;
}

TEST("legitimate relative open still works");
{
int fd = openat(dirfd, "normal.txt", O_RDONLY);
if (fd >= 0) {
close(fd);
PASS();
} else {
FAIL("openat(dirfd, normal.txt) failed");
}
}

TEST("absolute-target symlink escape via dirfd is blocked");
{
int fd = openat(dirfd, "abs-link", O_RDONLY);
if (fd < 0 && errno == ELOOP)
PASS();
else {
if (fd >= 0)
close(fd);
FAIL("openat(dirfd, abs-link) did not fail with ELOOP");
}
}

TEST("relative \"..\" symlink escape via dirfd is blocked");
{
int fd = openat(dirfd, "rel-link", O_RDONLY);
if (fd < 0 && errno == ELOOP)
PASS();
else {
if (fd >= 0)
close(fd);
FAIL("openat(dirfd, rel-link) did not fail with ELOOP");
}
}

close(dirfd);

TEST("AT_FDCWD relative escape is blocked after chdir");
{
if (chdir("/d1") < 0) {
FAIL("chdir /d1 failed");
} else {
int fd = open("abs-link", O_RDONLY);
if (fd < 0 && errno == ELOOP)
PASS();
else {
if (fd >= 0)
close(fd);
FAIL("open(AT_FDCWD, abs-link) did not fail with ELOOP");
}
}
}

SUMMARY("test-sysroot-symlink-escape");
return fails > 0 ? 1 : 0;
}
Loading