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; +}