From 65fb57485be7feb06dea2c6bbd69b2c79f52cad1 Mon Sep 17 00:00:00 2001 From: Max042004 Date: Thu, 9 Jul 2026 15:19:13 +0800 Subject: [PATCH] Close sysroot escape via relative symlinks A directory fd opened inside the sysroot let a relative openat(dirfd, name) escape it entirely. proc_resolve_sysroot_path_ flags() only runs its sysroot-prefix + realpath() containment check on absolute guest paths, since it has no dirfd context to reconstruct a host location from a relative one. A symlink reachable through dirfd -- a relative target with enough ".." components, or an absolute target -- therefore walked straight out of the sysroot with no check at all, even though the identical escape through an absolute guest path was already rejected with ELOOP. Verified by staging such a symlink and reading a host file outside the sysroot through the relative dirfd path. 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 already uses; realpath() inside that resolver collapses ".." and any symlink indirection, including an absolute target, before the prefix check runs. Separately, Darwin's linkat(2) can reject a hard link to a symlink itself (without AT_SYMLINK_FOLLOW) on non-APFS filesystems -- reproduced as ENOTSUP on a Case-sensitive HFS+ volume, where Linux allows it unconditionally. sys_linkat() now falls back to creating a plain symlink with the same target when linkat() fails with EPERM/ENOTSUP and the source is a symlink. Close #130 --- mk/tests.mk | 58 ++++++++++++++- src/syscall/fs.c | 39 ++++++++++- src/syscall/path.c | 61 ++++++++++++++++ tests/test-linkat-symlink-fallback.c | 68 ++++++++++++++++++ tests/test-sysroot-symlink-escape.c | 101 +++++++++++++++++++++++++++ 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 tests/test-linkat-symlink-fallback.c create mode 100644 tests/test-sysroot-symlink-escape.c 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; +}