From a89a59c1f714c824e7bafaa965f482248dcaff9f Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Sat, 18 Jul 2026 14:06:31 +0200 Subject: [PATCH] Resolve /dev/shm host paths in one place macOS has no /dev/shm, so elfuse backs POSIX shared memory with a per-UID host directory, /tmp/elfuse-shm-/, via the validated resolver proc_dev_shm_resolve. Only open, stat, and unlinkat did that redirect by hand; every other path syscall fell through path_translate_at, which prepends the sysroot, so one guest path resolved two ways: /dev/shm/foo open -> /tmp/elfuse-shm-1000/foo (backing dir, created) chmod -> /dev/shm/foo (absent -> ENOENT) LTP's setup_ipc (lib/tst_test.c) hits this exactly: open(O_CREAT|O_EXCL) then SAFE_CHMOD on the same path. The open succeeded in the backing dir, the chmod hit ENOENT and aborted common setup, failing all 24 conformance tests before any test body ran. Do the redirect once, in path_translate_at, behind an is_dev_shm flag, so chmod, chown, truncate, utimensat, rename, link, symlink, mknod, readlink, mkdir, statfs, and xattr all inherit the backing path from one choke point. The backing path is absolute, so path_translation_dirfd() selects AT_FDCWD and the hand-rolled unlinkat rewrite folds away. --- src/core/bootstrap.c | 10 + src/runtime/procemu.c | 45 +- src/syscall/exec.c | 43 +- src/syscall/fs-stat.c | 32 +- src/syscall/fs-xattr.c | 8 +- src/syscall/fs.c | 142 +++++-- src/syscall/path.c | 22 + src/syscall/path.h | 23 ++ tests/manifest.txt | 1 + tests/test-dev-shm-paths.c | 813 +++++++++++++++++++++++++++++++++++++ 10 files changed, 1072 insertions(+), 67 deletions(-) create mode 100644 tests/test-dev-shm-paths.c diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index 762611f1..828cdb15 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -227,6 +227,16 @@ static bool load_interpreter(guest_t *g, return false; } interp_host_temp = true; + } else if (tx.is_dev_shm) { + /* A loader in /dev/shm is never legitimate, and elf_load() below + * opens it by path with no nofollow. Reject outright rather than + * risk following a symlink leaf onto the host. (The guest is not + * yet running, so no link can have been planted; a plain reject is + * race-free, unlike the runtime execve paths.) + */ + log_error("refusing /dev/shm interpreter: %s", + boot->elf_info.interp_path); + return false; } else { str_copy_trunc(boot->interp_resolved, tx.host_path, sizeof(boot->interp_resolved)); diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index 77a4a5e5..a8fbe273 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -683,11 +683,35 @@ static int proc_parse_fd_index(const char *path, return (int) n; } -/* Resolve a /dev/shm/ guest path to a host path inside the per-UID shm - * dir. Rejects empty, traversing, or compound suffixes with EACCES; reports - * ENAMETOOLONG when the host path overflows. The same validation runs in - * proc_intercept_open and proc_intercept_stat, so the helper is one source of - * truth for the security gate. +/* Map a guest /dev/shm/ path to its host backing path, and gate the name. + * + * macOS has no /dev/shm, so elfuse backs POSIX shared memory with a per-UID + * host directory (/tmp/elfuse-shm-/). This is the single source of truth + * for that mapping. Callers proc_intercept_open, proc_intercept_stat, and + * path_translate_at (which records the hit in path_translation_t.is_dev_shm) + * all resolve through here, so one guest shm path never resolves two ways + * (e.g. open landing in the backing dir while chmod falls through to the + * sysroot). + * + * A POSIX shm name is always a single flat component: glibc's __shm_get_name + * (posix/shm-directory.c) strips the leading slash and rejects an empty name or + * any embedded '/' with EINVAL. This enforces the same shape, also rejects the + * ".." component (whole-component compare, so a flat name like "a..b" is fine), + * and returns EACCES (ENAMETOOLONG on overflow). + * + * The never-follow invariant lives here. On Linux /dev/shm is an in-namespace + * tmpfs, so a symlink planted at a shm leaf resolves inside that namespace. + * elfuse's backing store is a plain host directory, so the same symlink would + * resolve onto the host filesystem, escaping the sandbox. A symlink leaf is + * never legitimate anyway: glibc's shm_open (sysdeps/posix/shm_open.c) opens + * objects with O_NOFOLLOW. So every shm op must act on the leaf without + * following it. Because this resolver hands back an absolute host path that + * bypasses the sysroot, that duty is spread across syscall families and + * funneled through: path_translation_at_flags() (AT_SYMLINK_NOFOLLOW on the *at + * metadata calls), shm_open_leaf() (O_NOFOLLOW fd for truncate/chdir), proc + * open (O_NOFOLLOW), xattr (XATTR_NOFOLLOW), stat (lstat), linkat (clears + * AT_SYMLINK_FOLLOW), and statfs (answered synthetically, never touching the + * leaf). */ static int dev_shm_resolve_path(const char *guest_suffix, char *host_path, @@ -696,8 +720,8 @@ static int dev_shm_resolve_path(const char *guest_suffix, const char *shm = shm_dir_path(); if (!shm) return -1; - if (strstr(guest_suffix, "..") || strchr(guest_suffix, '/') || - guest_suffix[0] == '\0') { + if (guest_suffix[0] == '\0' || strchr(guest_suffix, '/') || + !strcmp(guest_suffix, "..")) { errno = EACCES; return -1; } @@ -3497,12 +3521,15 @@ int proc_intercept_stat(const char *path, struct stat *st) path); /* sticky bit, like real /dev/shm */ return 0; } - /* /dev/shm/ files: check the host temp dir */ + /* /dev/shm/ files: check the host backing dir, and lstat rather than + * stat so a planted symlink leaf is never followed (see + * dev_shm_resolve_path). + */ if (!strncmp(path, "/dev/shm/", 9)) { char host_path[512]; if (dev_shm_resolve_path(path + 9, host_path, sizeof(host_path)) < 0) return -1; - return stat(host_path, st); + return lstat(host_path, st); } /* /dev/pts directory and /dev/pts/N slave entries. glibc ptsname(3) stats diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 84c6f1e1..27b71dbb 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -160,18 +160,33 @@ static void exec_cleanup_inputs(char **argv, free(envp_buf); } +/* Open an execve image (binary or interpreter). For a shm redirect, force + * O_NOFOLLOW so a symlink leaf cannot point the exec at a host file; a real + * binary in /dev/shm still opens. See dev_shm_resolve_path(). + */ +static int exec_open_image(const char *host_path, bool shm_nofollow) +{ + int oflags = O_RDONLY | O_CLOEXEC; + if (shm_nofollow) + oflags |= O_NOFOLLOW; + return open(host_path, oflags); +} + static int exec_resolve_guest_host_path(const char *guest_path, char *host_path, size_t host_path_sz, - bool *host_path_temp) + bool *host_path_temp, + bool *shm_nofollow) { path_translation_t tx; - if (!guest_path || !host_path || host_path_sz == 0 || !host_path_temp) { + if (!guest_path || !host_path || host_path_sz == 0 || !host_path_temp || + !shm_nofollow) { errno = EINVAL; return -1; } *host_path_temp = false; + *shm_nofollow = false; if (path_translate_at(LINUX_AT_FDCWD, guest_path, PATH_TR_NONE, &tx) < 0) return -1; if (tx.fuse_path) { @@ -190,6 +205,7 @@ static int exec_resolve_guest_host_path(const char *guest_path, errno = ENAMETOOLONG; return -1; } + *shm_nofollow = tx.is_dev_shm; return 0; } @@ -197,11 +213,13 @@ static int exec_resolve_interp_host_path(const char *sysroot, const char *interp_guest_path, char *interp_host_path, size_t interp_host_path_sz, - bool *interp_host_temp) + bool *interp_host_temp, + bool *shm_nofollow) { char interp_candidate[LINUX_PATH_MAX]; elf_resolve_interp(sysroot, interp_guest_path, interp_candidate, sizeof(interp_candidate)); + *shm_nofollow = false; if (strcmp(interp_candidate, interp_guest_path) != 0) { size_t len = str_copy_trunc(interp_host_path, interp_candidate, interp_host_path_sz); @@ -214,7 +232,8 @@ static int exec_resolve_interp_host_path(const char *sysroot, } return exec_resolve_guest_host_path(interp_guest_path, interp_host_path, - interp_host_path_sz, interp_host_temp); + interp_host_path_sz, interp_host_temp, + shm_nofollow); } /* Read a NULL-terminated pointer array from guest memory. Each pointer in the @@ -387,6 +406,10 @@ int64_t sys_execve(hv_vcpu_t vcpu, char path_host_buf[LINUX_PATH_MAX]; const char *path_host = path; bool path_host_temp = false; + /* Whether path_host is a shm redirect leaf (drives O_NOFOLLOW on the exec + * open). Re-evaluated whenever path_host is repointed to an interpreter. + */ + bool path_host_shm = false; char interp_host_buf[LINUX_PATH_MAX]; bool interp_host_temp = false; @@ -455,6 +478,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, path_host_temp = true; } else { str_copy_trunc(path_host_buf, tx.host_path, sizeof(path_host_buf)); + path_host_shm = tx.is_dev_shm; } path_host = path_host_buf; } @@ -471,7 +495,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, /* Open the directly-executed file first and bind it to an fd to avoid * TOCTOU. */ - exec_fd = open(path_host, O_RDONLY | O_CLOEXEC); + exec_fd = exec_open_image(path_host, path_host_shm); if (exec_fd < 0) { err = linux_errno(); goto fail; @@ -616,15 +640,17 @@ int64_t sys_execve(hv_vcpu_t vcpu, goto fail; interp_host_temp = true; path_host = interp_host_buf; + path_host_shm = false; } else { str_copy_trunc(path_host_buf, interp_tx.host_path, sizeof(path_host_buf)); path_host = path_host_buf; + path_host_shm = interp_tx.is_dev_shm; } /* Close old fd and open the new interpreter */ close(exec_fd); - exec_fd = open(path_host, O_RDONLY | O_CLOEXEC); + exec_fd = exec_open_image(path_host, path_host_shm); if (exec_fd < 0) { err = linux_errno(); goto fail; @@ -734,6 +760,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, * itself via fd 3, so the aarch64-only interpreter pre-load below is * skipped for rosetta exec. */ + bool interp_shm = false; if (!target_is_rosetta && elf_info.interp_path[0] != '\0') { char sysroot_snap[LINUX_PATH_MAX]; bool have_sr = @@ -741,7 +768,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, if (exec_resolve_interp_host_path(have_sr ? sysroot_snap : NULL, elf_info.interp_path, interp_resolved, sizeof(interp_resolved), - &interp_host_temp) < 0) { + &interp_host_temp, &interp_shm) < 0) { log_error("execve: failed to resolve interpreter: %s", elf_info.interp_path); err = -LINUX_ENOEXEC; @@ -754,7 +781,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, log_debug("execve: pre-validating interpreter: %s", interp_resolved); - interp_fd = open(interp_resolved, O_RDONLY | O_CLOEXEC); + interp_fd = exec_open_image(interp_resolved, interp_shm); if (interp_fd < 0) { log_error("execve: failed to open interpreter: %s", interp_resolved); diff --git a/src/syscall/fs-stat.c b/src/syscall/fs-stat.c index 9e10c477..7b14d174 100644 --- a/src/syscall/fs-stat.c +++ b/src/syscall/fs-stat.c @@ -269,8 +269,10 @@ static int64_t stat_at_path(guest_t *g, } } if (intercepted == PROC_NOT_INTERCEPTED) { - int mac_flags = translate_at_flags(flags); - if (fstatat(dir_ref.fd, tx.host_path, mac_st, mac_flags) < 0) { + int mac_flags = + path_translation_at_flags(&tx, translate_at_flags(flags)); + if (fstatat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, + mac_st, mac_flags) < 0) { rc = linux_errno(); goto done; } @@ -429,6 +431,32 @@ static int64_t sys_statfs_impl(guest_t *g, } } + /* Report /dev/shm and its leaves as tmpfs, from the backing dir. statfs() + * on the leaf would follow a symlink onto the host and leak the host fs + * identity, so answer synthetically; lstat is the nofollow existence probe. + */ + bool shm_root = !strcmp(tx.intercept_path, "/dev/shm") || + !strcmp(tx.intercept_path, "/dev/shm/"); + if (tx.is_dev_shm || shm_root) { + const char *shm_dir = proc_get_shm_dir(); + if (!shm_dir) + return linux_errno(); + if (tx.is_dev_shm) { + struct stat leaf_st; + if (lstat(tx.host_path, &leaf_st) < 0) + return linux_errno(); + } + struct statfs shm_fs; + if (statfs(shm_dir, &shm_fs) < 0) + return linux_errno(); + linux_statfs_t lin_st; + translate_statfs(&shm_fs, &lin_st); /* sets f_namelen = 255 */ + lin_st.f_type = 0x01021994; /* TMPFS_MAGIC */ + if (guest_write_small(g, buf_gva, &lin_st, sizeof(lin_st)) < 0) + return -LINUX_EFAULT; + return 0; + } + struct statfs mac_st; if (statfs(tx.host_path, &mac_st) < 0) return linux_errno(); diff --git a/src/syscall/fs-xattr.c b/src/syscall/fs-xattr.c index fe70fecd..cb4cb154 100644 --- a/src/syscall/fs-xattr.c +++ b/src/syscall/fs-xattr.c @@ -86,7 +86,7 @@ int64_t sys_getxattr(guest_t *g, if (tx.fuse_path) return -LINUX_ENOSYS; - int opts = nofollow ? XATTR_NOFOLLOW : 0; + int opts = (nofollow || tx.is_dev_shm) ? XATTR_NOFOLLOW : 0; if (size == 0) { ssize_t ret = getxattr(tx.host_path, name, NULL, 0, 0, opts); @@ -134,7 +134,7 @@ int64_t sys_setxattr(guest_t *g, return -LINUX_EFAULT; } - int opts = nofollow ? XATTR_NOFOLLOW : 0; + int opts = (nofollow || tx.is_dev_shm) ? XATTR_NOFOLLOW : 0; err = xattr_translate_flags(flags, &opts); if (err < 0) { free(buf); @@ -163,7 +163,7 @@ int64_t sys_listxattr(guest_t *g, if (tx.fuse_path) return -LINUX_ENOSYS; - int opts = nofollow ? XATTR_NOFOLLOW : 0; + int opts = (nofollow || tx.is_dev_shm) ? XATTR_NOFOLLOW : 0; if (size == 0) { ssize_t ret = listxattr(tx.host_path, NULL, 0, opts); @@ -199,7 +199,7 @@ int64_t sys_removexattr(guest_t *g, if (tx.fuse_path) return -LINUX_ENOSYS; - int opts = nofollow ? XATTR_NOFOLLOW : 0; + int opts = (nofollow || tx.is_dev_shm) ? XATTR_NOFOLLOW : 0; int ret = removexattr(tx.host_path, name, opts); return ret < 0 ? linux_errno() : 0; } diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 0a512c92..64228484 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -1706,6 +1706,16 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) return ret; } +/* Reach a shm leaf via an fd for truncate/chdir, which have no nofollow path + * variant. O_NOFOLLOW keeps a symlink leaf contained, O_NONBLOCK stops a FIFO + * leaf from blocking the vCPU thread, O_CLOEXEC covers the short-lived fd. See + * dev_shm_resolve_path(). + */ +static int shm_open_leaf(const path_translation_t *tx, int oflags) +{ + return open(tx->host_path, oflags | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); +} + int64_t sys_chdir(guest_t *g, uint64_t path_gva) { char path[LINUX_PATH_MAX]; @@ -1749,6 +1759,22 @@ int64_t sys_chdir(guest_t *g, uint64_t path_gva) return 0; } + /* fchdir on a nofollow fd instead of chdir(), which would follow a symlink + * leaf. Set the virtual cwd to the guest path so getcwd never leaks the + * backing location, like the proc and fuse branches above. + */ + if (tx.is_dev_shm) { + int fd = shm_open_leaf(&tx, O_RDONLY | O_DIRECTORY); + if (fd < 0) + return linux_errno(); + int chdir_rc = fchdir(fd); + close_keep_errno(fd); + if (chdir_rc < 0) + return linux_errno(); + proc_cwd_set_virtual(tx.guest_path); + return 0; + } + if (chdir(tx.host_path) < 0) return linux_errno(); @@ -1909,7 +1935,8 @@ int64_t sys_readlinkat(guest_t *g, return -LINUX_EBADF; /* Apply sysroot redirect for absolute paths */ - ssize_t len = readlinkat(dir_ref.fd, tx.host_path, link, sizeof(link) - 1); + ssize_t len = readlinkat(path_translation_dirfd(&tx, &dir_ref), + tx.host_path, link, sizeof(link) - 1); host_fd_ref_close(&dir_ref); if (len < 0) return linux_errno(); @@ -1947,31 +1974,20 @@ int64_t sys_unlinkat(guest_t *g, int dirfd, uint64_t path_gva, int flags) if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - /* Rewrite /dev/shm/ to the host temp directory so shm_unlink works */ - const char *unlink_path; - char shm_host[LINUX_PATH_MAX]; - if (!strncmp(tx.guest_path, "/dev/shm/", 9)) { - if (proc_dev_shm_resolve(tx.guest_path + 9, shm_host, - sizeof(shm_host)) < 0) { - host_fd_ref_close(&dir_ref); - return linux_errno(); - } - unlink_path = shm_host; - host_fd_ref_close(&dir_ref); - dir_ref.fd = AT_FDCWD; - dir_ref.owned = 0; - } else { - unlink_path = tx.host_path; - } + /* path_translate_at rewrites /dev/shm/ to the absolute backing + * path, so shm_unlink works; path_translation_dirfd drops the guest dirfd + * there. + */ + host_fd_t unlink_dirfd = path_translation_dirfd(&tx, &dir_ref); struct stat removed_st; bool clear_removed_overlay = - fstatat(dir_ref.fd, unlink_path, &removed_st, AT_SYMLINK_NOFOLLOW) == + fstatat(unlink_dirfd, tx.host_path, &removed_st, AT_SYMLINK_NOFOLLOW) == 0 && (removed_st.st_nlink <= 1 || (flags & LINUX_AT_REMOVEDIR)); int host_flags = translate_at_flags(flags); - if (unlinkat(dir_ref.fd, unlink_path, host_flags) < 0) { + if (unlinkat(unlink_dirfd, tx.host_path, host_flags) < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } @@ -2006,7 +2022,8 @@ int64_t sys_mkdirat(guest_t *g, int dirfd, uint64_t path_gva, int mode) if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - if (mkdirat(dir_ref.fd, tx.host_path, (mode_t) mode) < 0) { + if (mkdirat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, + (mode_t) mode) < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } @@ -2065,6 +2082,8 @@ int64_t sys_renameat2(guest_t *g, host_fd_ref_close(&olddir_ref); return -LINUX_EBADF; } + host_fd_t old_host_dirfd = path_translation_dirfd(&old_tx, &olddir_ref); + host_fd_t new_host_dirfd = path_translation_dirfd(&new_tx, &newdir_ref); /* Apply sysroot resolution for absolute paths RENAME_NOREPLACE: fail if * destination exists. macOS renamex_np supports RENAME_EXCL for the same @@ -2085,14 +2104,14 @@ int64_t sys_renameat2(guest_t *g, * requirement. This path still cannot handle directories because * hardlinking directories is not allowed. */ - if (linkat(olddir_ref.fd, old_tx.host_path, newdir_ref.fd, + if (linkat(old_host_dirfd, old_tx.host_path, new_host_dirfd, new_tx.host_path, 0) < 0) { return close_dir_refs_result(&olddir_ref, &newdir_ref, linux_errno()); } - if (unlinkat(olddir_ref.fd, old_tx.host_path, 0) < 0) { + if (unlinkat(old_host_dirfd, old_tx.host_path, 0) < 0) { int err = errno; - (void) unlinkat(newdir_ref.fd, new_tx.host_path, 0); + (void) unlinkat(new_host_dirfd, new_tx.host_path, 0); errno = err; return close_dir_refs_result(&olddir_ref, &newdir_ref, linux_errno()); @@ -2117,11 +2136,11 @@ int64_t sys_renameat2(guest_t *g, } struct stat old_st; - bool have_old_st = fstatat(olddir_ref.fd, old_tx.host_path, &old_st, + bool have_old_st = fstatat(old_host_dirfd, old_tx.host_path, &old_st, AT_SYMLINK_NOFOLLOW) == 0; struct stat overwritten_st; bool clear_overwritten_overlay = - fstatat(newdir_ref.fd, new_tx.host_path, &overwritten_st, + fstatat(new_host_dirfd, new_tx.host_path, &overwritten_st, AT_SYMLINK_NOFOLLOW) == 0 && stat_identity_will_disappear(&overwritten_st) && (!have_old_st || !same_stat_identity(&old_st, &overwritten_st)); @@ -2136,7 +2155,7 @@ int64_t sys_renameat2(guest_t *g, return close_dir_refs_result(&olddir_ref, &newdir_ref, 0); } - if (renameat(olddir_ref.fd, old_tx.host_path, newdir_ref.fd, + if (renameat(old_host_dirfd, old_tx.host_path, new_host_dirfd, new_tx.host_path) < 0) { return close_dir_refs_result(&olddir_ref, &newdir_ref, linux_errno()); } @@ -2166,7 +2185,8 @@ int64_t sys_mknodat(guest_t *g, int dirfd, uint64_t path_gva, int mode, int dev) * nodes need root */ if (S_ISFIFO(mode)) { - if (mkfifoat(dir_ref.fd, tx.host_path, mode & 0777) < 0) { + if (mkfifoat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, + mode & 0777) < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } @@ -2176,8 +2196,8 @@ int64_t sys_mknodat(guest_t *g, int dirfd, uint64_t path_gva, int mode, int dev) /* Regular files: create an empty file */ if (S_ISREG(mode) || (mode & S_IFMT) == 0) { - int fd = openat(dir_ref.fd, tx.host_path, O_CREAT | O_WRONLY | O_EXCL, - mode & 0777); + int fd = openat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, + O_CREAT | O_WRONLY | O_EXCL, mode & 0777); host_fd_ref_close(&dir_ref); if (fd < 0) return linux_errno(); @@ -2212,7 +2232,8 @@ int64_t sys_symlinkat(guest_t *g, return -LINUX_EBADF; /* Resolve linkpath (the new symlink location) through sysroot */ - if (symlinkat(target, dir_ref.fd, tx.host_path) < 0) { + if (symlinkat(target, path_translation_dirfd(&tx, &dir_ref), tx.host_path) < + 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } @@ -2258,11 +2279,18 @@ int64_t sys_linkat(guest_t *g, host_fd_ref_close(&olddir_ref); return -LINUX_EBADF; } + host_fd_t old_host_dirfd = path_translation_dirfd(&old_tx, &olddir_ref); + host_fd_t new_host_dirfd = path_translation_dirfd(&new_tx, &newdir_ref); /* Resolve both paths through sysroot */ 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) { + /* Clear AT_SYMLINK_FOLLOW so a shm symlink is hard-linked as the leaf + * itself, never dereferenced to its host target (see dev_shm_resolve_path). + */ + if (old_tx.is_dev_shm) + mac_flags &= ~AT_SYMLINK_FOLLOW; + if (linkat(old_host_dirfd, old_tx.host_path, new_host_dirfd, + new_tx.host_path, mac_flags) < 0) { /* 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 @@ -2283,10 +2311,10 @@ int64_t sys_linkat(guest_t *g, struct stat old_st; char target[LINUX_PATH_MAX]; ssize_t target_len; - if (fstatat(olddir_ref.fd, old_tx.host_path, &old_st, + if (fstatat(old_host_dirfd, 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, + (target_len = readlinkat(old_host_dirfd, old_tx.host_path, target, sizeof(target) - 1)) < 0) { host_fd_ref_close(&olddir_ref); host_fd_ref_close(&newdir_ref); @@ -2294,7 +2322,7 @@ int64_t sys_linkat(guest_t *g, } target[target_len] = '\0'; - if (symlinkat(target, newdir_ref.fd, new_tx.host_path) < 0) { + if (symlinkat(target, new_host_dirfd, new_tx.host_path) < 0) { host_fd_ref_close(&olddir_ref); host_fd_ref_close(&newdir_ref); return linux_errno(); @@ -2373,8 +2401,10 @@ int64_t sys_faccessat(guest_t *g, return 0; } - int mac_flags = translate_faccessat_flags(flags); - if (faccessat(dir_ref.fd, tx.host_path, mode, mac_flags) < 0) { + int mac_flags = + path_translation_at_flags(&tx, translate_faccessat_flags(flags)); + if (faccessat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, mode, + mac_flags) < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } @@ -2437,6 +2467,27 @@ int64_t sys_truncate(guest_t *g, uint64_t path_gva, int64_t length) if (rc != INT64_MIN) return rc; + /* truncate(2) has no nofollow variant; reach the leaf via shm_open_leaf + + * ftruncate. + */ + if (tx.is_dev_shm) { + int fd = shm_open_leaf(&tx, O_WRONLY); + if (fd < 0) { + /* A FIFO leaf yields ENXIO (O_NONBLOCK write-open, no reader); + * Linux truncate(2) on a FIFO returns EINVAL, so match it. + */ + if (errno == ENXIO) + return -LINUX_EINVAL; + return linux_errno(); + } + if (ftruncate(fd, length) < 0) { + close_keep_errno(fd); + return linux_errno(); + } + close(fd); + return 0; + } + if (truncate(tx.host_path, length) < 0) return linux_errno(); return 0; @@ -2528,8 +2579,9 @@ int64_t sys_fchmodat(guest_t *g, if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - int mac_flags = translate_at_flags(flags); - if (fchmodat(dir_ref.fd, tx.host_path, mode, mac_flags) < 0) { + int mac_flags = path_translation_at_flags(&tx, translate_at_flags(flags)); + if (fchmodat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, mode, + mac_flags) < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } @@ -2678,12 +2730,13 @@ int64_t sys_fchownat(guest_t *g, if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - int mac_flags = translate_at_flags(flags); + int mac_flags = path_translation_at_flags(&tx, translate_at_flags(flags)); + host_fd_t host_dirfd = path_translation_dirfd(&tx, &dir_ref); struct stat before_st; bool before_ok = - fstatat(dir_ref.fd, tx.host_path, &before_st, mac_flags) == 0; + fstatat(host_dirfd, tx.host_path, &before_st, mac_flags) == 0; - int host_rc = fchownat(dir_ref.fd, tx.host_path, owner, group, mac_flags); + int host_rc = fchownat(host_dirfd, tx.host_path, owner, group, mac_flags); int saved_errno = errno; struct stat after_st; @@ -2818,8 +2871,9 @@ int64_t sys_utimensat(guest_t *g, return linux_errno(); } } else { - if (utimensat(dir_ref.fd, path_arg, times_gva ? ts : NULL, mac_flags) < - 0) { + mac_flags = path_translation_at_flags(&tx, mac_flags); + if (utimensat(path_translation_dirfd(&tx, &dir_ref), path_arg, + times_gva ? ts : NULL, mac_flags) < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); } diff --git a/src/syscall/path.c b/src/syscall/path.c index 46a7053a..76541b8d 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -17,6 +17,7 @@ #include "utils.h" +#include "runtime/procemu.h" #include "syscall/abi.h" #include "syscall/fuse.h" #include "syscall/path.h" @@ -177,6 +178,7 @@ int path_translate_at(guest_fd_t dirfd, tx->host_path = path; tx->proc_resolved = 0; tx->fuse_path = false; + tx->is_dev_shm = false; if (!path) return 0; @@ -200,6 +202,26 @@ int path_translate_at(guest_fd_t dirfd, } } + /* /dev/shm/ maps into the per-UID host backing dir, through the + * same validated resolver as the open and stat intercepts. Only a + * non-empty flat leaf is redirected; bare "/dev/shm" and "/dev/shm/" + * stay on the sysroot path so the synthetic-directory intercepts keep + * answering for them. The resolver rejects "..", embedded '/', and + * empty names with EACCES. The early return skips sysroot resolution, + * the relative-containment recheck, and the sidecar lookup: the backing + * path is absolute, self-contained, and must never be sidecar-mapped. + * is_dev_shm signals the redirect to callers, which must force nofollow + * on the host call; see dev_shm_resolve_path() for that invariant. + */ + if (!strncmp(tx->guest_path, "/dev/shm/", 9) && tx->guest_path[9] != '\0') { + if (proc_dev_shm_resolve(tx->guest_path + 9, tx->host_buf, + sizeof(tx->host_buf)) < 0) + return -1; + tx->host_path = tx->host_buf; + tx->is_dev_shm = true; + return 0; + } + errno = 0; if ((flags & PATH_TR_CREATE) && sidecar_active() && sidecar_path_targets_reserved_name(tx->guest_path)) { diff --git a/src/syscall/path.h b/src/syscall/path.h index 6508b127..61d3b8cc 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -7,6 +7,7 @@ #pragma once +#include #include #include #include @@ -27,11 +28,33 @@ typedef struct { const char *host_path; int proc_resolved; bool fuse_path; + /* Path was rewritten into the /dev/shm host backing dir. Follow-capable + * callers must force nofollow; see dev_shm_resolve_path() in procemu.c. + */ + bool is_dev_shm; char proc_path[LINUX_PATH_MAX]; char guest_buf[LINUX_PATH_MAX]; char host_buf[LINUX_PATH_MAX]; } path_translation_t; +/* Host dirfd for a *at() call on a translated path. A shm redirect gives an + * absolute host path, so use AT_FDCWD (POSIX ignores dirfd for absolute paths). + */ +static inline host_fd_t path_translation_dirfd(const path_translation_t *tx, + const host_fd_ref_t *ref) +{ + return tx->is_dev_shm ? AT_FDCWD : ref->fd; +} + +/* Force AT_SYMLINK_NOFOLLOW on the *at metadata calls for a shm redirect. One + * choke point for the never-follow invariant; see dev_shm_resolve_path(). + */ +static inline int path_translation_at_flags(const path_translation_t *tx, + int at_flags) +{ + return tx->is_dev_shm ? (at_flags | AT_SYMLINK_NOFOLLOW) : at_flags; +} + /* Advance *pathp to the next '/'-separated component, skipping empty segments * from repeated slashes. Returns true with the component (not NUL-terminated) * reported through comp and len, leaving *pathp at its end; returns false once diff --git a/tests/manifest.txt b/tests/manifest.txt index 871557d3..d33d9527 100644 --- a/tests/manifest.txt +++ b/tests/manifest.txt @@ -75,6 +75,7 @@ test-ioctl-cloexec test-pty test-ioctl-fioasync test-getdents-refcount +test-dev-shm-paths [section] Threading tests test-thread # diff=skip diff --git a/tests/test-dev-shm-paths.c b/tests/test-dev-shm-paths.c new file mode 100644 index 00000000..728aae9d --- /dev/null +++ b/tests/test-dev-shm-paths.c @@ -0,0 +1,813 @@ +/* + * /dev/shm path-syscall consistency tests + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Every path syscall must resolve /dev/shm/ to the same per-UID host + * backing object that open() creates. The regression: LTP's setup_ipc + * (lib/tst_test.c) does open(O_CREAT|O_EXCL) then chmod() on a /dev/shm path; + * a split resolution turns the chmod into ENOENT and aborts every test. + * + * The containment half checks that no shm op follows a symlink planted at a + * leaf: the backing store is a host directory, so a followed link escapes onto + * the host. Each op class is driven against a symlink to a host victim, which + * must come out untouched. stat reports the link itself (lstat), as escaping is + * the alternative here. + * + * An alarm bounds the run so a regression that reintroduces a blocking open on + * a FIFO leaf (see test_fifo_truncate_fast) fails instead of hanging CI. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +/* Linux tmpfs superblock magic, reported for /dev/shm and its leaves. */ +#define TMPFS_MAGIC 0x01021994 + +/* Shm mount and leaf prefix; a leaf is SHM_DIR "name" via concatenation. */ +#define SHM_ROOT "/dev/shm" +#define SHM_DIR SHM_ROOT "/" + +static char shm_path[128]; +static char shm_path2[128]; +static char shm_link[128]; +static char shm_evil[128]; +static char shm_dir[128]; +static char shm_fifo[128]; +static char shm_exec[128]; +static char victim_path[128]; + +static void name_fixtures(void) +{ + int pid = (int) getpid(); + snprintf(shm_path, sizeof(shm_path), SHM_DIR "elfuse_paths_%d", pid); + snprintf(shm_path2, sizeof(shm_path2), SHM_DIR "elfuse_paths2_%d", pid); + snprintf(shm_link, sizeof(shm_link), SHM_DIR "elfuse_link_%d", pid); + snprintf(shm_evil, sizeof(shm_evil), SHM_DIR "elfuse_evil_%d", pid); + snprintf(shm_dir, sizeof(shm_dir), SHM_DIR "elfuse_dir_%d", pid); + snprintf(shm_fifo, sizeof(shm_fifo), SHM_DIR "elfuse_fifo_%d", pid); + snprintf(shm_exec, sizeof(shm_exec), SHM_DIR "elfuse_exec_%d", pid); + snprintf(victim_path, sizeof(victim_path), "/tmp/elfuse-shm-victim-%d", + pid); +} + +static void cleanup_fixtures(void) +{ + unlink(shm_path); + unlink(shm_path2); + unlink(shm_link); + unlink(shm_evil); + unlink(shm_fifo); + unlink(shm_exec); + rmdir(shm_dir); + unlink(victim_path); +} + +/* The exact LTP setup_ipc() sequence: create via open, adjust via chmod. */ +static int test_open_then_chmod(void) +{ + TEST("open O_CREAT then chmod same path"); + int fd = open(shm_path, O_CREAT | O_EXCL | O_RDWR, 0600); + if (fd < 0) { + FAIL("open O_CREAT"); + return -1; + } + if (chmod(shm_path, 0666) != 0) { + FAIL("chmod after open"); + close(fd); + return -1; + } + struct stat fd_st, path_st; + int ok = fstat(fd, &fd_st) == 0 && stat(shm_path, &path_st) == 0 && + fd_st.st_ino == path_st.st_ino && fd_st.st_dev == path_st.st_dev && + (path_st.st_mode & 0777) == 0666; + close(fd); + if (!ok) { + FAIL("path stat disagrees with the fd the open created"); + return -1; + } + PASS(); + return 0; +} + +static void test_metadata_ops_hit_same_object(void) +{ + TEST("chown/truncate/utimensat/access"); + if (chown(shm_path, (uid_t) -1, (gid_t) -1) != 0) { + FAIL("chown"); + return; + } + if (truncate(shm_path, 4096) != 0) { + FAIL("truncate"); + return; + } + struct stat st; + if (stat(shm_path, &st) != 0 || st.st_size != 4096) { + FAIL("truncate size not visible through path stat"); + return; + } + if (utimensat(AT_FDCWD, shm_path, NULL, 0) != 0) { + FAIL("utimensat"); + return; + } + if (access(shm_path, R_OK | W_OK) != 0) { + FAIL("access"); + return; + } + PASS(); +} + +/* xattr round-trips through the same backing object. A host fs that refuses + * user xattrs (ENOTSUP) is a skip, not a failure. + */ +static void test_xattr_round_trip(void) +{ + TEST("xattr set/get on a shm path"); + static const char name[] = "user.elfuse_test"; + static const char val[] = "shmval"; + if (setxattr(shm_path, name, val, sizeof(val), 0) != 0) { + if (errno == ENOTSUP || errno == EOPNOTSUPP) { + printf("SKIP (host xattr unsupported)\n"); + return; + } + FAIL("setxattr"); + return; + } + char buf[32]; + ssize_t n = getxattr(shm_path, name, buf, sizeof(buf)); + if (n != (ssize_t) sizeof(val) || memcmp(buf, val, sizeof(val)) != 0) { + FAIL("getxattr mismatch"); + return; + } + (void) removexattr(shm_path, name); + PASS(); +} + +static void test_statfs_reports_tmpfs(void) +{ + TEST("statfs on a shm leaf reports tmpfs"); + struct statfs sfs; + if (statfs(shm_path, &sfs) != 0) { + FAIL("statfs leaf"); + return; + } + if (sfs.f_type != TMPFS_MAGIC) { + FAIL("shm leaf statfs f_type is not TMPFS_MAGIC"); + return; + } + if (sfs.f_namelen != 255) { + FAIL("shm leaf statfs f_namelen is not 255"); + return; + } + PASS(); +} + +static void test_statfs_root_reports_tmpfs(void) +{ + TEST("statfs on /dev/shm reports tmpfs"); + struct statfs sfs; + if (statfs(SHM_ROOT, &sfs) != 0) { + FAIL("statfs /dev/shm"); + return; + } + if (sfs.f_type != TMPFS_MAGIC) { + FAIL("/dev/shm statfs is not TMPFS_MAGIC"); + return; + } + /* A trailing slash names the same mount root (stat accepts it), so statfs + * must answer identically rather than falling through to the host. + */ + if (statfs(SHM_DIR, &sfs) != 0 || sfs.f_type != TMPFS_MAGIC) { + FAIL("/dev/shm/ (trailing slash) statfs is not TMPFS_MAGIC"); + return; + } + PASS(); +} + +static void test_statfs_missing_leaf(void) +{ + TEST("statfs on an absent shm leaf is ENOENT"); + struct statfs sfs; + errno = 0; + EXPECT_TRUE( + statfs(SHM_DIR "elfuse_absent_leaf", &sfs) == -1 && errno == ENOENT, + "absent shm leaf did not report ENOENT"); +} + +static void test_rename_within_shm(void) +{ + TEST("rename within /dev/shm and back"); + if (rename(shm_path, shm_path2) != 0) { + FAIL("rename away"); + return; + } + struct stat st; + if (stat(shm_path, &st) == 0 || errno != ENOENT) { + FAIL("old name still visible"); + rename(shm_path2, shm_path); + return; + } + if (rename(shm_path2, shm_path) != 0) { + FAIL("rename back"); + return; + } + EXPECT_TRUE(stat(shm_path, &st) == 0, "object lost across rename"); +} + +/* Data moved out of /dev/shm to a plain /tmp path must survive intact, + * exercising a rename where only the source side is a shm redirect. + */ +static void test_rename_out_of_shm(void) +{ + TEST("rename /dev/shm leaf out to /tmp"); + int fd = open(shm_path2, O_CREAT | O_EXCL | O_WRONLY, 0600); + if (fd < 0) { + FAIL("create shm source"); + return; + } + if (write(fd, "payload", 7) != 7) { + FAIL("write shm source"); + close(fd); + return; + } + close(fd); + if (rename(shm_path2, victim_path) != 0) { + FAIL("rename shm->/tmp"); + return; + } + struct stat st; + int ok = stat(victim_path, &st) == 0 && st.st_size == 7 && + stat(shm_path2, &st) == -1 && errno == ENOENT; + unlink(victim_path); + EXPECT_TRUE(ok, "payload did not move out of /dev/shm intact"); +} + +static void test_link_and_unlink(void) +{ + TEST("link then unlink second name"); + if (link(shm_path, shm_link) != 0) { + FAIL("link"); + return; + } + struct stat a, b; + int ok = stat(shm_path, &a) == 0 && stat(shm_link, &b) == 0 && + a.st_ino == b.st_ino; + if (unlink(shm_link) != 0) { + FAIL("unlink second name"); + return; + } + EXPECT_TRUE(ok, "hard link is a different object"); +} + +static void test_unlink_removes(void) +{ + TEST("unlink then stat is ENOENT"); + if (unlink(shm_path) != 0) { + FAIL("unlink"); + return; + } + struct stat st; + EXPECT_TRUE(stat(shm_path, &st) == -1 && errno == ENOENT, + "object survived unlink"); +} + +/* chdir into a shm directory; getcwd must report the guest path, not the host + * backing location. + */ +static void test_mkdir_chdir(void) +{ + TEST("mkdir + chdir into a shm directory"); + char cwd_before[256]; + if (!getcwd(cwd_before, sizeof(cwd_before))) { + FAIL("getcwd before"); + return; + } + if (mkdir(shm_dir, 0700) != 0) { + FAIL("mkdir shm dir"); + return; + } + if (chdir(shm_dir) != 0) { + FAIL("chdir shm dir"); + return; + } + char cwd_after[256]; + int ok = getcwd(cwd_after, sizeof(cwd_after)) != NULL && + strcmp(cwd_after, shm_dir) == 0; + /* Restore cwd before touching anything else. */ + if (chdir(cwd_before) != 0) { + FAIL("chdir back"); + return; + } + if (rmdir(shm_dir) != 0) { + FAIL("rmdir shm dir"); + return; + } + EXPECT_TRUE(ok, "getcwd leaked the host backing path"); +} + +/* truncate a FIFO leaf. Linux returns EINVAL immediately; the regression is an + * O_WRONLY open blocking forever on the reader-less FIFO (caught by the alarm). + */ +static void test_fifo_truncate_fast(void) +{ + TEST("truncate on a shm FIFO fails fast, not blocks"); + if (mknod(shm_fifo, S_IFIFO | 0600, 0) != 0) { + if (errno == EPERM || errno == ENOSYS) { + printf("SKIP (mknod FIFO unsupported)\n"); + return; + } + FAIL("mknod FIFO"); + return; + } + errno = 0; + int rc = truncate(shm_fifo, 0); + int saved = errno; + if (unlink(shm_fifo) != 0) { + FAIL("unlink FIFO"); + return; + } + /* Accept EINVAL (Linux truncate-on-FIFO). The critical property is that + * the call returned at all rather than hanging. + */ + EXPECT_TRUE(rc == -1 && saved == EINVAL, + "truncate on FIFO did not fail with EINVAL"); +} + +/* statfs on a FIFO leaf must answer from the backing dir synthetically and + * never open the leaf (an open would risk blocking on the FIFO). + */ +static void test_statfs_fifo_no_block(void) +{ + TEST("statfs on a shm FIFO does not block"); + if (mknod(shm_fifo, S_IFIFO | 0600, 0) != 0) { + if (errno == EPERM || errno == ENOSYS) { + printf("SKIP (mknod FIFO unsupported)\n"); + return; + } + FAIL("mknod FIFO"); + return; + } + struct statfs sfs; + int ok = statfs(shm_fifo, &sfs) == 0 && sfs.f_type == TMPFS_MAGIC; + if (unlink(shm_fifo) != 0) { + FAIL("unlink FIFO"); + return; + } + EXPECT_TRUE(ok, "statfs on FIFO leaf did not report tmpfs"); +} + +static int make_victim(void) +{ + int fd = open(victim_path, O_CREAT | O_EXCL | O_WRONLY, 0644); + if (fd < 0) + return -1; + if (write(fd, "victim", 6) != 6) { + close(fd); + return -1; + } + close(fd); + return 0; +} + +static int victim_unchanged(void) +{ + struct stat vic; + if (stat(victim_path, &vic) != 0) + return 0; + return (vic.st_mode & 0777) == 0644 && vic.st_size == 6; +} + +/* Create the host victim and plant a symlink to it at the shm leaf shm_evil. + * Reports the failing step through FAIL; returns 0 on success, -1 otherwise. + */ +static int plant_symlink_victim(void) +{ + if (make_victim() != 0) { + FAIL("create victim"); + return -1; + } + if (symlink(victim_path, shm_evil) != 0) { + FAIL("symlink into /dev/shm"); + return -1; + } + return 0; +} + +/* The central containment test: plant a symlink at a shm leaf pointing at a + * host victim, then drive every op class that can reach the leaf. Each must + * either fail or act on the link itself; none may reach the victim. + */ +static void test_symlink_leaf_is_not_followed(void) +{ + TEST("symlink leaf never reaches its target"); + if (plant_symlink_victim() != 0) + return; + + /* Metadata ops: must not touch the victim. */ + (void) chmod(shm_evil, 0777); + (void) chown(shm_evil, (uid_t) -1, (gid_t) -1); + (void) truncate(shm_evil, 0); + struct timeval past[2] = {{1, 0}, {1, 0}}; + (void) utimes(shm_evil, past); + + /* xattr through the symlink must not reach the victim either. */ + (void) setxattr(shm_evil, "user.elfuse_evil", "x", 1, 0); + + if (!victim_unchanged()) { + FAIL("victim modified through the shm symlink"); + return; + } + + /* stat reports the link (lstat semantics), not the target. */ + struct stat link_st; + if (stat(shm_evil, &link_st) != 0 || !S_ISLNK(link_st.st_mode)) { + FAIL("shm stat followed the symlink"); + return; + } + + /* access() judges the link, and with a live target reports success on the + * link's own reachability; the point is that it did not escape. Re-check + * the victim afterwards. + */ + (void) access(shm_evil, F_OK); + if (!victim_unchanged()) { + FAIL("access escaped through the shm symlink"); + return; + } + + /* readlink is inherently nofollow and must return the raw target string. */ + char target[256]; + ssize_t n = readlink(shm_evil, target, sizeof(target) - 1); + if (n < 0) { + FAIL("readlink"); + return; + } + target[n] = '\0'; + if (strcmp(target, victim_path) != 0) { + FAIL("readlink target mismatch"); + return; + } + + /* open must refuse to follow the leaf. */ + errno = 0; + if (open(shm_evil, O_RDWR) >= 0 || errno != ELOOP) { + FAIL("open followed the shm symlink"); + return; + } + + /* statfs must not follow onto the host filesystem. */ + struct statfs sfs; + if (statfs(shm_evil, &sfs) == 0 && sfs.f_type != TMPFS_MAGIC) { + FAIL("statfs followed the shm symlink onto the host"); + return; + } + + if (unlink(shm_evil) != 0) { + FAIL("unlink symlink"); + return; + } + if (unlink(victim_path) != 0) { + FAIL("unlink victim"); + return; + } + PASS(); +} + +/* chdir must not follow a symlink-to-directory leaf out of the backing dir. */ +static void test_symlink_dir_chdir_contained(void) +{ + TEST("chdir refuses a symlink-to-dir shm leaf"); + char cwd_before[256]; + if (!getcwd(cwd_before, sizeof(cwd_before))) { + FAIL("getcwd"); + return; + } + /* Point the link at /tmp, a real host directory outside the backing dir. */ + if (symlink("/tmp", shm_evil) != 0) { + FAIL("symlink to /tmp"); + return; + } + errno = 0; + int rc = chdir(shm_evil); + char cwd_after[256]; + int cwd_ok = getcwd(cwd_after, sizeof(cwd_after)) != NULL && + strcmp(cwd_after, cwd_before) == 0; + if (rc == 0) + (void) chdir(cwd_before); + if (unlink(shm_evil) != 0) { + FAIL("unlink symlink"); + return; + } + /* Either the chdir failed (ELOOP/ENOTDIR), or if it somehow succeeded the + * cwd must not have escaped; the strong assertion is that it failed. + */ + EXPECT_TRUE(rc == -1 && cwd_ok, "chdir followed a symlink-to-dir shm leaf"); +} + +/* linkat of a symlink leaf hard-links the link itself, never its target. */ +static void test_link_of_symlink_is_link_itself(void) +{ + TEST("link of a shm symlink links the link"); + if (plant_symlink_victim() != 0) + return; + int linked = linkat(AT_FDCWD, shm_evil, AT_FDCWD, shm_link, 0); + int ok = 1; + if (linked == 0) { + struct stat st; + /* New name must itself be a symlink (the link, not the file). */ + ok = lstat(shm_link, &st) == 0 && S_ISLNK(st.st_mode) && + victim_unchanged(); + (void) unlink(shm_link); + } else { + /* Some host filesystems reject hard-linking a symlink; that is also + * contained. Just require the victim untouched. + */ + ok = victim_unchanged(); + } + (void) unlink(shm_evil); + (void) unlink(victim_path); + EXPECT_TRUE(ok, "link of a shm symlink reached its target"); +} + +/* rename of a symlink within /dev/shm moves the link, not its target. */ +static void test_rename_symlink_within_shm(void) +{ + TEST("rename of a shm symlink moves the link"); + if (plant_symlink_victim() != 0) + return; + if (rename(shm_evil, shm_link) != 0) { + FAIL("rename symlink"); + (void) unlink(shm_evil); + (void) unlink(victim_path); + return; + } + struct stat st; + int ok = + lstat(shm_link, &st) == 0 && S_ISLNK(st.st_mode) && victim_unchanged(); + (void) unlink(shm_link); + (void) unlink(victim_path); + EXPECT_TRUE(ok, "rename dereferenced the shm symlink"); +} + +/* A symlink renamed into /dev/shm from a plain path still cannot be followed, + * covering the creation vector that bypasses sys_symlinkat. + */ +static void test_imported_symlink_contained(void) +{ + TEST("symlink renamed into shm stays contained"); + char tmp_link[128]; + snprintf(tmp_link, sizeof(tmp_link), "/tmp/elfuse-shm-implink-%d", + (int) getpid()); + if (make_victim() != 0) { + FAIL("create victim"); + return; + } + if (symlink(victim_path, tmp_link) != 0) { + FAIL("symlink at /tmp"); + (void) unlink(victim_path); + return; + } + if (rename(tmp_link, shm_evil) != 0) { + /* If the host refuses the cross-directory rename of a symlink, skip. */ + printf("SKIP (import rename unsupported)\n"); + (void) unlink(tmp_link); + (void) unlink(victim_path); + return; + } + (void) chmod(shm_evil, 0777); + int ok = victim_unchanged(); + (void) unlink(shm_evil); + (void) unlink(victim_path); + EXPECT_TRUE(ok, "chmod followed an imported shm symlink"); +} + +/* execve of a symlink leaf pointing at a host binary must not run the target; + * a real binary copied into /dev/shm must still run. Both are checked in a + * child so a successful exec does not replace the test process. + */ +static void test_execve_symlink_contained(void) +{ + TEST("execve refuses a symlink shm leaf"); + if (symlink("/bin/true", shm_evil) != 0) { + /* No host /bin/true visible; still assert the symlink+exec is refused + * by pointing at a test-controlled victim path. + */ + if (make_victim() != 0 || symlink(victim_path, shm_evil) != 0) { + FAIL("prepare symlink"); + return; + } + } + pid_t pid = fork(); + if (pid == 0) { + char *argv[] = {shm_evil, NULL}; + execv(shm_evil, argv); + /* Reachable only if exec failed, which is the contained outcome. */ + _exit(42); + } + int status = 0; + int refused = 0; + if (pid > 0 && waitpid(pid, &status, 0) == pid) + refused = WIFEXITED(status) && WEXITSTATUS(status) == 42; + (void) unlink(shm_evil); + (void) unlink(victim_path); + EXPECT_TRUE(refused, "execve followed the shm symlink"); +} + +/* A real binary in /dev/shm must execute the same as anywhere else: the + * redirect adds no barrier for legitimate files. elfuse's exec-permission model + * independently rejects freshly-created guest binaries with EACCES regardless + * of location, so an EACCES child is a skip, keeping this about the redirect + * only. The child reports the execv errno as its exit code; success exits 7. + */ +static void test_execve_real_binary_runs(const char *self) +{ + TEST("execve of a real binary in /dev/shm runs"); + int in = open(self, O_RDONLY); + if (in < 0) { + printf("SKIP (cannot reopen self)\n"); + return; + } + int out = open(shm_exec, O_CREAT | O_EXCL | O_WRONLY, 0700); + if (out < 0) { + close(in); + FAIL("create shm exec target"); + return; + } + char buf[65536]; + ssize_t r; + int copy_ok = 1; + while ((r = read(in, buf, sizeof(buf))) > 0) { + if (write(out, buf, (size_t) r) != r) { + copy_ok = 0; + break; + } + } + close(in); + close(out); + if (!copy_ok) { + (void) unlink(shm_exec); + FAIL("copy self into /dev/shm"); + return; + } + /* Grant world-execute so elfuse's exec-permission model (which compares the + * emulated euid against the host file's owner) admits the copy regardless + * of the runner's host uid. Without this the check is owner-only (0700) and + * the exec runs or is rejected depending on whether the host uid happens to + * match the emulated GUEST_UID, making this case pass on some CI runners + * and skip on others. + */ + if (chmod(shm_exec, 0777) != 0) { + (void) unlink(shm_exec); + FAIL("chmod shm exec copy"); + return; + } + pid_t pid = fork(); + if (pid == 0) { + char *argv[] = {(char *) shm_exec, (char *) "--exec-sentinel", NULL}; + execv(shm_exec, argv); + _exit(errno); /* report why exec failed to the parent */ + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid || !WIFEXITED(status)) { + (void) unlink(shm_exec); + FAIL("fork/wait for shm exec child"); + return; + } + (void) unlink(shm_exec); + int code = WEXITSTATUS(status); + if (code == 7) { + PASS(); + } else if (code == EACCES) { + printf("SKIP (elfuse exec-permission model rejects the copy)\n"); + } else { + FAIL("real binary in /dev/shm failed for a shm-specific reason"); + } +} + +static void test_flat_namespace_gate(void) +{ + TEST("nested and traversing names rejected"); + errno = 0; + if (chmod(SHM_DIR "a/b", 0644) != -1 || errno != EACCES) { + FAIL("nested name not rejected with EACCES"); + return; + } + errno = 0; + if (chmod(SHM_DIR "..", 0644) != -1 || errno != EACCES) { + FAIL("traversing name not rejected with EACCES"); + return; + } + PASS(); +} + +/* Flat names that merely contain ".." are valid shm names, the regression the + * strstr("..") gate broke. + */ +static void test_dotdot_bearing_names_allowed(void) +{ + TEST("flat names containing .. are allowed"); + static const char *names[] = {"a..b", "..a", "a..", "..."}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + char p[128]; + snprintf(p, sizeof(p), SHM_DIR "elfuse_%d_%s", (int) getpid(), + names[i]); + int fd = open(p, O_CREAT | O_EXCL | O_RDWR, 0600); + if (fd < 0) { + FAIL("open dotdot-bearing name"); + return; + } + close(fd); + if (chmod(p, 0640) != 0) { + FAIL("chmod dotdot-bearing name"); + (void) unlink(p); + return; + } + if (unlink(p) != 0) { + FAIL("unlink dotdot-bearing name"); + return; + } + } + PASS(); +} + +/* An over-long leaf name overflows the host backing path and must surface as + * ENAMETOOLONG rather than a truncated resolution. + */ +static void test_oversized_name(void) +{ + TEST("oversized shm name is ENAMETOOLONG"); + char p[600]; + int off = snprintf(p, sizeof(p), SHM_DIR); + memset(p + off, 'x', sizeof(p) - off - 1); + p[sizeof(p) - 1] = '\0'; + errno = 0; + EXPECT_TRUE(open(p, O_CREAT | O_RDWR, 0600) == -1 && + (errno == ENAMETOOLONG || errno == EACCES), + "oversized name was not rejected"); +} + +int main(int argc, char **argv) +{ + /* Re-exec sentinel used by test_execve_real_binary_runs. */ + if (argc >= 2 && strcmp(argv[1], "--exec-sentinel") == 0) + return 7; + + /* Bound the run so a reintroduced blocking FIFO open cannot hang CI: an + * unhandled SIGALRM ends the process. A true hang is unbounded, so this + * still catches it while leaving headroom for a slow host. + */ + alarm(120); + + printf("test-dev-shm-paths: /dev/shm path-syscall consistency\n"); + + name_fixtures(); + cleanup_fixtures(); + + if (test_open_then_chmod() == 0) { + test_metadata_ops_hit_same_object(); + test_xattr_round_trip(); + test_statfs_reports_tmpfs(); + test_statfs_root_reports_tmpfs(); + test_statfs_missing_leaf(); + test_rename_within_shm(); + test_rename_out_of_shm(); + test_link_and_unlink(); + test_unlink_removes(); + } + + test_mkdir_chdir(); + test_fifo_truncate_fast(); + test_statfs_fifo_no_block(); + + test_symlink_leaf_is_not_followed(); + test_symlink_dir_chdir_contained(); + test_link_of_symlink_is_link_itself(); + test_rename_symlink_within_shm(); + test_imported_symlink_contained(); + test_execve_symlink_contained(); + test_execve_real_binary_runs(argv[0]); + + test_flat_namespace_gate(); + test_dotdot_bearing_names_allowed(); + test_oversized_name(); + + cleanup_fixtures(); + + SUMMARY("test-dev-shm-paths"); + return fails == 0 ? 0 : 1; +}