diff --git a/bind-mount.c b/bind-mount.c index 4e1c5e39..92813bfa 100644 --- a/bind-mount.c +++ b/bind-mount.c @@ -416,6 +416,58 @@ bind_mount (const char *src, return bind_mount_fd (src_fd, dest_fd, options, failing_path); } +bind_mount_result +bind_mount_fd_new (int src_fd, + int dest_fd, + bind_option_t options) +{ +#ifdef HAVE_FD_MOUNTS + struct stat st; + struct mount_attr attr = { + .attr_set = MOUNT_ATTR_NOSUID | + ((options & BIND_DEVICES) ? 0 : MOUNT_ATTR_NODEV) | + ((options & BIND_READONLY) ? MOUNT_ATTR_RDONLY : 0), + /* An original caller fd can refer to a shared mount outside the new + * namespace. Receive its events without sending changes back to it. */ + .propagation = MS_SLAVE, + }; + + if (opt_force_mount_setattr_fallback) + return BIND_MOUNT_UNSUPPORTED; + if (fstat (src_fd, &st) < 0) + return BIND_MOUNT_ERROR_MOUNT; + if (S_ISLNK (st.st_mode)) + { + errno = ELOOP; + return BIND_MOUNT_ERROR_MOUNT; + } + + unsigned recursive = (options & BIND_RECURSIVE) && S_ISDIR (st.st_mode) ? AT_RECURSIVE : 0; + cleanup_fd int tree = open_tree (src_fd, "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | + AT_EMPTY_PATH | recursive); + if (tree < 0) + { + /* Some kernels cannot clone an fd retained from the parent's mount + * namespace. No mount has been attached, so the caller can reopen and + * verify that source with the existing implementation. */ + return (errno == ENOSYS || errno == EINVAL) ? BIND_MOUNT_UNSUPPORTED : BIND_MOUNT_ERROR_MOUNT; + } + + /* Only add restrictions to the private clone; retain inherited flags. */ + if (mount_setattr_wrapper (tree, "", AT_EMPTY_PATH | recursive, &attr, sizeof attr) < 0) + return errno == ENOSYS ? BIND_MOUNT_UNSUPPORTED : BIND_MOUNT_ERROR_MOUNT_SETATTR; + if (move_mount (tree, "", dest_fd, "", + MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_T_EMPTY_PATH) < 0) + return errno == ENOSYS ? BIND_MOUNT_UNSUPPORTED : BIND_MOUNT_ERROR_MOUNT; + return BIND_MOUNT_SUCCESS; +#else + (void) src_fd; + (void) dest_fd; + (void) options; + return BIND_MOUNT_UNSUPPORTED; +#endif +} + bind_mount_result bind_mount_fd (int src_fd, int dest_fd, @@ -426,6 +478,21 @@ bind_mount_fd (int src_fd, cleanup_free char *resolved_dest = NULL; cleanup_free char *dest_proc = NULL; + if (src_fd >= 0) + { + bind_mount_result result = bind_mount_fd_new (src_fd, dest_fd, options); + if (result != BIND_MOUNT_UNSUPPORTED) + { + if (result != BIND_MOUNT_SUCCESS && failing_path != NULL) + { + int saved_errno = errno; + *failing_path = fd_to_proc_path (dest_fd); + errno = saved_errno; + } + return result; + } + } + dest_proc = fd_to_proc_path (dest_fd); /* If we are in a case-insensitive filesystem, mountinfo might contain a @@ -594,6 +661,11 @@ bind_mount_result_to_string (bind_mount_result res, string = xstrdup ("Success"); break; + case BIND_MOUNT_UNSUPPORTED: + string = xstrdup ("File-descriptor mount API is unavailable"); + want_errno = false; + break; + default: string = xstrdup ("(unknown/invalid bind_mount_result)"); break; @@ -644,6 +716,7 @@ die_with_bind_result (bind_mount_result res, case BIND_MOUNT_ERROR_OPEN_FD: case BIND_MOUNT_ERROR_MOUNT_SETATTR: case BIND_MOUNT_SUCCESS: + case BIND_MOUNT_UNSUPPORTED: default: fprintf (stderr, ": %s", strerror (saved_errno)); } diff --git a/bind-mount.h b/bind-mount.h index 71f54c39..02e8ba7b 100644 --- a/bind-mount.h +++ b/bind-mount.h @@ -38,8 +38,16 @@ typedef enum BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT, BIND_MOUNT_ERROR_OPEN_FD, BIND_MOUNT_ERROR_MOUNT_SETATTR, + BIND_MOUNT_UNSUPPORTED, } bind_mount_result; +/* Clone, restrict and attach using only descriptors. UNSUPPORTED means that + * no mount was attached and the caller can use the traditional mount API. + * All other failures must be reported, without retrying with weaker rules. */ +bind_mount_result bind_mount_fd_new (int src_fd, + int dest_fd, + bind_option_t options); + bind_mount_result bind_mount (const char *src, const char *dest, bind_option_t options, diff --git a/bubblewrap.c b/bubblewrap.c index 62227014..66b7cace 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -40,6 +40,8 @@ #include "utils.h" #include "network.h" #include "bind-mount.h" +#include "mount-api.h" +#include "newroot-mount.h" #ifndef CLONE_NEWCGROUP #define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */ @@ -76,6 +78,10 @@ static bool opt_unshare_cgroup = false; static bool opt_unshare_cgroup_try = false; static bool opt_needs_devpts = false; static bool opt_new_session = false; +#ifdef HAVE_DETACHED_MOUNTS +static bool use_single_pivot = false; +static SinglePivot single_pivot = { .root_fd = -1, .host_fd = -1 }; +#endif static bool opt_die_with_parent = false; static uid_t opt_sandbox_uid = -1; static gid_t opt_sandbox_gid = -1; @@ -857,9 +863,201 @@ drop_privs (bool keep_requested_caps) die_with_error ("can't set dumpable"); } +#ifdef HAVE_DETACHED_MOUNTS +/* The retained root descriptor cannot follow a mount placed over /. + * Keep relative paths and dot components on the existing setup path too. */ +static bool +single_pivot_destination_supported (const char *path) +{ + if (path == NULL || path[0] != '/') + return false; + + while (*path == '/') + path++; + if (*path == '\0') + return false; + + while (*path) + { + const char *end = strchr (path, '/'); + size_t length = end ? (size_t) (end - path) : strlen (path); + + if ((length == 1 && path[0] == '.') || + (length == 2 && path[0] == '.' && path[1] == '.')) + return false; + path += length; + while (*path == '/') + path++; + } + return true; +} + +static bool +single_pivot_layout_supported (void) +{ + SetupOp *op; + + if (real_uid == 0 || !opt_unshare_user || !opt_unshare_pid || + opt_userns_fd != -1 || opt_userns2_fd != -1 || opt_userns_block_fd != -1 || + opt_pidns_fd != -1 || opt_file_label != NULL || opt_not_a_security_boundary || + opt_force_openat_fallback || opt_force_mount_setattr_fallback) + return false; + for (op = ops; op; op = op->next) + { + /* Start with layouts whose root and destination construction can use a + * retained descriptor. Preserve the existing implementation for other + * operations, root overmounts, relative paths and dot components. */ + if (op->fd >= 0) + return false; + switch (op->type) + { + case SETUP_BIND_MOUNT: + case SETUP_RO_BIND_MOUNT: + case SETUP_DEV_BIND_MOUNT: + case SETUP_MOUNT_PROC: + case SETUP_MOUNT_DEV: + case SETUP_MOUNT_TMPFS: + case SETUP_MAKE_DIR: + if (!single_pivot_destination_supported (op->dest)) + return false; + break; + + case SETUP_SET_HOSTNAME: + break; + + case SETUP_MAKE_SYMLINK: + case SETUP_OVERLAY_MOUNT: + case SETUP_TMP_OVERLAY_MOUNT: + case SETUP_RO_OVERLAY_MOUNT: + case SETUP_OVERLAY_SRC: + case SETUP_MOUNT_MQUEUE: + case SETUP_MAKE_FILE: + case SETUP_MAKE_BIND_FILE: + case SETUP_MAKE_RO_BIND_FILE: + case SETUP_REMOUNT_RO_NO_RECURSIVE: + case SETUP_CHMOD: + default: + return false; + } + } + return true; +} +#endif /* HAVE_DETACHED_MOUNTS */ + +static char * +prepare_legacy_root (const char *base_path) +{ + char *old_cwd; + int i; + + /* Create a tmpfs which we will use as / in the namespace */ + if (mount ("tmpfs", base_path, "tmpfs", MS_NODEV | MS_NOSUID, NULL) != 0) + die_with_mount_error ("Failed to mount tmpfs"); + + old_cwd = get_current_dir_name (); + + /* Chdir to the new root tmpfs mount. This will be the CWD during + the entire setup. Access old or new root via "oldroot" and "newroot". */ + if (chdir (base_path) != 0) + die_with_error ("chdir base_path"); + + /* We create a subdir "$base_path/newroot" for the new root, that + * way we can pivot_root to base_path, and put the old root at + * "$base_path/oldroot". This avoids problems accessing the oldroot + * dir if the user requested to bind mount something over / (or + * over /tmp, now that we use that for base_path). */ + + if (mkdir ("newroot", 0755)) + die_with_error ("Creating newroot failed"); + + if (mount ("newroot", "newroot", NULL, MS_SILENT | MS_MGC_VAL | MS_BIND | MS_REC, NULL) < 0) + die_with_mount_error ("setting up newroot bind"); + + if (mkdir ("oldroot", 0755)) + die_with_error ("Creating oldroot failed"); + + for (i = 0; i < opt_tmp_overlay_count; i++) + { + char *dirname; + dirname = xasprintf ("tmp-overlay-upper-%d", i); + if (mkdir (dirname, 0755)) + die_with_error ("Creating --tmp-overlay upperdir failed"); + free (dirname); + dirname = xasprintf ("tmp-overlay-work-%d", i); + if (mkdir (dirname, 0755)) + die_with_error ("Creating --tmp-overlay workdir failed"); + free (dirname); + } + + if (pivot_root (base_path, "oldroot")) + die_with_error ("pivot_root"); + + if (chdir ("/") != 0) + die_with_error ("chdir / (base path)"); + + /* Bind-mount proc so /proc/self/fd/N paths work for fd-based mount() calls */ + if (mkdir ("proc", 0755)) + die_with_error ("Creating proc failed"); + if (mount ("oldroot/proc", "proc", NULL, MS_SILENT | MS_BIND | MS_REC, NULL) != 0) + die_with_mount_error ("mounting proc"); + + return old_cwd; +} + +static void +enter_legacy_root (void) +{ + /* The old root better be rprivate or we will send unmount events to the parent namespace */ + if (mount ("oldroot", "oldroot", NULL, MS_SILENT | MS_REC | MS_PRIVATE, NULL) != 0) + die_with_mount_error ("Failed to make old root rprivate"); + + if (umount2 ("oldroot", MNT_DETACH)) + die_with_error ("unmount old root"); + + /* This is our second pivot. It's like we're a Silicon Valley startup flush + * with cash but short on ideas! + * + * We're aiming to make /newroot the real root, and get rid of /oldroot. To do + * that we need a temporary place to store it before we can unmount it. + */ + { cleanup_fd int oldrootfd = TEMP_FAILURE_RETRY (open ("/", O_DIRECTORY | O_RDONLY)); + if (oldrootfd < 0) + die_with_error ("can't open /"); + if (chdir ("/newroot") != 0) + die_with_error ("chdir /newroot"); + /* While the documentation claims that put_old must be underneath + * new_root, it is perfectly fine to use the same directory as the + * kernel checks only if old_root is accessible from new_root. + * + * Both runc and LXC are using this "alternative" method for + * setting up the root of the container: + * + * https://github.com/opencontainers/runc/blob/HEAD/libcontainer/rootfs_linux.go#L671 + * https://github.com/lxc/lxc/blob/HEAD/src/lxc/conf.c#L1121 + */ + if (pivot_root (".", ".") != 0) + die_with_error ("pivot_root(/newroot)"); + if (fchdir (oldrootfd) < 0) + die_with_error ("fchdir to oldroot"); + if (umount2 (".", MNT_DETACH) < 0) + die_with_error ("umount old root"); + if (chdir ("/") != 0) + die_with_error ("chdir /"); + } +} + static int openat_in_root (const char *root, const char *path, int flags) { +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + if (strcmp (root, "/oldroot") == 0) + return single_pivot_openat2 (single_pivot.host_fd, path, flags); + if (strcmp (root, "/newroot") == 0) + return single_pivot_openat2 (single_pivot.root_fd, path, flags); + } +#endif /* We have reopen the root dir, because we typically mount on top of * /newroot (e.g. with --bind / /), which an old O_PATH fd will not * pick up */ @@ -928,6 +1126,13 @@ setup_op_bind_mount_fd (bind_option_t options, int dest_fd, const char *dest_display) { +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + single_pivot_bind (&single_pivot, options, src_fd, src_display, dest_fd, dest_display); + return; + } +#endif bind_mount_result bind_result; char *failing_path = NULL; @@ -969,6 +1174,20 @@ setup_op_tmpfs_mount (uint32_t perms, if (size > MAX_TMPFS_BYTES) die_with_error ("Specified tmpfs size too large (%zu > %zu)", size, MAX_TMPFS_BYTES); +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + single_pivot_mount_filesystem (&single_pivot, "tmpfs", MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV, + perms, size, dest_fd, dest_display); + return; + } +#endif + /* Keep SELinux-labelled mounts on the existing option-handling path. */ + if (opt_file_label == NULL && !opt_force_mount_setattr_fallback && + mount_filesystem_fd ("tmpfs", MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV, + perms, size, dest_fd, dest_display)) + return; + if (size != 0) mode = xasprintf ("mode=%#o,size=%zu", perms, size); else @@ -1153,7 +1372,16 @@ setup_newroot (bool unshare_pid) * for these. This should be fine because /proc doesn't have any regular * absolute symlinks, and the magic links should work fine. */ - cleanup_free char *proc_oldroot_path = get_oldroot_path (op->source); + cleanup_free char *proc_oldroot_path = NULL; +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + cleanup_free char *host_root = fd_to_proc_path (single_pivot.host_fd); + proc_oldroot_path = strconcat (host_root, op->source); + } + else +#endif + proc_oldroot_path = get_oldroot_path (op->source); source_fd = TEMP_FAILURE_RETRY ( open (proc_oldroot_path, O_PATH | O_CLOEXEC)); } @@ -1279,9 +1507,19 @@ setup_newroot (bool unshare_pid) if (op->type == SETUP_DEV_BIND_MOUNT) bind_flags |= BIND_DEVICES; - setup_op_bind_mount_fd (bind_flags, source_fd, op->source, dest_fd, op->dest); - - /* When using bind-fd, there is a race condition between resolving the fd as a magic symlink + /* Prefer the caller's original descriptor for --[ro-]bind-fd. + * The reopened source and identity check below remain necessary + * for the traditional mount API on older kernels. */ + bind_mount_result result = BIND_MOUNT_UNSUPPORTED; + if (op->fd >= 0) + result = bind_mount_fd_new (op->fd, dest_fd, BIND_RECURSIVE | bind_flags); + if (result == BIND_MOUNT_UNSUPPORTED) + setup_op_bind_mount_fd (bind_flags, source_fd, op->source, dest_fd, op->dest); + else if (result != BIND_MOUNT_SUCCESS) + die_with_bind_result (result, errno, op->dest, + "Can't bind fd %d on %s", op->fd, op->dest); + + /* When using the traditional API for bind-fd, there is a race condition between resolving the fd as a magic symlink * and mounting it, where someone could replace what is at the symlink target. Ideally * we would not even resolve the symlink and directly bind-mount from the fd, but unfortunately * we can't do that, because its not permitted to bind mount a fd from another user namespace. @@ -1388,7 +1626,22 @@ setup_newroot (bool unshare_pid) if (unshare_pid || opt_pidns_fd != -1) { /* Our own procfs */ - if (mount ("proc", dest_path, "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) + bool mounted = false; +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + single_pivot_mount_filesystem (&single_pivot, "proc", + MOUNT_ATTR_NOSUID | MOUNT_ATTR_NOEXEC | MOUNT_ATTR_NODEV, + 0, 0, dest_fd, op->dest); + mounted = true; + } + else +#endif + if (!opt_force_mount_setattr_fallback) + mounted = mount_filesystem_fd ("proc", MOUNT_ATTR_NOSUID | MOUNT_ATTR_NOEXEC | MOUNT_ATTR_NODEV, + 0, 0, dest_fd, op->dest); + if (!mounted && + mount ("proc", dest_path, "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) die_with_mount_error ("Can't mount proc on %s", op->dest); } else @@ -1474,7 +1727,24 @@ setup_newroot (bool unshare_pid) die_with_error ("Can't open %s/pts", op->dest); cleanup_free char *pts_path = fd_to_proc_path (pts_fd); - if (mount ("devpts", pts_path, "devpts", MS_NOSUID | MS_NOEXEC, + bool mounted = false; +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + cleanup_free char *pts_display = strconcat (op->dest, "/pts"); + single_pivot_mount_filesystem (&single_pivot, "devpts", MOUNT_ATTR_NOSUID | MOUNT_ATTR_NOEXEC, + 0, 0, pts_fd, pts_display); + mounted = true; + } + else +#endif + if (!opt_force_mount_setattr_fallback) + { + cleanup_free char *pts_display = strconcat (op->dest, "/pts"); + mounted = mount_filesystem_fd ("devpts", MOUNT_ATTR_NOSUID | MOUNT_ATTR_NOEXEC, + 0, 0, pts_fd, pts_display); + } + if (!mounted && mount ("devpts", pts_path, "devpts", MS_NOSUID | MS_NOEXEC, "newinstance,ptmxmode=0666,mode=620") != 0) die_with_mount_error ("Can't mount devpts on %s/pts", op->dest); } @@ -2878,7 +3148,6 @@ main (int argc, cleanup_free char *args_data UNUSED = NULL; int intermediate_pids_sockets[2] = {-1, -1}; const char *exec_path = NULL; - int i; struct sigaction sa = {}; /* Handle --version early on before we try to acquire/drop @@ -3251,104 +3520,42 @@ main (int argc, /* Need to do this before the chroot, but after we're the real uid */ resolve_symlinks_in_ops (); +#ifdef HAVE_DETACHED_MOUNTS + if (single_pivot_layout_supported ()) + single_pivot.root_fd = single_pivot_prepare (); + use_single_pivot = single_pivot.root_fd >= 0; +#endif + /* Mark everything as slave, so that we still * receive mounts from the real root, but don't * propagate mounts to the real root. */ if (mount (NULL, "/", NULL, MS_SILENT | MS_SLAVE | MS_REC, NULL) < 0) die_with_mount_error ("Failed to make / slave"); - /* Create a tmpfs which we will use as / in the namespace */ - if (mount ("tmpfs", base_path, "tmpfs", MS_NODEV | MS_NOSUID, NULL) != 0) - die_with_mount_error ("Failed to mount tmpfs"); - - old_cwd = get_current_dir_name (); - - /* Chdir to the new root tmpfs mount. This will be the CWD during - the entire setup. Access old or new root via "oldroot" and "newroot". */ - if (chdir (base_path) != 0) - die_with_error ("chdir base_path"); - - /* We create a subdir "$base_path/newroot" for the new root, that - * way we can pivot_root to base_path, and put the old root at - * "$base_path/oldroot". This avoids problems accessing the oldroot - * dir if the user requested to bind mount something over / (or - * over /tmp, now that we use that for base_path). */ - - if (mkdir ("newroot", 0755)) - die_with_error ("Creating newroot failed"); - - if (mount ("newroot", "newroot", NULL, MS_SILENT | MS_MGC_VAL | MS_BIND | MS_REC, NULL) < 0) - die_with_mount_error ("setting up newroot bind"); - - if (mkdir ("oldroot", 0755)) - die_with_error ("Creating oldroot failed"); - - for (i = 0; i < opt_tmp_overlay_count; i++) +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) { - char *dirname; - dirname = xasprintf ("tmp-overlay-upper-%d", i); - if (mkdir (dirname, 0755)) - die_with_error ("Creating --tmp-overlay upperdir failed"); - free (dirname); - dirname = xasprintf ("tmp-overlay-work-%d", i); - if (mkdir (dirname, 0755)) - die_with_error ("Creating --tmp-overlay workdir failed"); - free (dirname); + /* The detached root has not covered any host path. Save cwd before + * entering it, so relative working directories retain their meaning. */ + old_cwd = get_current_dir_name (); + single_pivot.host_fd = open ("/", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (single_pivot.host_fd < 0) + die_with_error ("single-pivot: retain host root"); } - - if (pivot_root (base_path, "oldroot")) - die_with_error ("pivot_root"); - - if (chdir ("/") != 0) - die_with_error ("chdir / (base path)"); - - /* Bind-mount proc so /proc/self/fd/N paths work for fd-based mount() calls */ - if (mkdir ("proc", 0755)) - die_with_error ("Creating proc failed"); - if (mount ("oldroot/proc", "proc", NULL, MS_SILENT | MS_BIND | MS_REC, NULL) != 0) - die_with_mount_error ("mounting proc"); + else +#endif + old_cwd = prepare_legacy_root (base_path); setup_newroot (opt_unshare_pid); close_ops_fd (); - /* The old root better be rprivate or we will send unmount events to the parent namespace */ - if (mount ("oldroot", "oldroot", NULL, MS_SILENT | MS_REC | MS_PRIVATE, NULL) != 0) - die_with_mount_error ("Failed to make old root rprivate"); - - if (umount2 ("oldroot", MNT_DETACH)) - die_with_error ("unmount old root"); - - /* This is our second pivot. It's like we're a Silicon Valley startup flush - * with cash but short on ideas! - * - * We're aiming to make /newroot the real root, and get rid of /oldroot. To do - * that we need a temporary place to store it before we can unmount it. - */ - { cleanup_fd int oldrootfd = TEMP_FAILURE_RETRY (open ("/", O_DIRECTORY | O_RDONLY)); - if (oldrootfd < 0) - die_with_error ("can't open /"); - if (chdir ("/newroot") != 0) - die_with_error ("chdir /newroot"); - /* While the documentation claims that put_old must be underneath - * new_root, it is perfectly fine to use the same directory as the - * kernel checks only if old_root is accessible from new_root. - * - * Both runc and LXC are using this "alternative" method for - * setting up the root of the container: - * - * https://github.com/opencontainers/runc/blob/HEAD/libcontainer/rootfs_linux.go#L671 - * https://github.com/lxc/lxc/blob/HEAD/src/lxc/conf.c#L1121 - */ - if (pivot_root (".", ".") != 0) - die_with_error ("pivot_root(/newroot)"); - if (fchdir (oldrootfd) < 0) - die_with_error ("fchdir to oldroot"); - if (umount2 (".", MNT_DETACH) < 0) - die_with_error ("umount old root"); - if (chdir ("/") != 0) - die_with_error ("chdir /"); - } +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + single_pivot_enter (&single_pivot, base_path); + else +#endif + enter_legacy_root (); if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); diff --git a/meson.build b/meson.build index feb3a785..58066a6c 100644 --- a/meson.build +++ b/meson.build @@ -99,6 +99,30 @@ if assume_kernel != '' cdata.set('HAVE_ASSUMED_KERNEL', 1) endif +# The mount_setattr wrapper also works with older libc versions. +have_fd_mounts = true +foreach function : ['open_tree', 'move_mount'] + have_fd_mounts = have_fd_mounts and cc.has_function( + function, prefix : '#define _GNU_SOURCE\n#include ') +endforeach +if have_fd_mounts + cdata.set('HAVE_FD_MOUNTS', 1) +endif + +have_filesystem_mounts = have_fd_mounts +foreach function : ['fsopen', 'fsconfig', 'fsmount'] + have_filesystem_mounts = have_filesystem_mounts and cc.has_function( + function, prefix : '#define _GNU_SOURCE\n#include ') +endforeach +if have_filesystem_mounts + cdata.set('HAVE_FILESYSTEM_MOUNTS', 1) +endif + +have_detached_mounts = have_filesystem_mounts and cc.has_header('linux/openat2.h') and cc.has_header_symbol('sys/syscall.h', 'SYS_openat2') +if have_detached_mounts + cdata.set('HAVE_DETACHED_MOUNTS', 1) +endif + configure_file( output : 'config.h', configuration : cdata, @@ -121,6 +145,8 @@ bwrap = executable( [ 'bubblewrap.c', 'bind-mount.c', + 'mount-api.c', + 'newroot-mount.c', 'network.c', 'utils.c', 'chroot_realpath.c', diff --git a/mount-api.c b/mount-api.c new file mode 100644 index 00000000..41e8c35b --- /dev/null +++ b/mount-api.c @@ -0,0 +1,94 @@ +/* File-descriptor filesystem creation. + * SPDX-License-Identifier: LGPL-2.0-or-later */ +#include "config.h" + +#include +#include "mount-api.h" + +#ifdef HAVE_FILESYSTEM_MOUNTS +static bool +configure_filesystem (int context, unsigned command, const char *key, + const char *value, const char *type, const char *dest_display) +{ + if (fsconfig (context, command, key, value, 0) == 0) + return true; + if (errno == ENOSYS) + return false; + die_with_error ("fsconfig %s for %s on %s", key ? key : "create", type, dest_display); +} +#endif + +int +create_detached_mount (const char *type, unsigned attrs, uint32_t perms, + size_t size, const char *dest_display) +{ +#ifdef HAVE_FILESYSTEM_MOUNTS + cleanup_fd int context = fsopen (type, FSOPEN_CLOEXEC); + + if (context < 0) + { + if (errno == ENOSYS) + return -1; + die_with_error ("fsopen %s for %s", type, dest_display); + } + if (strcmp (type, "tmpfs") == 0) + { + cleanup_free char *mode = xasprintf ("%#o", perms); + if (!configure_filesystem (context, FSCONFIG_SET_STRING, "mode", mode, type, dest_display)) + return -1; + if (size) + { + cleanup_free char *bytes = xasprintf ("%zu", size); + if (!configure_filesystem (context, FSCONFIG_SET_STRING, "size", bytes, type, dest_display)) + return -1; + } + } + if (strcmp (type, "devpts") == 0) + { + if (!configure_filesystem (context, FSCONFIG_SET_FLAG, "newinstance", NULL, type, dest_display) || + !configure_filesystem (context, FSCONFIG_SET_STRING, "ptmxmode", "0666", type, dest_display) || + !configure_filesystem (context, FSCONFIG_SET_STRING, "mode", "620", type, dest_display)) + return -1; + } + if (!configure_filesystem (context, FSCONFIG_CMD_CREATE, NULL, NULL, type, dest_display)) + return -1; + int tree = fsmount (context, FSMOUNT_CLOEXEC, attrs); + if (tree < 0 && errno != ENOSYS) + die_with_error ("fsmount %s on %s", type, dest_display); + return tree; +#else + (void) type; + (void) attrs; + (void) perms; + (void) size; + (void) dest_display; + errno = ENOSYS; + return -1; +#endif +} + +bool +mount_filesystem_fd (const char *type, unsigned attrs, uint32_t perms, + size_t size, int dest_fd, const char *dest_display) +{ +#ifdef HAVE_FILESYSTEM_MOUNTS + cleanup_fd int tree = create_detached_mount (type, attrs, perms, size, dest_display); + + if (tree < 0) + return false; + if (move_mount (tree, "", dest_fd, "", + MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_T_EMPTY_PATH) == 0) + return true; + if (errno == ENOSYS) + return false; + die_with_error ("move_mount %s on %s", type, dest_display); +#else + (void) type; + (void) attrs; + (void) perms; + (void) size; + (void) dest_fd; + (void) dest_display; + return false; +#endif +} diff --git a/mount-api.h b/mount-api.h new file mode 100644 index 00000000..d1036d98 --- /dev/null +++ b/mount-api.h @@ -0,0 +1,15 @@ +/* File-descriptor filesystem creation. + * SPDX-License-Identifier: LGPL-2.0-or-later */ +#pragma once + +#include +#include "utils.h" + +/* Return an owned mount fd, or -1 with ENOSYS if an API is unavailable. + * Other errors are fatal and identify the filesystem and destination. */ +int create_detached_mount (const char *type, unsigned attrs, uint32_t perms, + size_t size, const char *dest_display); + +/* Return false only if an API is unavailable, before attaching anything. */ +bool mount_filesystem_fd (const char *type, unsigned attrs, uint32_t perms, + size_t size, int dest_fd, const char *dest_display); diff --git a/newroot-mount.c b/newroot-mount.c new file mode 100644 index 00000000..851a3def --- /dev/null +++ b/newroot-mount.c @@ -0,0 +1,158 @@ +/* Detached mount backend for supported layouts on modern kernels. + * SPDX-License-Identifier: LGPL-2.0-or-later */ +#include "config.h" + +#include + +#include "newroot-mount.h" +#include "mount-api.h" + +#ifdef HAVE_DETACHED_MOUNTS +#include +#include + +/* No path-based fallback: detached trees have no usable visible path. */ +int +single_pivot_openat2 (int root, const char *path, int flags) +{ + struct open_how how = { + .flags = flags | O_CLOEXEC | ((flags & O_PATH) ? 0 : O_NOCTTY), + .resolve = RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS, + }; + int fd; + + do + fd = syscall (SYS_openat2, root, path[0] ? path : ".", &how, sizeof how); + while (fd < 0 && (errno == EINTR || errno == EAGAIN)); + return fd; +} + +static void +single_pivot_check_target (const SinglePivot *state, int target, + const char *src_display, const char *dest_display) +{ + struct stat target_st, root_st; + + if (fstat (target, &target_st) < 0 || fstat (state->root_fd, &root_st) < 0) + die_with_error ("single-pivot: stat mount target %s", dest_display); + /* Covering the retained root descriptor would make later lookups use the + * covered tree. Reject all aliases, including /., // and parent symlinks. */ + if (target_st.st_dev == root_st.st_dev && target_st.st_ino == root_st.st_ino) + die ("single-pivot does not support mounting %s on / (destination %s)", + src_display, dest_display); +} + +void +single_pivot_mount_filesystem (const SinglePivot *state, const char *type, unsigned attrs, + uint32_t perms, size_t size, int target, const char *dest_display) +{ + single_pivot_check_target (state, target, type, dest_display); + if (!mount_filesystem_fd (type, attrs, perms, size, target, dest_display)) + die_with_error ("single-pivot: mount API unavailable for %s on %s", type, dest_display); +} + +void +single_pivot_bind (const SinglePivot *state, bind_option_t options, + int source, const char *src_display, int target, const char *dest_display) +{ + single_pivot_check_target (state, target, src_display, dest_display); + bind_mount_result result = bind_mount_fd_new (source, target, options | BIND_RECURSIVE); + if (result != BIND_MOUNT_SUCCESS) + die_with_bind_result (result, errno, dest_display, + "single-pivot: Can't bind mount %s on %s", src_display, dest_display); +} + +/* EBADF/EINVAL are expected for these deliberately invalid descriptors. + * Probes must not change the host or sandbox mount tree. An absent syscall + * selects the existing backend before setup starts. Denials and unexpected + * errors are fatal: never retry a failed mount operation with weaker rules. */ +static bool +single_pivot_probe (int result, const char *name) +{ + if (result < 0 && errno == ENOSYS) + return false; + if (result < 0 && (errno == EBADF || errno == EINVAL)) + return true; + die_with_error ("single-pivot: probe %s", name); +} + +int +single_pivot_prepare (void) +{ + cleanup_fd int host = open ("/", O_PATH | O_DIRECTORY | O_CLOEXEC); + + if (host < 0) + die_with_error ("single-pivot: probe host root"); + cleanup_fd int lookup = single_pivot_openat2 (host, "/", O_PATH | O_DIRECTORY); + if (lookup < 0) + { + if (errno == ENOSYS) + return -1; + die_with_error ("single-pivot: probe openat2"); + } + cleanup_fd int tree = create_detached_mount ("tmpfs", MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV, + 0755, 0, "/"); + if (tree < 0) + return -1; + struct mount_attr attr = { .attr_set = MOUNT_ATTR_NOSUID }; + bool present = + single_pivot_probe (open_tree (-1, "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | + AT_EMPTY_PATH | AT_RECURSIVE), "open_tree") && + single_pivot_probe (mount_setattr_wrapper (-1, "", AT_EMPTY_PATH | AT_RECURSIVE, + &attr, sizeof attr), "mount_setattr") && + single_pivot_probe (move_mount (-1, "", -1, "", MOVE_MOUNT_F_EMPTY_PATH | + MOVE_MOUNT_T_EMPTY_PATH), "move_mount"); + if (!present) + return -1; + + /* Syscall presence is insufficient: older kernels reject cloning detached + * mounts and attaching a mount to a detached target. Test both operations + * on disposable clones, retaining the untouched tmpfs as the real root. + * No user setup has run, so unsupported semantics can still select legacy. + * Only known unsupported errors from these valid probes allow fallback; + * permission denials and all failures after selection remain fatal. */ + cleanup_fd int clone = open_tree (tree, "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | + AT_EMPTY_PATH | AT_RECURSIVE); + if (clone < 0) + { + if (errno == EINVAL || errno == ENOSYS) + return -1; + die_with_error ("single-pivot: functional probe open_tree"); + } + cleanup_fd int target = open_tree (tree, "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | + AT_EMPTY_PATH | AT_RECURSIVE); + if (target < 0) + die_with_error ("single-pivot: functional probe target clone"); + if (move_mount (clone, "", target, "", + MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_T_EMPTY_PATH) < 0) + { + if (errno == EINVAL || errno == ENOSYS) + return -1; + die_with_error ("single-pivot: functional probe move_mount"); + } + int root_fd = tree; + tree = -1; + return root_fd; +} + +void +single_pivot_enter (SinglePivot *state, const char *base_path) +{ + if (move_mount (state->root_fd, "", AT_FDCWD, base_path, + MOVE_MOUNT_F_EMPTY_PATH) < 0) + die_with_error ("single-pivot: attach final root at %s", base_path); + if (fchdir (state->root_fd) < 0) + die_with_error ("single-pivot: enter final root"); + if (pivot_root (".", ".") < 0) + die_with_error ("single-pivot: pivot_root"); + if (fchdir (state->host_fd) < 0) + die_with_error ("single-pivot: enter old root"); + if (umount2 (".", MNT_DETACH) < 0) + die_with_error ("single-pivot: detach old root"); + if (chdir ("/") < 0) + die_with_error ("single-pivot: return to final root"); + close (state->host_fd); + close (state->root_fd); + state->host_fd = state->root_fd = -1; +} +#endif /* HAVE_DETACHED_MOUNTS */ diff --git a/newroot-mount.h b/newroot-mount.h new file mode 100644 index 00000000..12122e82 --- /dev/null +++ b/newroot-mount.h @@ -0,0 +1,44 @@ +/* Detached mount backend for supported layouts on modern kernels. + * SPDX-License-Identifier: LGPL-2.0-or-later */ +#pragma once + +#include + +#include "bind-mount.h" + +#ifdef HAVE_DETACHED_MOUNTS +/* The caller owns both descriptors and initializes them to -1. */ +typedef struct +{ + int root_fd; + int host_fd; +} SinglePivot; + +/* Return an owned, empty root descriptor, or -1 if the kernel lacks support. + * Permission errors and unexpected failures are fatal. Does not alter the + * caller's mount tree; layout eligibility must be checked by the caller. */ +int single_pivot_prepare (void); + +int single_pivot_openat2 (int root, + const char *path, + int flags); +void single_pivot_bind (const SinglePivot *state, + bind_option_t options, + int source, + const char *src_display, + int target, + const char *dest_display); +void single_pivot_mount_filesystem (const SinglePivot *state, + const char *type, + unsigned attrs, + uint32_t perms, + size_t size, + int target, + const char *dest_display); + +/* Attach and enter the prepared root, detach the host root, and close both + * owned descriptors. The host root must have been made recursively slave + * before assembling bind mounts. */ +void single_pivot_enter (SinglePivot *state, + const char *base_path); +#endif /* HAVE_DETACHED_MOUNTS */ diff --git a/tests/meson.build b/tests/meson.build index fe45af0a..30c56d31 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -71,3 +71,21 @@ foreach test_script : test_scripts ) endif endforeach + +# Fault injection asserts use of the descriptor mount implementation. +if have_fd_mounts + test_env.set('BWRAP_TEST_FILESYSTEM_MOUNTS', have_filesystem_mounts ? '1' : '0') + if meson.version().version_compare('>=0.50.0') + test('test-mount-api.py', python, args : [files('test-mount-api.py')], + env : test_env, protocol : 'tap', timeout : 120) + else + test('test-mount-api.py', python, args : [files('test-mount-api.py')], + env : test_env, timeout : 120) + endif +endif + +# This script uses normal exit-status reporting, rather than TAP. +if have_detached_mounts + test('test-single-pivot', python, args : [files('test-single-pivot.py')], + env : test_env, timeout : 120) +endif diff --git a/tests/test-mount-api.py b/tests/test-mount-api.py new file mode 100644 index 00000000..e95db6cc --- /dev/null +++ b/tests/test-mount-api.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.0-or-later +"""Exercise mount API selection and failure handling before sandbox exec.""" + +import ctypes +import ctypes.util +import errno +import importlib.util +import os +import subprocess +import sys +import unittest + +try: + import seccomp +except ImportError: + print('1..0 # SKIP cannot import seccomp Python module') + sys.exit(0) + +_spec = importlib.util.spec_from_file_location( + 'test_helper', os.path.join(os.path.dirname(__file__), 'test-helper.py')) +_helper = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_helper) + + +def mount_apis_available(): + libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) + for name in ('open_tree', 'mount_setattr', 'move_mount'): + number = seccomp.resolve_syscall(seccomp.Arch.NATIVE, name) + # Invalid arguments cannot create or attach a mount. Other errors, + # including EPERM before entering a user namespace, show presence. + ctypes.set_errno(0) + result = libc.syscall(number, -1, ctypes.c_void_p(), -1, + ctypes.c_void_p(), 0, 0) + if result < 0 and ctypes.get_errno() == errno.ENOSYS: + return False + return True + + +@unittest.skipUnless(_helper.can_run_bwrap(), 'bwrap not functional') +@unittest.skipUnless(mount_apis_available(), 'kernel lacks descriptor mount APIs') +class TestMountApi(unittest.TestCase): + def run_filtered(self, syscall, error, *extra, + mounts=('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp'), + match=()): + def load_filter(): + policy = seccomp.SyscallFilter(defaction=seccomp.ALLOW) + policy.add_rule(seccomp.ERRNO(error), syscall, *match) + policy.load() + + # A root overmount keeps this fixture on the original root lifecycle. + return subprocess.run( + [_helper.BWRAP, '--unshare-user', '--unshare-pid', + '--ro-bind', '/', '/', *mounts, *extra, '--', sys.executable, '-c', 'pass'], + preexec_fn=load_filter, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=20) + + def test_absent_bind_syscalls(self): + for syscall in ('open_tree', 'mount_setattr', 'move_mount'): + with self.subTest(syscall=syscall): + result = self.run_filtered(syscall, errno.ENOSYS) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_denied_bind_syscalls(self): + for syscall in ('open_tree', 'mount_setattr', 'move_mount'): + with self.subTest(syscall=syscall): + result = self.run_filtered(syscall, errno.EPERM) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b'Operation not permitted', result.stderr) + + def test_unsupported_clone(self): + result = self.run_filtered('open_tree', errno.EINVAL) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_invalid_attach_is_fatal(self): + result = self.run_filtered('move_mount', errno.EINVAL) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b'Invalid argument', result.stderr) + + def test_forced_fallback(self): + for syscall in ('open_tree', 'move_mount'): + with self.subTest(syscall=syscall): + result = self.run_filtered(syscall, errno.EPERM, + '--debug-opt=force-mount-setattr-fallback') + self.assertEqual(result.returncode, 0, result.stderr) + + @unittest.skipUnless(os.environ.get('BWRAP_TEST_FILESYSTEM_MOUNTS') == '1', + 'filesystem mount APIs not compiled in') + def test_absent_filesystem_syscalls(self): + for syscall in ('fsopen', 'fsconfig', 'fsmount'): + with self.subTest(syscall=syscall): + result = self.run_filtered(syscall, errno.ENOSYS) + self.assertEqual(result.returncode, 0, result.stderr) + + @unittest.skipUnless(os.environ.get('BWRAP_TEST_FILESYSTEM_MOUNTS') == '1', + 'filesystem mount APIs not compiled in') + def test_denied_filesystem_syscalls(self): + for syscall in ('fsopen', 'fsconfig', 'fsmount'): + with self.subTest(syscall=syscall): + result = self.run_filtered(syscall, errno.EPERM) + self.assertNotEqual(result.returncode, 0) + self.assertIn(syscall.encode(), result.stderr) + self.assertIn(b'tmpfs', result.stderr) + self.assertIn(b'Operation not permitted', result.stderr) + + @unittest.skipUnless(os.environ.get('BWRAP_TEST_FILESYSTEM_MOUNTS') == '1', + 'filesystem mount APIs not compiled in') + def test_proc_syscalls(self): + for syscall in ('fsopen', 'fsconfig', 'fsmount'): + for error in (errno.ENOSYS, errno.EPERM): + with self.subTest(syscall=syscall, error=error): + result = self.run_filtered(syscall, error, mounts=('--proc', '/proc')) + if error == errno.ENOSYS: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn(b'proc', result.stderr) + self.assertIn(syscall.encode(), result.stderr) + + @unittest.skipUnless(os.environ.get('BWRAP_TEST_FILESYSTEM_MOUNTS') == '1', + 'filesystem mount APIs not compiled in') + def test_devpts_syscalls(self): + # Only devpts uses FSCONFIG_SET_FLAG (newinstance), so the tmpfs + # creation for /dev succeeds before this injected failure. + for error in (errno.ENOSYS, errno.EPERM): + with self.subTest(error=error): + result = self.run_filtered('fsconfig', error, + match=(seccomp.Arg(1, seccomp.EQ, 0),)) + if error == errno.ENOSYS: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn(b'fsconfig newinstance for devpts on /dev/pts', result.stderr) + + +if __name__ == '__main__': + _helper.run_tap_tests(sys.modules[__name__]) diff --git a/tests/test-sandbox.py b/tests/test-sandbox.py index a266b8cd..aaecff74 100644 --- a/tests/test-sandbox.py +++ b/tests/test-sandbox.py @@ -7,8 +7,6 @@ # the @sandbox_test_class decorator auto-generates the host-side # test_* methods that launch bwrap and run them. -import ctypes -import ctypes.util import importlib.util import os import stat @@ -112,21 +110,16 @@ def assertFileContent(self, path, expected): with open(path) as f: self.assertEqual(f.read(), expected) - _libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True) - TMPFS_MAGIC = 0x01021994 - - def _statfs_type(self, path): - class statfs_t(ctypes.Structure): - _fields_ = [('f_type', ctypes.c_long), ('f_bsize', ctypes.c_long)] - buf = statfs_t() - rc = self._libc.statfs(path.encode(), ctypes.byref(buf)) - self.assertEqual(rc, 0, f'statfs({path}) failed') - return buf.f_type - def assertIsTmpfs(self, path): - fs_type = self._statfs_type(path) - self.assertEqual(fs_type, self.TMPFS_MAGIC, - f'{path} is not tmpfs (f_type=0x{fs_type:x})') + # Avoid a ctypes statfs buffer: libc writes the entire structure, + # whose layout depends on the architecture, even if we only need type. + filesystems = {} + with open('/proc/self/mounts') as mounts: + for line in mounts: + fields = line.split() + filesystems[fields[1]] = fields[2] + self.assertEqual(filesystems.get(path), 'tmpfs', + f'{path} is not a tmpfs mount') def assertIsMountpoint(self, path): self.assertIn(path, list_mounts(), @@ -368,6 +361,12 @@ def test_proc(self): self.assertFalse(os.access(path, os.W_OK), f'/proc/{subdir} should not be writable') + @in_sandbox('--unshare-pid') + def test_proc_new_pid_namespace(self): + self.assertMountFlags('/proc', + [os.ST_NOSUID, os.ST_NODEV, os.ST_NOEXEC], []) + self.assertIsRegFile('/proc/self/status') + # ------ dev ------ @in_sandbox() @@ -378,6 +377,14 @@ def test_dev_nodes(self): self.assertIsSymlink(f'/dev/{link}') self.assertIsDir('/dev/pts') self.assertIsDir('/dev/shm') + self.assertMountFlags('/dev/pts', [os.ST_NOSUID, os.ST_NOEXEC], [os.ST_NODEV]) + self.assertEqual(stat.S_IMODE(os.stat('/dev/pts/ptmx').st_mode), 0o666) + master, slave = os.openpty() + try: + self.assertEqual(stat.S_IMODE(os.fstat(slave).st_mode), 0o620) + finally: + os.close(master) + os.close(slave) # ------ dir ------ diff --git a/tests/test-single-pivot.py b/tests/test-single-pivot.py new file mode 100644 index 00000000..bc62fd39 --- /dev/null +++ b/tests/test-single-pivot.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.0-or-later +"""Exercise automatic detached mounts and legacy selection in private namespaces. + +Run with BWRAP=/absolute/path/to/bwrap python3 tests/test-single-pivot.py. +The test seccomp filter is deliberately allow-all: mount policy, capability +and descriptor checks are separate from the existing seccomp tests. +""" +import os + +# Capture inherited descriptors before ctypes/libffi opens runtime-owned fds. +# listdir closes its directory fd before returning; ignore that stale entry. +INHERITED_FDS = {name: os.readlink('/proc/self/fd/' + name) + for name in os.listdir('/proc/self/fd') + if int(name) > 2 and os.path.exists('/proc/self/fd/' + name)} + +import ctypes +import contextlib +import errno +import json +from pathlib import Path +import platform +import select +import shutil +import stat +import struct +import subprocess +import sys +import tempfile + +sys.dont_write_bytecode = True +LIBC = ctypes.CDLL(None, use_errno=True) +SCRIPT = Path(__file__).resolve() +MS_RDONLY, MS_NOSUID, MS_NODEV, MS_NOEXEC = 1, 2, 4, 8 +# These syscall numbers are shared by the two architectures supported below. +SYSCALLS = { + "open_tree": 428, + "move_mount": 429, + "fsopen": 430, + "fsconfig": 431, + "fsmount": 432, + "openat2": 437, + "mount_setattr": 442, +} + + +def mount(source, target, flags, kind='tmpfs', data='mode=0700'): + args = [x.encode() if x is not None else None for x in (source, str(target), kind, data)] + if LIBC.mount(args[0], args[1], args[2], ctypes.c_ulong(flags), args[3]) != 0: + raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()), str(target)) + + +def inspect_sandbox(): + expected = json.loads(sys.argv[2]) + if 'cwd' in expected: + assert os.getcwd() == expected['cwd'], os.getcwd() + def options(path): + for line in Path('/proc/self/mountinfo').read_text().splitlines(): + fields = line.split() + if fields[4] == path: + return set(fields[5].split(',')) + raise AssertionError(f'not a mount point: {path}') + for path, required in expected['mounts'].items(): + actual = options(path) + assert set(required) <= actual, (path, required, actual) + for path, writable in expected.get('writes', {}).items(): + try: + Path(path).write_text('child') + except OSError as error: + assert not writable and error.errno in (errno.EROFS, errno.EACCES, errno.EPERM), (path, error) + else: + assert writable, f'unexpected writable path: {path}' + if 'device' in expected: + try: + with open('/tmp/probe-device', 'wb') as stream: + stream.write(b'test') + except OSError as error: + assert not expected['device'] and error.errno in (errno.EACCES, errno.EPERM, errno.EROFS), error + else: + assert expected['device'], 'nodev did not block device access' + + for path, expected_mode in expected.get('modes', {}).items(): + assert stat.S_IMODE(os.stat(path).st_mode) == expected_mode, path + for path, expected_size in expected.get('sizes', {}).items(): + filesystem = os.statvfs(path) + assert filesystem.f_blocks * filesystem.f_frsize == expected_size, path + try: + Path(path, 'too-large').write_bytes(b'x' * (expected_size * 2)) + except OSError as error: + assert error.errno == errno.ENOSPC, (path, error) + else: + raise AssertionError(f'tmpfs size limit not enforced: {path}') + + +def drop_caps(): + # The fixture needs mount privileges in its private namespace, but bwrap + # must start with ordinary unprivileged credentials. + assert LIBC.prctl(47, 4, 0, 0, 0) == 0 # PR_CAP_AMBIENT_CLEAR_ALL + header = (ctypes.c_uint32 * 2)(0x20080522, 0) + data = (ctypes.c_uint32 * 6)() + assert LIBC.capset(header, data) == 0 + + +def deny(number, error, valid_fd=False, devpts=False): + assert platform.machine() in ("x86_64", "aarch64") + class Filter(ctypes.Structure): + _fields_ = [("code", ctypes.c_ushort), ("jt", ctypes.c_ubyte), + ("jf", ctypes.c_ubyte), ("k", ctypes.c_uint)] + class Program(ctypes.Structure): + _fields_ = [("len", ctypes.c_ushort), ("filter", ctypes.POINTER(Filter))] + instructions = (Filter * 4)(Filter(0x20, 0, 0, 0), Filter(0x15, 0, 1, number), + Filter(0x06, 0, 0, 0x50000 | error), + Filter(0x06, 0, 0, 0x7fff0000)) + if valid_fd: + instructions = (Filter * 6)( + Filter(0x20, 0, 0, 0), Filter(0x15, 0, 3, number), + Filter(0x20, 0, 0, 16), Filter(0x15, 1, 0, 0xffffffff), + Filter(0x06, 0, 0, 0x50000 | error), Filter(0x06, 0, 0, 0x7fff0000)) + if devpts: + instructions = (Filter * 6)( + Filter(0x20, 0, 0, 0), Filter(0x15, 0, 3, number), + Filter(0x20, 0, 0, 24), Filter(0x15, 0, 1, 0), + Filter(0x06, 0, 0, 0x50000 | error), Filter(0x06, 0, 0, 0x7fff0000)) + program = Program(len(instructions), instructions) + assert LIBC.prctl(38, 1, 0, 0, 0) == 0 + assert LIBC.prctl(22, 2, ctypes.byref(program), 0, 0) == 0 + + +def detached_mounts_supported(): + """Distinguish syscall presence from detached-tree semantics in this fixture.""" + fds = [] + def keep(fd, operation): + if fd < 0: + raise OSError(ctypes.get_errno(), operation) + fds.append(fd) + return fd + try: + context = keep(LIBC.fsopen(b"tmpfs", 1), "fsopen") + if LIBC.fsconfig(context, 6, None, None, 0) < 0: # FSCONFIG_CMD_CREATE + raise OSError(ctypes.get_errno(), "fsconfig") + tree = keep(LIBC.fsmount(context, 1, 2 | 4), "fsmount") + # OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | AT_EMPTY_PATH | AT_RECURSIVE + flags = 1 | os.O_CLOEXEC | 0x1000 | 0x8000 + clone = LIBC.open_tree(tree, b"", flags) + if clone < 0 and ctypes.get_errno() in (errno.EINVAL, errno.ENOSYS): + return False + keep(clone, "open_tree") + target = keep(LIBC.open_tree(tree, b"", flags), "open_tree target") + if LIBC.move_mount(clone, b"", target, b"", 0x4 | 0x40) < 0: + if ctypes.get_errno() in (errno.EINVAL, errno.ENOSYS): + return False + raise OSError(ctypes.get_errno(), "move_mount") + return True + finally: + for fd in reversed(fds): + os.close(fd) + + +def main(): + if len(sys.argv) == 1: + sys.argv.append(os.environ.get("BWRAP", "bwrap")) + if sys.argv[1] in ("--deny", "--deny-valid-fd", "--deny-devpts"): + deny(int(sys.argv[2]), int(sys.argv[3]), sys.argv[1] == "--deny-valid-fd", + sys.argv[1] == "--deny-devpts") + os.execv(sys.argv[4], sys.argv[4:]) + if sys.argv[1] == "--inspect": + inspect_sandbox() + assert not Path("/tmp/input/sub/hidden").exists() + assert not Path("/oldroot").exists() and not Path("/newroot").exists() + for name in ("null", "zero", "full", "random", "urandom", "tty"): + assert Path("/dev", name).is_char_device(), name + with open("/dev/null", "wb") as output: + output.write(b"device works") + os.close(os.open("/dev/ptmx", os.O_RDWR | os.O_NOCTTY)) + status = Path("/proc/self/status").read_text() + for line in status.splitlines(): + if line.startswith(("CapPrm:", "CapEff:", "CapAmb:")): + assert int(line.split()[1], 16) == 0, line + assert not os.read(0, 1), "unexpected stdin data" + assert not INHERITED_FDS, f"setup descriptors leaked: {INHERITED_FDS}" + Path("/tmp/private-output").write_text("private") + return + if sys.argv[1] != "--inside": + if os.getuid() == 0 or platform.machine() not in ("x86_64", "aarch64"): + print("SKIP: requires unprivileged x86_64/aarch64 user namespaces") + sys.exit(77) + helper = str(Path(shutil.which(sys.argv[1]) or sys.argv[1]).resolve(strict=True)) + namespace = ["unshare", "--user", "--map-current-user", "--keep-caps", "--mount", + "--propagation", "private"] + probe = subprocess.run([*namespace, sys.executable, "-c", "pass"], capture_output=True) + if probe.returncode != 0: + print("SKIP: fixture namespaces unavailable:", probe.stderr.decode(errors="replace").strip()) + sys.exit(77) + result = subprocess.run([*namespace, sys.executable, str(SCRIPT), + "--inside", helper]) + sys.exit(result.returncode) + helper = sys.argv[2] + # An old kernel can legitimately select legacy mounts for every layout. + # Probe only invalid arguments here; no mount can be created by this check. + for number in SYSCALLS.values(): + ctypes.set_errno(0) + result = LIBC.syscall(number, -1, ctypes.c_void_p(), -1, ctypes.c_void_p(), 0, 0) + if result < 0 and ctypes.get_errno() == errno.ENOSYS: + print("SKIP: kernel lacks a syscall needed for detached mounts") + sys.exit(77) + detached_supported = detached_mounts_supported() + with tempfile.TemporaryDirectory(prefix="bwrap-single-pivot-check-") as work, contextlib.ExitStack() as mounts: + root = Path(work) + source = root / "source" + source.mkdir() + mount("tmpfs", source, 2 | 4 | 8 | 1024) + mounts.callback(LIBC.umount2, str(source).encode(), 2) + (source / "file").write_text("source") + (source / "sub").mkdir() + (source / "sub/hidden").write_text("must remain covered") + mount("tmpfs", source / "sub", 1 << 21) + mounts.callback(LIBC.umount2, str(source / "sub").encode(), 2) + (source / "sub/file").write_text("nested") + (source / "locked").mkdir() + mount("tmpfs", source / "locked", 2 | 4 | 8 | 1024) + mounts.callback(LIBC.umount2, str(source / "locked").encode(), 2) + (source / "locked/file").write_text("readonly") + mount(None, source / "locked", 32 | 1 | 2 | 4 | 8 | 1024) + policy = root / "filter" + policy.write_bytes(struct.pack("=HBBI", 6, 0, 0, 0x7fff0000)) + base = [helper, "--unshare-user", "--unshare-pid", "--unshare-net", "--unshare-ipc", + "--unshare-uts", "--die-with-parent", "--clearenv", "--proc", "/proc", + "--dev", "/dev", "--tmpfs", "/tmp"] + # Bind only the runtime paths needed by this Python interpreter, not /. + # Cover both conventional distro layouts and Nix-based test hosts. + runtime = [] + for path in ("/nix/store", "/usr", "/bin", "/lib", "/lib64", "/etc/ld.so.cache"): + if Path(path).exists(): + runtime += ["--ro-bind", path, path] + checks = 0 + + def run(name, kind="--ro-bind", *, prefix=(), before=(), extra=(), + destination="/tmp/input", error=None, expectations=None, + runtime_binds=runtime, launch_cwd=None): + nonlocal checks + expected = {"mounts": { + "/tmp/input": ["nosuid", "nodev", "noexec", "noatime", "ro" if kind == "--ro-bind" else "rw"], + "/tmp/input/sub": ["nosuid", "relatime", "ro" if kind == "--ro-bind" else "rw"] + + ([] if kind == "--dev-bind" else ["nodev"]), + "/tmp/input/locked": ["ro", "nosuid", "nodev", "noexec", "noatime"], + "/proc": ["nosuid", "nodev", "noexec"], + "/dev/pts": ["nosuid", "noexec"], + "/tmp": ["nosuid", "nodev"], + }, "writes": {"/tmp/input/file": kind != "--ro-bind", + "/tmp/input/sub/file": kind != "--ro-bind", + "/tmp/input/locked/file": False}} + expected.update(expectations or {}) + late = [*runtime_binds, "--ro-bind", str(SCRIPT), "/tmp/test.py", + kind, str(source), destination, "--", sys.executable, + "/tmp/test.py", "--inspect", json.dumps(expected)] + fd = os.open(policy, os.O_RDONLY | os.O_CLOEXEC) + try: + args = [*prefix, base[0], *before, *base[1:], *extra, "--seccomp", str(fd), *late] + result = subprocess.run(args, input=b"", capture_output=True, timeout=20, + preexec_fn=drop_caps, pass_fds=(fd,), cwd=launch_cwd) + finally: + os.close(fd) + if error is not None: + assert result.returncode != 0, (name, result) + assert error in result.stderr, (name, result.stderr) + else: + assert result.returncode == 0 and result.stdout == b"", (name, result.stderr) + checks += 1 + print(f"ok {checks} - {name}", flush=True) + (source / "file").write_text("host still writable") + (source / "sub/file").write_text("nested host still writable") + + for kind in ("--bind", "--ro-bind", "--dev-bind"): + run(f"mount policy {kind}", kind) + run("parent symlink uses legacy", extra=("--symlink", "/", "/alias"), + destination="/alias/tmp/input") + run("reject symlink destination", "--bind", + extra=("--dir", "/tmp/real", "--symlink", "real", "/tmp/link"), + destination="/tmp/link", error=b"symlink destination") + + # Check device access and tmpfs options with both setup paths. + for fallback in (False, True): + extra = ("--debug-opt=force-mount-setattr-fallback",) if fallback else () + label = "forced legacy" if fallback else "automatic" + for kind in ("--bind", "--dev-bind"): + run(f"device access {kind}, {label}", + extra=(*extra, kind, "/dev/null", "/tmp/probe-device"), + expectations={"device": kind == "--dev-bind"}) + run(f"tmpfs size and permissions, {label}", + extra=(*extra, "--perms", "0700", "--size", "1048576", "--tmpfs", "/tmp/sized"), + expectations={"modes": {"/tmp/sized": 0o700}, + "sizes": {"/tmp/sized": 1048576}}) + run(f"preserve working directory below /tmp, {label}", + extra=(*extra, "--bind", str(source), str(source)), + expectations={"cwd": str(source)}, launch_cwd=str(source)) + + def denied_call(name, error, valid_fd=False): + mode = "--deny-valid-fd" if valid_fd else "--deny" + return (sys.executable, str(SCRIPT), mode, str(SYSCALLS[name]), str(error)) + + # An absent syscall selects legacy; a permission denial must stop setup. + for syscall in SYSCALLS: + for error in (errno.EPERM, errno.ENOSYS): + run(f"{syscall} returns {errno.errorcode[error]}", + prefix=denied_call(syscall, error), + error=None if error == errno.ENOSYS else syscall.encode()) + + # Permit invalid-FD presence probes, deny calls with real descriptors. + # Filesystem creation and clone/attach probes precede selection. + # Older kernels can fall back before reaching these operations. + if detached_supported: + run("bind failure reports source and destination", + prefix=denied_call("mount_setattr", errno.EPERM, valid_fd=True), + before=("--ro-bind", str(source), "/input"), + error=f"Can't bind mount {source} on /input".encode()) + for syscall in ("open_tree", "move_mount", "fsconfig", "fsmount", "mount_setattr"): + for error in (errno.EPERM, errno.ENOSYS): + fallback = syscall != "mount_setattr" and error == errno.ENOSYS + run(f"{syscall} with valid fd returns {errno.errorcode[error]}", + prefix=denied_call(syscall, error, valid_fd=True), + error=None if fallback else (b"single-pivot" if error == errno.ENOSYS else syscall.encode())) + + if detached_supported: + for error in (errno.ENOSYS, errno.EPERM): + run(f"devpts failure after selection is fatal: {errno.errorcode[error]}", + prefix=(sys.executable, str(SCRIPT), "--deny-devpts", + str(SYSCALLS["fsconfig"]), str(error)), + error=b"devpts") + + run("unsupported detached open_tree selects legacy", + prefix=denied_call("open_tree", errno.EINVAL, valid_fd=True)) + # The original root lifecycle also uses move_mount for binds. A filter + # denying every attach must remain fatal after the probe falls back. + run("invalid attach remains fatal after detached probe fallback", + prefix=denied_call("move_mount", errno.EINVAL, valid_fd=True), + error=b"Invalid argument") + + # Unsupported layouts retain the original root lifecycle, which can + # now use the descriptor mount APIs independently of single pivot. + for dest in ("/", "//", "/.", "/tmp/.."): + # The root bind already includes the runtime. Binding it again + # would target symlinks such as /bin on distributions using /usr/bin. + run(f"root overmount {dest} selects legacy", + before=("--ro-bind", "/", dest), runtime_binds=()) + for option in ("force-openat-fallback", "force-mount-setattr-fallback"): + run(f"{option} selects legacy", extra=("--debug-opt=" + option,)) + + tracer = shutil.which("strace") + if tracer: + for fallback in (False, True): + trace = root / f"pivot-{fallback}.trace" + extra = ("--debug-opt=force-mount-setattr-fallback",) if fallback else () + run(f"root transition count, forced fallback={fallback}", extra=extra, + prefix=(tracer, "-f", "-e", "trace=pivot_root", "-o", str(trace))) + expected = 2 if fallback or not detached_supported else 1 + assert trace.read_text().count("pivot_root(") == expected, trace.read_text() + + # New host mounts must reach both shared and slave bind sources, + # including shared submounts cloned recursively with their parent. + for slave in (False, True): + for backend in ("single-pivot", "bind-fd", "legacy"): + fallback = backend == "legacy" + with contextlib.ExitStack() as propagation_mounts: + shared = root / f"shared-{slave}-{backend}" + shared.mkdir() + mount("tmpfs", shared, MS_NOSUID | MS_NODEV) + propagation_mounts.callback(LIBC.umount2, str(shared).encode(), 2) + mount(None, shared, 1 << 20) # MS_SHARED + (shared / "nested").mkdir() + mount("tmpfs", shared / "nested", MS_NOSUID | MS_NODEV) + mount(None, shared / "nested", 1 << 20) + for path in (shared / "late", shared / "nested/late", shared / "local"): + path.mkdir() + (shared / "local/marker").write_text("host") + bind_source = shared + if slave: + bind_source = root / f"slave-{backend}" + bind_source.mkdir() + mount(str(shared), bind_source, 4096 | 16384) # MS_BIND | MS_REC + propagation_mounts.callback(LIBC.umount2, str(bind_source).encode(), 2) + mount(None, bind_source, (1 << 19) | 16384) # MS_SLAVE | MS_REC + extra = ["--debug-opt=force-mount-setattr-fallback"] if fallback else [] + code = ("from pathlib import Path; import sys; " + "assert not Path('/data/local/marker').exists(); " + "print('ready', flush=True); sys.stdin.readline(); " + "assert Path('/data/late/marker').read_text() == 'propagated'; " + "assert Path('/data/nested/late/marker').read_text() == 'propagated'") + source_fd = os.open(bind_source, os.O_PATH | os.O_CLOEXEC) + propagation_mounts.callback(os.close, source_fd) + bind = (["--bind-fd", str(source_fd)] if backend == "bind-fd" + else ["--bind", str(bind_source)]) + args = [*base, *runtime, *extra, *bind, "/data", + "--tmpfs", "/data/local", "--", sys.executable, "-c", code] + with subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, preexec_fn=drop_caps, + pass_fds=(source_fd,)) as child: + try: + assert select.select([child.stdout], [], [], 20)[0], "sandbox startup timed out" + assert child.stdout.readline() == b"ready\n", child.stderr.read() + assert (shared / "local/marker").read_text() == "host", "mount propagated to host" + for path in (shared / "late", shared / "nested/late"): + mount("tmpfs", path, MS_NOSUID | MS_NODEV) + (path / "marker").write_text("propagated") + stdout, stderr = child.communicate(b"go\n", timeout=20) + assert child.returncode == 0, (slave, fallback, stdout, stderr) + finally: + if child.poll() is None: + child.kill() + child.communicate() + checks += 1 + source_kind = "slave" if slave else "shared" + label = backend + print(f"ok {checks} - propagation from {source_kind} source, {label}", flush=True) + print(f"{checks} automatic single-pivot mount checks passed (detached semantics: {detached_supported})") + + +if __name__ == "__main__": + main()