diff --git a/mk/tests.mk b/mk/tests.mk index ba579e78..85c3e456 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -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) @@ -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" @@ -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; \ diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 30dbb0d1..3530aa99 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -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); diff --git a/src/syscall/path.c b/src/syscall/path.c index 1906fe75..b7d042f6 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -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, @@ -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. @@ -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)) diff --git a/tests/test-linkat-symlink-fallback.c b/tests/test-linkat-symlink-fallback.c new file mode 100644 index 00000000..dcd01a59 --- /dev/null +++ b/tests/test-linkat-symlink-fallback.c @@ -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 +#include +#include +#include +#include +#include + +#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; +} diff --git a/tests/test-sysroot-symlink-escape.c b/tests/test-sysroot-symlink-escape.c new file mode 100644 index 00000000..5b9db553 --- /dev/null +++ b/tests/test-sysroot-symlink-escape.c @@ -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 +#include +#include +#include + +#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; +}