From 6e558d463e9cf93320ddcb81e5ecfd6bb948589c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:40:38 -0500 Subject: [PATCH 1/7] tests: Avoid an undersized statfs buffer in tmpfs assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the filesystem type from /proc/self/mounts instead of letting libc write a complete, architecture-dependent statfs structure into a two-field ctypes buffer. Validation: all six Meson suites pass in Debian trixie with warnings as errors, SELinux, ASan, UBSan and PYTHONMALLOC=debug. Signed-off-by: Domen Kožar --- tests/test-sandbox.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/tests/test-sandbox.py b/tests/test-sandbox.py index a266b8cd..3bd0717b 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(), From 971739322fef88fa22defe56b0365b1f181aed25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:42:59 -0500 Subject: [PATCH 2/7] bind-mount: Clone and restrict bind mounts through file descriptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use open_tree, mount_setattr and move_mount while retaining the existing two-pivot root lifecycle. Apply restrictions to the detached clone without clearing inherited flags. Prefer the original descriptor for bind-fd operations, retaining the existing reopen and identity verification for kernels that reject descriptors from the parent mount namespace. Only unavailable syscalls and unsupported clone operations select the existing implementation; permission and attach errors remain fatal. Keep the explicit mount-setattr fallback and builds without libc support. Validation: all seven Meson suites pass with SELinux, warnings as errors, ASan, UBSan and Python debug allocation. New seccomp tests cover absent, denied and invalid operations and forced fallback. Signed-off-by: Domen Kožar --- bind-mount.c | 73 ++++++++++++++++++++++++++++++++++ bind-mount.h | 8 ++++ bubblewrap.c | 16 ++++++-- meson.build | 10 +++++ tests/meson.build | 11 ++++++ tests/test-mount-api.py | 88 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 tests/test-mount-api.py 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..654a711c 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -1279,9 +1279,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. diff --git a/meson.build b/meson.build index feb3a785..d4370082 100644 --- a/meson.build +++ b/meson.build @@ -99,6 +99,16 @@ 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 + configure_file( output : 'config.h', configuration : cdata, diff --git a/tests/meson.build b/tests/meson.build index fe45af0a..0d07578c 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -71,3 +71,14 @@ foreach test_script : test_scripts ) endif endforeach + +# Fault injection asserts use of the descriptor mount implementation. +if have_fd_mounts + 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 diff --git a/tests/test-mount-api.py b/tests/test-mount-api.py new file mode 100644 index 00000000..3008a7f7 --- /dev/null +++ b/tests/test-mount-api.py @@ -0,0 +1,88 @@ +#!/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): + def load_filter(): + policy = seccomp.SyscallFilter(defaction=seccomp.ALLOW) + policy.add_rule(seccomp.ERRNO(error), syscall) + policy.load() + + # A root overmount keeps this fixture on the original root lifecycle. + return subprocess.run( + [_helper.BWRAP, '--unshare-user', '--unshare-pid', + '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', + '--tmpfs', '/tmp', *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) + + +if __name__ == '__main__': + _helper.run_tap_tests(sys.modules[__name__]) From 5e05cf5775f633ce37d70ea006ac7ab0603fef11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:45:30 -0500 Subject: [PATCH 3/7] mount: Create tmpfs mounts with the file-descriptor API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce shared filesystem creation helpers and use them for unlabelled tmpfs mounts. Preserve mode, size, nosuid and nodev, retaining the existing root lifecycle and SELinux-labelled mount path. Unavailable syscalls select the traditional mount API before attachment; denials remain fatal. Validation: all seven Meson suites pass with SELinux, warnings as errors, ASan, UBSan and Python debug allocation, including tmpfs permissions and size assertions and filesystem syscall fault injection. Signed-off-by: Domen Kožar --- bubblewrap.c | 7 ++++ meson.build | 10 +++++ mount-api.c | 87 +++++++++++++++++++++++++++++++++++++++++ mount-api.h | 15 +++++++ tests/meson.build | 1 + tests/test-mount-api.py | 19 +++++++++ 6 files changed, 139 insertions(+) create mode 100644 mount-api.c create mode 100644 mount-api.h diff --git a/bubblewrap.c b/bubblewrap.c index 654a711c..d947f0a4 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -40,6 +40,7 @@ #include "utils.h" #include "network.h" #include "bind-mount.h" +#include "mount-api.h" #ifndef CLONE_NEWCGROUP #define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */ @@ -969,6 +970,12 @@ 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); + /* 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 diff --git a/meson.build b/meson.build index d4370082..aeb61ec9 100644 --- a/meson.build +++ b/meson.build @@ -109,6 +109,15 @@ 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 + configure_file( output : 'config.h', configuration : cdata, @@ -131,6 +140,7 @@ bwrap = executable( [ 'bubblewrap.c', 'bind-mount.c', + 'mount-api.c', 'network.c', 'utils.c', 'chroot_realpath.c', diff --git a/mount-api.c b/mount-api.c new file mode 100644 index 00000000..6648a593 --- /dev/null +++ b/mount-api.c @@ -0,0 +1,87 @@ +/* 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 (!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/tests/meson.build b/tests/meson.build index 0d07578c..876a3242 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -74,6 +74,7 @@ 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) diff --git a/tests/test-mount-api.py b/tests/test-mount-api.py index 3008a7f7..f4b77f27 100644 --- a/tests/test-mount-api.py +++ b/tests/test-mount-api.py @@ -83,6 +83,25 @@ def test_forced_fallback(self): '--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) + if __name__ == '__main__': _helper.run_tap_tests(sys.modules[__name__]) From 9bc1bebee02e9f4ef0d07af79b2e9b9423339935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:46:39 -0500 Subject: [PATCH 4/7] mount: Create procfs through a filesystem context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the shared descriptor mount helper when creating procfs for a PID namespace. Preserve nosuid, nodev, noexec and the existing masking of sensitive proc entries. Keep host-proc binds and the root lifecycle unchanged. Validation: all seven sanitizer-enabled Meson suites pass. Add proc-specific syscall failure tests and mount flag assertions in a new PID namespace. Signed-off-by: Domen Kožar --- bubblewrap.c | 7 ++++++- tests/test-mount-api.py | 20 +++++++++++++++++--- tests/test-sandbox.py | 6 ++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/bubblewrap.c b/bubblewrap.c index d947f0a4..b0daebc8 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -1405,7 +1405,12 @@ 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; + 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 diff --git a/tests/test-mount-api.py b/tests/test-mount-api.py index f4b77f27..6cd1eafe 100644 --- a/tests/test-mount-api.py +++ b/tests/test-mount-api.py @@ -40,7 +40,8 @@ def mount_apis_available(): @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): + def run_filtered(self, syscall, error, *extra, + mounts=('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp')): def load_filter(): policy = seccomp.SyscallFilter(defaction=seccomp.ALLOW) policy.add_rule(seccomp.ERRNO(error), syscall) @@ -49,8 +50,7 @@ def load_filter(): # A root overmount keeps this fixture on the original root lifecycle. return subprocess.run( [_helper.BWRAP, '--unshare-user', '--unshare-pid', - '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', - '--tmpfs', '/tmp', *extra, '--', sys.executable, '-c', 'pass'], + '--ro-bind', '/', '/', *mounts, *extra, '--', sys.executable, '-c', 'pass'], preexec_fn=load_filter, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) @@ -102,6 +102,20 @@ def test_denied_filesystem_syscalls(self): 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) + if __name__ == '__main__': _helper.run_tap_tests(sys.modules[__name__]) diff --git a/tests/test-sandbox.py b/tests/test-sandbox.py index 3bd0717b..ed009837 100644 --- a/tests/test-sandbox.py +++ b/tests/test-sandbox.py @@ -361,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() From e9666d306ed9e6048f86c79874ceddabbb8dda73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:47:27 -0500 Subject: [PATCH 5/7] mount: Create devpts with explicit filesystem parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set newinstance, ptmxmode=0666 and mode=620 through fsconfig, then attach the nosuid/noexec mount through its descriptor. Retain the existing fallback when a syscall is unavailable. Validation: all seven sanitizer-enabled Meson suites pass. Check PTY allocation, master/slave permissions and mount restrictions, and inject failures specifically into devpts newinstance configuration. Signed-off-by: Domen Kožar --- bubblewrap.c | 9 ++++++++- mount-api.c | 7 +++++++ tests/test-mount-api.py | 20 ++++++++++++++++++-- tests/test-sandbox.py | 8 ++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/bubblewrap.c b/bubblewrap.c index b0daebc8..846c0453 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -1496,7 +1496,14 @@ 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; + 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); } diff --git a/mount-api.c b/mount-api.c index 6648a593..41e8c35b 100644 --- a/mount-api.c +++ b/mount-api.c @@ -43,6 +43,13 @@ create_detached_mount (const char *type, unsigned attrs, uint32_t perms, 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); diff --git a/tests/test-mount-api.py b/tests/test-mount-api.py index 6cd1eafe..e95db6cc 100644 --- a/tests/test-mount-api.py +++ b/tests/test-mount-api.py @@ -41,10 +41,11 @@ def mount_apis_available(): @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')): + mounts=('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp'), + match=()): def load_filter(): policy = seccomp.SyscallFilter(defaction=seccomp.ALLOW) - policy.add_rule(seccomp.ERRNO(error), syscall) + policy.add_rule(seccomp.ERRNO(error), syscall, *match) policy.load() # A root overmount keeps this fixture on the original root lifecycle. @@ -116,6 +117,21 @@ def test_proc_syscalls(self): 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 ed009837..aaecff74 100644 --- a/tests/test-sandbox.py +++ b/tests/test-sandbox.py @@ -377,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 ------ From 0725419772aca9289dee9e77ff7859dd253a2c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:48:13 -0500 Subject: [PATCH 6/7] sandbox: Extract root preparation and entry without changing behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the existing root setup and second pivot into two helpers. Preserve every statement and its order, including cwd capture after the temporary tmpfs mount and before chdir. No backend selection or namespace behavior changes. Validation: both extracted bodies match the original statements byte for byte; all seven Meson suites pass with warnings as errors, SELinux, ASan, UBSan and Python debug allocation. Signed-off-by: Domen Kožar --- bubblewrap.c | 192 ++++++++++++++++++++++++++++----------------------- 1 file changed, 104 insertions(+), 88 deletions(-) diff --git a/bubblewrap.c b/bubblewrap.c index 846c0453..dfd75251 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -858,6 +858,108 @@ drop_privs (bool keep_requested_caps) die_with_error ("can't set dumpable"); } +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) { @@ -2907,7 +3009,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 @@ -3286,98 +3387,13 @@ main (int argc, 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++) - { - 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"); + 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 /"); - } + enter_legacy_root (); if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); From 89088f2bbc1fe85b4a2a5b213d1b37999c037f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 9 Sep 2026 07:55:26 -0500 Subject: [PATCH 7/7] sandbox: Assemble supported roots in a detached mount tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the descriptor bind and filesystem helpers to build eligible layouts before entering the root with one pivot. Retain the two-pivot lifecycle for root overmounts, supplied FDs, unsupported setup operations and unavailable kernel semantics. Probe detached clone and attach behavior before selection. Once selected, unavailable APIs and permission failures remain fatal. Root-relative openat2 lookups reject magic links; root overmount aliases are checked against the retained root descriptor. Preserve host-to-sandbox propagation and close both root descriptors at entry. Validation: all eight Meson suites pass in Debian trixie on Linux 7.0.10 with SELinux, warnings as errors, ASan, UBSan and PYTHONMALLOC=debug. The detached-root suite includes 56 checks covering restrictions, working directories below /tmp, devices, tmpfs limits, FD and capability cleanup, propagation through path and inherited-FD sources, unsupported APIs, post-selection failures and traced pivot counts. All six suites pass with the new APIs compiled out, and all seven applicable suites pass with only filesystem creation compiled out. Fresh matched GCC 15.2 release-build benchmarks against 26bb788 show 13.2-21.3% lower median elapsed time for serial launches and 26.9-34.7% lower elapsed time per sandbox with four concurrent launches across minimal and 3/32/128-bind layouts. Twelve alternating pairs of 32 launches per case include probing and cleanup. Root-overmount controls are 2.5%/0.2% slower; direct-exec controls are 2.2%/2.4% slower. These are host-specific startup and throughput measurements, not workload speedups. Runtime validation also passes in Linux 5.10.269 and 5.15.220 VMs. The 5.10 kernel skips tests requiring mount_setattr; 5.15 runs all eight suites, including 43 detached-root fallback checks. Traces confirm two pivots on both older kernels. Capture inherited FDs before ctypes initializes libffi so runtime-owned library descriptors are not mistaken for setup leaks. Signed-off-by: Domen Kožar --- bubblewrap.c | 168 ++++++++++++++- meson.build | 6 + newroot-mount.c | 158 ++++++++++++++ newroot-mount.h | 44 ++++ tests/meson.build | 6 + tests/test-single-pivot.py | 418 +++++++++++++++++++++++++++++++++++++ 6 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 newroot-mount.c create mode 100644 newroot-mount.h create mode 100644 tests/test-single-pivot.py diff --git a/bubblewrap.c b/bubblewrap.c index dfd75251..66b7cace 100644 --- a/bubblewrap.c +++ b/bubblewrap.c @@ -41,6 +41,7 @@ #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 */ @@ -77,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; @@ -858,6 +863,87 @@ 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) { @@ -963,6 +1049,15 @@ enter_legacy_root (void) 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 */ @@ -1031,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; @@ -1072,6 +1174,14 @@ 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, @@ -1262,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)); } @@ -1508,6 +1627,16 @@ setup_newroot (bool unshare_pid) { /* Our own procfs */ 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); @@ -1599,6 +1728,16 @@ setup_newroot (bool unshare_pid) cleanup_free char *pts_path = fd_to_proc_path (pts_fd); 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"); @@ -3381,19 +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"); - old_cwd = prepare_legacy_root (base_path); +#ifdef HAVE_DETACHED_MOUNTS + if (use_single_pivot) + { + /* 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"); + } + else +#endif + old_cwd = prepare_legacy_root (base_path); setup_newroot (opt_unshare_pid); close_ops_fd (); - enter_legacy_root (); +#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 aeb61ec9..58066a6c 100644 --- a/meson.build +++ b/meson.build @@ -118,6 +118,11 @@ 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, @@ -141,6 +146,7 @@ bwrap = executable( 'bubblewrap.c', 'bind-mount.c', 'mount-api.c', + 'newroot-mount.c', 'network.c', 'utils.c', 'chroot_realpath.c', 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 876a3242..30c56d31 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -83,3 +83,9 @@ if have_fd_mounts 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-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()