diff --git a/man/systemd-system.conf.xml b/man/systemd-system.conf.xml index 9351c6ec52..bb28debbf2 100644 --- a/man/systemd-system.conf.xml +++ b/man/systemd-system.conf.xml @@ -553,6 +553,10 @@ shared libraries (mmap_file), and write-to-execute transitions such as JIT compilation (file_mprotect). + The policy also enforces W^X system-wide: no mapping may be writable and executable at the + same time, and a private file mapping that has been modified through copy-on-write may not be made + executable afterwards. + Since v262, files on overlayfs mounts are resolved to the layer that hosts their data, so execution from an overlay whose data resides on a signed dm-verity device is permitted, while files in a writable upper layer are denied. This requires kernel v7.2 or newer; on older kernels execution from @@ -561,13 +565,11 @@ btrfs are unsupported. Note that, without further measures to secure the system, kexec can be used to circumvent - this. Writable and executable memory mappings can also be used to circumvent this, so they need to - be blocked, which can be achieved using other LSMs, or on a per-service basis by using the - MemoryDenyWriteExecute= setting. + this. This requires the kernel to be booted with dm_verity.require_signatures=1 - on the kernel command line and with BPF LSM enabled (lsm=...,bpf). If either - prerequisite is not met, PID 1 will refuse to complete startup. + and proc_mem.force_override=never on the kernel command line, and with BPF LSM + enabled (lsm=...,bpf). The value yes is equivalent to exec. Additional modes may be added in the future. diff --git a/src/bpf/restrict-fsaccess.bpf.c b/src/bpf/restrict-fsaccess.bpf.c index 97d7009161..512b80e293 100644 --- a/src/bpf/restrict-fsaccess.bpf.c +++ b/src/bpf/restrict-fsaccess.bpf.c @@ -16,8 +16,11 @@ * dm-verity signals signature validity * - bdev_free_security hook: removes devices from the map on teardown * - bprm_check_security: blocks execve() from untrusted sources - * - mmap_file: blocks PROT_EXEC mmap from untrusted sources - * - file_mprotect: blocks W->X transitions from untrusted sources + * - mmap_file: blocks PROT_EXEC mmap from untrusted sources, and + * any writable+executable (W^X-violating) mapping + * - file_mprotect: blocks writable+executable mprotect, and making + * executable a mapping from an untrusted source or + * one that was modified through copy-on-write * * On kernels providing the bpf_real_data_inode() kfunc (v7.2+), files on * union filesystems (overlayfs) are resolved to the layer hosting their @@ -39,6 +42,7 @@ #include #include +#define PROT_WRITE 0x2 #define PROT_EXEC 0x4 #define VM_EXEC 0x00000004 #define PTRACE_MODE_ATTACH 0x02 @@ -218,6 +222,10 @@ SEC("lsm.s/mmap_file") int BPF_PROG(restrict_fsaccess_mmap_file, struct file *file, unsigned long reqprot, unsigned long prot, unsigned long flags) { + /* W^X: a mapping may never be writable and executable at once. */ + if ((prot & PROT_EXEC) && (prot & PROT_WRITE)) + return -EPERM; + /* Only enforce on executable mappings */ if (!(prot & PROT_EXEC)) return 0; @@ -245,17 +253,27 @@ int BPF_PROG(restrict_fsaccess_file_mprotect, struct vm_area_struct *vma, { struct file *file; unsigned long vm_flags; + void *anon_vma; + + /* W^X: a mapping may never be writable and executable at once. */ + if ((prot & PROT_EXEC) && (prot & PROT_WRITE)) + return -EPERM; /* Only enforce when adding PROT_EXEC */ if (!(prot & PROT_EXEC)) return 0; - /* If VM_EXEC is already set, the mapping is already executable — this - * mprotect isn't granting new executable capability, allow */ + /* Already executable so this mprotect() grants no new capability. */ BPF_CORE_READ_INTO(&vm_flags, vma, vm_flags); if (vm_flags & VM_EXEC) return 0; + /* A private file mapping with an anon_vma has been written to + * (copy-on-write), so its pages may no longer match the trusted file. */ + BPF_CORE_READ_INTO(&anon_vma, vma, anon_vma); + if (anon_vma) + return -EPERM; + /* Anonymous executable mapping — no file backing, deny. Direct * dereference, see restrict_fsaccess_bprm_check(). vm_file is in the * verifier's BTF_TYPE_SAFE_TRUSTED_OR_NULL list, so this check is live diff --git a/src/core/bpf-restrict-fsaccess.c b/src/core/bpf-restrict-fsaccess.c index ab746bda05..d0ab080e15 100644 --- a/src/core/bpf-restrict-fsaccess.c +++ b/src/core/bpf-restrict-fsaccess.c @@ -3,7 +3,9 @@ #include #include #include +#include +#include "alloc-util.h" #include "bpf-restrict-fsaccess.h" #include "devnum-util.h" #include "fd-util.h" @@ -13,8 +15,11 @@ #include "lsm-util.h" #include "manager.h" #include "memory-util.h" +#include "proc-cmdline.h" +#include "seccomp-util.h" #include "serialize.h" #include "string-table.h" +#include "string-util.h" /* DMVERITY_DEVICES_MAX lives in bpf-restrict-fsaccess.h for sharing with tests. */ @@ -81,7 +86,7 @@ assert_cc(sizeof_field(typeof_field(struct restrict_fsaccess_bpf, bss[0]), initr [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_GUARD] = (obj)->links.restrict_fsaccess_bpf_guard, \ } -bool dm_verity_require_signatures(void) { +static bool dm_verity_require_signatures(void) { int r; r = read_boolean_file("/sys/module/dm_verity/parameters/require_signatures"); @@ -94,6 +99,129 @@ bool dm_verity_require_signatures(void) { return r > 0; } +/* proc_mem.force_override= is __ro_after_init. We require "never" because "ptrace" still lets a tracer + * rewrite its tracee. */ +static int proc_mem_force_override_restricted(void) { + _cleanup_free_ char *value = NULL; + int r; + + r = proc_cmdline_get_key("proc_mem.force_override", /* flags= */ 0, &value); + if (r <= 0) + return r; + + return streq(value, "never"); +} + +/* Unknown command line options are silently ignored, so verify the kernel really refuses FOLL_FORCE + * writes. Returns > 0 if a write through /proc/self/mem overrode a read-only mapping, 0 if it was refused. */ +static int proc_mem_can_force_override(void) { + _cleanup_close_ int fd = -EBADF; + const uint8_t zero = 0; + ssize_t n; + void *p; + int r; + + p = mmap(/* addr= */ NULL, page_size(), PROT_READ, MAP_PRIVATE|MAP_ANONYMOUS, /* fd= */ -1, /* offset= */ 0); + if (p == MAP_FAILED) + return -errno; + + fd = open("/proc/self/mem", O_RDWR|O_CLOEXEC); + if (fd < 0) { + r = -errno; + goto finish; + } + + /* Without FOLL_FORCE access_remote_vm() copies nothing and mem_rw() fails with EIO. */ + n = pwrite(fd, &zero, sizeof(zero), (off_t) (uintptr_t) p); + if (n < 0) + r = errno == EIO ? 0 : -errno; + else + r = n > 0 ? 1 : -EIO; /* Nothing written and no error is not a refusal, fail closed. */ + +finish: + (void) munmap(p, page_size()); + return r; +} + +/* Both /proc//mem gates: the command line says "never", and the kernel honours it. */ +static int restrict_fsaccess_check_proc_mem(void) { + int r; + + r = proc_mem_force_override_restricted(); + if (r < 0) + return log_error_errno(r, "bpf-restrict-fsaccess: Failed to read the kernel command line: %m"); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), + "bpf-restrict-fsaccess: proc_mem.force_override is not set to \"never\", so " + "/proc//mem can rewrite executable pages and bypass " + "RestrictFileSystemAccess=. Add proc_mem.force_override=never to the kernel " + "command line, or boot with systemd.restrict_filesystem_access=no to disable " + "the policy."); + + r = proc_mem_can_force_override(); + if (r < 0) + return log_error_errno(r, "bpf-restrict-fsaccess: Failed to probe whether /proc/self/mem overrides " + "memory protections: %m"); + if (r > 0) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), + "bpf-restrict-fsaccess: proc_mem.force_override=never is on the kernel command " + "line, but a write through /proc/self/mem still overrides memory protections. " + "The kernel apparently does not support the option (Linux 6.12 or newer is " + "required). Boot with systemd.restrict_filesystem_access=no to disable the " + "policy."); + + return 0; +} + +/* The kernel-side prerequisites of the policy, shared with test-bpf-restrict-fsaccess. */ +int bpf_restrict_fsaccess_check_prerequisites(void) { + int r; + + r = dlopen_bpf(LOG_ERR); + if (r < 0) + return r; + + r = lsm_supported("bpf"); + if (r == -ENOPKG) + return log_error_errno(r, "bpf-restrict-fsaccess: securityfs not mounted, BPF LSM not available."); + if (r < 0) + return log_error_errno(r, "bpf-restrict-fsaccess: Can't determine whether the BPF LSM module is " + "used: %m"); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), + "bpf-restrict-fsaccess: BPF LSM hook not enabled in the kernel, not supported."); + log_debug("bpf-restrict-fsaccess: BPF LSM: supported."); + + if (!dm_verity_require_signatures()) + return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), + "bpf-restrict-fsaccess: dm-verity require_signatures is not enabled. " + "RestrictFileSystemAccess= requires the kernel to enforce dm-verity signatures. " + "Set dm_verity.require_signatures=1 on the kernel command line."); + log_debug("bpf-restrict-fsaccess: dm-verity require_signatures: enabled."); + + return restrict_fsaccess_check_proc_mem(); +} + +/* The seccomp filter survives execve() so ensure that we're not installing another filter. */ +static int restrict_fsaccess_install_ptrace_filter(Manager *m) { + int r; + + assert(m); + + if (m->restrict_fsaccess_ptrace_filter) + return 0; + + r = seccomp_restrict_ptrace(); + if (r == -EOPNOTSUPP) + return log_error_errno(r, "bpf-restrict-fsaccess: seccomp support is not available, cannot block " + "ptrace() code injection."); + if (r < 0) + return log_error_errno(r, "bpf-restrict-fsaccess: Failed to install the ptrace() seccomp filter: %m"); + + m->restrict_fsaccess_ptrace_filter = true; + return 0; +} + static int get_root_s_dev(uint32_t *ret) { struct stat st; @@ -413,29 +541,21 @@ int bpf_restrict_fsaccess_setup(Manager *m) { return r; } + /* A v261 PID 1 neither checked this nor installed the filter. Enforcement is active already, + * so only complain. */ + (void) restrict_fsaccess_check_proc_mem(); + (void) restrict_fsaccess_install_ptrace_filter(m); + return 0; } - /* Fresh setup: verify BPF LSM is available */ - r = dlopen_bpf(LOG_WARNING); + r = bpf_restrict_fsaccess_check_prerequisites(); if (r < 0) return r; - r = lsm_supported("bpf"); - if (r == -ENOPKG) - return log_warning_errno(r, "bpf-restrict-fsaccess: securityfs not mounted, BPF LSM not available."); + r = restrict_fsaccess_install_ptrace_filter(m); if (r < 0) - return log_warning_errno(r, "bpf-restrict-fsaccess: Can't determine whether the BPF LSM module is used: %m"); - if (r == 0) - return log_warning_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), - "bpf-restrict-fsaccess: BPF LSM hook not enabled in the kernel, not supported."); - - /* Require dm-verity signature enforcement */ - if (!dm_verity_require_signatures()) - return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), - "bpf-restrict-fsaccess: dm-verity require_signatures is not enabled. " - "RestrictFileSystemAccess= requires the kernel to enforce dm-verity signatures. " - "Set dm_verity.require_signatures=1 on the kernel command line."); + return r; r = bpf_restrict_fsaccess_prepare(&obj); if (r < 0) @@ -536,7 +656,15 @@ int bpf_restrict_fsaccess_serialize(Manager *m, FILE *f, FDSet *fds) { assert(f); assert(fds); - if (!MANAGER_IS_SYSTEM(m) || m->restrict_filesystem_access <= RESTRICT_FILESYSTEM_ACCESS_NO) + if (!MANAGER_IS_SYSTEM(m)) + return 0; + + /* Process state, not policy state: the filter outlives the setting being turned off. */ + r = serialize_bool_elide(f, "restrict-fsaccess-ptrace-filter", m->restrict_fsaccess_ptrace_filter); + if (r < 0) + return r; + + if (m->restrict_filesystem_access <= RESTRICT_FILESYSTEM_ACCESS_NO) return 0; FOREACH_ELEMENT(fd, m->restrict_fsaccess_link_fds) { @@ -554,8 +682,8 @@ int bpf_restrict_fsaccess_serialize(Manager *m, FILE *f, FDSet *fds) { #else /* ! BPF_FRAMEWORK || ! HAVE_LSM_INTEGRITY_TYPE */ -bool dm_verity_require_signatures(void) { - return false; +int bpf_restrict_fsaccess_check_prerequisites(void) { + return -EOPNOTSUPP; } int bpf_restrict_fsaccess_setup(Manager *m) { diff --git a/src/core/bpf-restrict-fsaccess.h b/src/core/bpf-restrict-fsaccess.h index 0a8cb45c8f..0549e18165 100644 --- a/src/core/bpf-restrict-fsaccess.h +++ b/src/core/bpf-restrict-fsaccess.h @@ -33,7 +33,7 @@ enum { extern const char* const restrict_fsaccess_link_names[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX]; -bool dm_verity_require_signatures(void); +int bpf_restrict_fsaccess_check_prerequisites(void); int bpf_restrict_fsaccess_setup(Manager *m); int bpf_restrict_fsaccess_prepare(struct restrict_fsaccess_bpf **ret); int bpf_restrict_fsaccess_populate_guard(struct restrict_fsaccess_bpf *obj); diff --git a/src/core/manager-serialize.c b/src/core/manager-serialize.c index 7703d63e7a..7a3d879bc3 100644 --- a/src/core/manager-serialize.c +++ b/src/core/manager-serialize.c @@ -519,7 +519,7 @@ static void manager_deserialize_gid_refs_one(Manager *m, const char *value) { static void deserialize_restrict_fsaccess(Manager *m, const char *l, FDSet *fds) { const char *val; - int fd; + int fd, r; FOREACH_ELEMENT(name, restrict_fsaccess_link_names) { val = startswith(l, *name); @@ -537,6 +537,17 @@ static void deserialize_restrict_fsaccess(Manager *m, const char *l, FDSet *fds) return; } + val = startswith(l, "restrict-fsaccess-ptrace-filter="); + if (val) { + r = parse_boolean(val); + if (r < 0) + log_warning_errno(r, "bpf-restrict-fsaccess: Failed to parse ptrace filter state '%s', " + "ignoring: %m", val); + else + m->restrict_fsaccess_ptrace_filter = r; + return; + } + val = startswith(l, "restrict-fsaccess-bss-map="); if (!val) return; diff --git a/src/core/manager.h b/src/core/manager.h index 453ae74668..f3db614b02 100644 --- a/src/core/manager.h +++ b/src/core/manager.h @@ -514,6 +514,9 @@ typedef struct Manager { int restrict_fsaccess_link_fds[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX]; int restrict_fsaccess_bss_map_fd; + /* Whether the ptrace seccomp filter is installed in this process; it survives execve() */ + bool restrict_fsaccess_ptrace_filter; + /* Allow users to configure a rate limit for Reload()/Reexecute() operations */ RateLimit reload_reexec_ratelimit; /* Dump*() are slow, so always rate limit them to 10 per 10 minutes */ diff --git a/src/shared/seccomp-util.c b/src/shared/seccomp-util.c index 0a0471bcb1..c7663417f7 100644 --- a/src/shared/seccomp-util.c +++ b/src/shared/seccomp-util.c @@ -9,6 +9,7 @@ #include #include #include /* IWYU pragma: keep */ +#include #include #include @@ -2152,6 +2153,47 @@ int seccomp_filter_set_add(Hashmap *filter, bool add, const SyscallFilterSet *se return 0; } +/* Block PTRACE_POKE{TEXT,DATA} FOLL_FORCE writes. A missing filter is a bypass, so unlike the other helpers + * this fails when the native architecture cannot be filtered. A secondary architecture the kernel refuses a + * filter for is skipped as everywhere else: it cannot run binaries either. */ +int seccomp_restrict_ptrace(void) { + uint32_t arch; + int r; + + r = dlopen_libseccomp(LOG_DEBUG); + if (r < 0) + return r; + + SECCOMP_FOREACH_LOCAL_ARCH(arch) { + _cleanup_(seccomp_releasep) scmp_filter_ctx seccomp = NULL; + + r = seccomp_init_for_arch(&seccomp, arch, SCMP_ACT_ALLOW); + if (r < 0) + return r; + + r = add_seccomp_syscall_filter(seccomp, arch, SCMP_SYS(ptrace), + 1, SCMP_A0(SCMP_CMP_EQ, PTRACE_POKETEXT)); + if (r < 0) + return r; + + r = add_seccomp_syscall_filter(seccomp, arch, SCMP_SYS(ptrace), + 1, SCMP_A0(SCMP_CMP_EQ, PTRACE_POKEDATA)); + if (r < 0) + return r; + + r = sym_seccomp_load(seccomp); + if (r < 0) { + if (ERRNO_IS_NEG_SECCOMP_FATAL(r) || arch == sym_seccomp_arch_native()) + return log_debug_errno(r, "Failed to apply ptrace restrictions for architecture %s: %m", + seccomp_arch_to_string(arch)); + log_debug_errno(r, "Failed to apply ptrace restrictions for architecture %s, skipping: %m", + seccomp_arch_to_string(arch)); + } + } + + return 0; +} + int seccomp_lock_personality(unsigned long personality) { uint32_t arch; int r; diff --git a/src/shared/seccomp-util.h b/src/shared/seccomp-util.h index 699d819ecc..20f30d88c7 100644 --- a/src/shared/seccomp-util.h +++ b/src/shared/seccomp-util.h @@ -122,6 +122,7 @@ static inline int seccomp_restrict_realtime(void) { return seccomp_restrict_realtime_full(EPERM); } int seccomp_memory_deny_write_execute(void); +int seccomp_restrict_ptrace(void); int seccomp_lock_personality(unsigned long personality); int seccomp_protect_hostname(void); int seccomp_restrict_suid_sgid(void); @@ -166,6 +167,10 @@ static inline bool is_seccomp_available(void) { return false; } +static inline int seccomp_restrict_ptrace(void) { + return -EOPNOTSUPP; +} + #endif int dlopen_libseccomp(int log_level) _dlopen_loader_; diff --git a/src/test/test-bpf-restrict-fsaccess.c b/src/test/test-bpf-restrict-fsaccess.c index 51c8722c68..1cf90507fb 100644 --- a/src/test/test-bpf-restrict-fsaccess.c +++ b/src/test/test-bpf-restrict-fsaccess.c @@ -7,10 +7,21 @@ * test-bpf-restrict-fsaccess attach — Load, attach, print IDs, then block. * Kill the process to detach (synchronous * via bpf_link_put_direct on last FD close). - * test-bpf-restrict-fsaccess check — Check BPF LSM + require_signatures preconditions + * test-bpf-restrict-fsaccess check — Run PID 1's prerequisite checks and install its + * ptrace seccomp filter * test-bpf-restrict-fsaccess mmap-exec PATH — Attempt PROT_READ|PROT_EXEC mmap of PATH * test-bpf-restrict-fsaccess anon-mmap-exec — Attempt anonymous PROT_READ|PROT_EXEC mmap * test-bpf-restrict-fsaccess mprotect-exec PATH — mmap PATH PROT_READ, then mprotect to PROT_EXEC + * test-bpf-restrict-fsaccess mmap-wx PATH — PROT_WRITE|PROT_EXEC mmap of PATH (W^X variant A) + * test-bpf-restrict-fsaccess mprotect-cow-exec PATH — mmap PROT_WRITE, write, mprotect PROT_EXEC (variant B) + * test-bpf-restrict-fsaccess mprotect-wx PATH — mmap PROT_EXEC, then mprotect +PROT_WRITE (variant C) + * test-bpf-restrict-fsaccess procmem-cow PATH — mmap PROT_EXEC, then rewrite it via /proc/self/mem + * test-bpf-restrict-fsaccess poketext — install ptrace seccomp, verify PTRACE_POKE{TEXT,DATA} + * are denied + * + * The probes exit with 0 if the operation was allowed (the bypass works), 1 if + * it was refused with the errno the mechanism under test produces, 2 if it + * could not be attempted and 3 if it was refused with an unexpected errno. * * When "attach" is used, the BPF LSM program is loaded with initramfs_s_dev * set to the current rootfs s_dev, so the calling test script (running from @@ -19,79 +30,320 @@ */ #include +#include #include #include +#include #include +#include #include #include "bpf-link.h" #include "bpf-restrict-fsaccess.h" #include "devnum-util.h" +#include "errno-util.h" #include "fd-util.h" #include "log.h" -#include "lsm-util.h" +#include "pidref.h" +#include "process-util.h" +#include "seccomp-util.h" #include "string-util.h" #include "tests.h" -/* ---- mmap/mprotect probe commands (no BPF dependency) ---- +/* ---- Probe commands (no BPF dependency) ---- * - * These exercise the mmap_file, file_mprotect, and anonymous-mmap LSM hooks. - * The test script copies a file to tmpfs and passes its path here. - * Returns 0 if the operation was allowed, negative errno if denied. */ + * Each attempts one operation the hooks are supposed to deny. The exit codes + * let the test script tell a denial by the mechanism under test from a probe + * that could not run at all. */ +enum { + PROBE_ALLOWED = 0, /* the operation succeeded, i.e. the bypass works */ + PROBE_DENIED = 1, /* refused with the errno the mechanism under test produces */ + PROBE_ERROR = 2, /* the operation could not be attempted */ + PROBE_DENIED_OTHER = 3, /* refused, but with an unexpected errno (another LSM?) */ +}; -static int do_mmap_exec(const char *path) { - _cleanup_close_ int fd = -EBADF; - void *addr; +/* Only the errno the mechanism under test produces counts as a denial. */ +static int probe_denied(int error, int expected) { + assert(error < 0); - fd = open(path, O_RDONLY | O_CLOEXEC); - if (fd < 0) - return log_error_errno(errno, "Failed to open %s: %m", path); + if (error == expected) + return PROBE_DENIED; - addr = mmap(NULL, 4096, PROT_READ | PROT_EXEC, MAP_PRIVATE, fd, 0); - if (addr == MAP_FAILED) - return log_info_errno(errno, "PROT_EXEC mmap of %s denied: %m", path); - - (void) munmap(addr, 4096); - log_info("PROT_EXEC mmap of %s succeeded", path); - return 0; + log_warning("Refused with unexpected error %s, expected %s.", STRERROR(error), STRERROR(expected)); + return PROBE_DENIED_OTHER; } -static int do_anon_mmap_exec(void) { - void *addr; - - addr = mmap(NULL, 4096, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (addr == MAP_FAILED) - return log_info_errno(errno, "Anonymous PROT_EXEC mmap denied: %m"); - - (void) munmap(addr, 4096); - log_info("Anonymous PROT_EXEC mmap succeeded"); - return 0; -} - -static int do_mprotect_exec(const char *path) { +static int mmap_probe(const char *path, int prot, const char *what) { _cleanup_close_ int fd = -EBADF; void *addr; int r; fd = open(path, O_RDONLY | O_CLOEXEC); - if (fd < 0) - return log_error_errno(errno, "Failed to open %s: %m", path); + if (fd < 0) { + log_error_errno(errno, "Failed to open %s: %m", path); + return PROBE_ERROR; + } - addr = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0); - if (addr == MAP_FAILED) - return log_error_errno(errno, "PROT_READ mmap of %s failed: %m", path); - - r = mprotect(addr, 4096, PROT_READ | PROT_EXEC); - if (r < 0) - r = -errno; + addr = mmap(NULL, 4096, prot, MAP_PRIVATE, fd, 0); + if (addr == MAP_FAILED) { + r = log_info_errno(errno, "%s of %s denied: %m", what, path); + return probe_denied(r, -EPERM); + } (void) munmap(addr, 4096); + log_info("%s of %s succeeded", what, path); + return PROBE_ALLOWED; +} +/* Maps PATH with 'prot', then changes the protection to 'new_prot'. With 'poke' the mapping is written to + * first, so that the page is a private copy. */ +static int mprotect_probe(const char *path, int prot, bool poke, int new_prot, const char *what) { + _cleanup_close_ int fd = -EBADF; + void *addr; + int r; + + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + log_error_errno(errno, "Failed to open %s: %m", path); + return PROBE_ERROR; + } + + addr = mmap(NULL, 4096, prot, MAP_PRIVATE, fd, 0); + if (addr == MAP_FAILED) { + log_error_errno(errno, "Initial mmap of %s failed, cannot probe: %m", path); + return PROBE_ERROR; + } + + if (poke) + *(volatile uint8_t*) addr = 0x90; /* trigger copy-on-write */ + + r = RET_NERRNO(mprotect(addr, 4096, new_prot)); + (void) munmap(addr, 4096); + + if (r < 0) { + log_info_errno(r, "%s on %s denied: %m", what, path); + return probe_denied(r, -EPERM); + } + + log_info("%s on %s succeeded", what, path); + return PROBE_ALLOWED; +} + +static int do_mmap_exec(const char *path) { + return mmap_probe(path, PROT_READ | PROT_EXEC, "PROT_EXEC mmap"); +} + +static int do_anon_mmap_exec(void) { + void *addr; + int r; + + addr = mmap(NULL, 4096, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (addr == MAP_FAILED) { + r = log_info_errno(errno, "Anonymous PROT_EXEC mmap denied: %m"); + return probe_denied(r, -EPERM); + } + + (void) munmap(addr, 4096); + log_info("Anonymous PROT_EXEC mmap succeeded"); + return PROBE_ALLOWED; +} + +/* mmap PROT_READ, then add PROT_EXEC: denied for untrusted files, a positive control for trusted ones. */ +static int do_mprotect_exec(const char *path) { + return mprotect_probe(path, PROT_READ, /* poke= */ false, PROT_READ | PROT_EXEC, "mprotect PROT_EXEC"); +} + +/* ---- W^X bypass probes (GHSA-q65q-ggpx-fjgp) ---- + * + * Each tries to get attacker-controlled bytes into an executable page of a + * trusted file. With the W^X hooks in place they must all be denied; an exit + * of PROBE_ALLOWED means the bypass still works. */ + +/* Variant A: a private writable+executable mapping. */ +static int do_mmap_wx(const char *path) { + return mmap_probe(path, PROT_READ | PROT_WRITE | PROT_EXEC, "W+X mmap"); +} + +/* Variant B: map writable, modify via copy-on-write, then turn executable. */ +static int do_mprotect_cow_exec(const char *path) { + return mprotect_probe(path, PROT_READ | PROT_WRITE, /* poke= */ true, PROT_READ | PROT_EXEC, + "mprotect PROT_EXEC after COW write"); +} + +/* Variant C: map executable, then add write. */ +static int do_mprotect_wx(const char *path) { + return mprotect_probe(path, PROT_READ | PROT_EXEC, /* poke= */ false, PROT_READ | PROT_WRITE | PROT_EXEC, + "mprotect PROT_WRITE|PROT_EXEC"); +} + +/* mmap PROT_EXEC read-only (allowed), then rewrite the page through /proc/self/mem, which the kernel only + * services with FOLL_FORCE, i.e. unless booted with proc_mem.force_override=never. */ +static int do_procmem_cow(const char *path) { + _cleanup_close_ int fd = -EBADF, mem_fd = -EBADF; + static const uint8_t byte = 0x90; + void *addr; + ssize_t n; + int r; + + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + log_error_errno(errno, "Failed to open %s: %m", path); + return PROBE_ERROR; + } + + addr = mmap(NULL, 4096, PROT_READ | PROT_EXEC, MAP_PRIVATE, fd, 0); + if (addr == MAP_FAILED) { + log_error_errno(errno, "PROT_EXEC mmap of %s failed, cannot probe: %m", path); + return PROBE_ERROR; + } + + mem_fd = open("/proc/self/mem", O_RDWR | O_CLOEXEC); + if (mem_fd < 0) { + r = -errno; + (void) munmap(addr, 4096); + log_error_errno(r, "Failed to open /proc/self/mem: %m"); + return PROBE_ERROR; + } + + n = pwrite(mem_fd, &byte, sizeof(byte), (off_t) (uintptr_t) addr); + r = n < 0 ? -errno : 0; + (void) munmap(addr, 4096); + + if (r < 0) { + log_info_errno(r, "/proc/self/mem write to exec page of %s denied: %m", path); + /* Without FOLL_FORCE access_remote_vm() copies nothing and mem_rw() fails with EIO. */ + return probe_denied(r, -EIO); + } + + log_info("/proc/self/mem write to exec page of %s succeeded", path); + return PROBE_ALLOWED; +} + +/* Writes one word into the stopped tracee with 'request' (PTRACE_POKETEXT or PTRACE_POKEDATA). */ +static int poke(pid_t pid, int request, const char *name) { + errno = 0; + if (ptrace(request, pid, (void*) (uintptr_t) &poke, (void*) 0x42) < 0) { + if (errno == EPERM) { + log_info("ptrace(%s) denied by seccomp (EPERM) as expected", name); + return PROBE_DENIED; + } + + log_error_errno(errno, "ptrace(%s) failed unexpectedly: %m", name); + return PROBE_ERROR; + } + + log_info("ptrace(%s) succeeded, seccomp NOT enforcing", name); + return PROBE_ALLOWED; +} + +/* Self-contained check of the ptrace() memory-write block (seccomp layer). The + * filter is installed by PID 1 in production, not by "attach", and seccomp is + * per-process-tree, so this probe installs it itself and then verifies that + * PTRACE_POKETEXT and PTRACE_POKEDATA are refused while attaching and reading + * (PTRACE_PEEKTEXT) still work. */ +static int do_poketext_seccomp(void) { + _cleanup_(pidref_done_sigkill_wait) PidRef pid = PIDREF_NULL; + int status = 0, r; + long peek; + + r = pidref_safe_fork("(poketext)", FORK_LOG|FORK_DEATHSIG_SIGKILL, &pid); if (r < 0) - return log_info_errno(r, "mprotect PROT_EXEC on %s denied: %m", path); + return PROBE_ERROR; + if (r == 0) { + /* Child: invite tracing (PTRACE_TRACEME is not blocked), then stop. */ + if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) + _exit(EXIT_FAILURE); + raise(SIGSTOP); + _exit(EXIT_SUCCESS); + } - log_info("mprotect PROT_EXEC on %s succeeded", path); - return 0; + /* WUNTRACED: if PTRACE_TRACEME failed (e.g. Yama ptrace_scope=3) the child stops untraced, and + * without the flag we would wait forever. */ + if (waitpid(pid.pid, &status, WUNTRACED) < 0) { + log_error_errno(errno, "waitpid failed: %m"); + return PROBE_ERROR; + } + if (WIFEXITED(status)) { + log_error("PTRACE_TRACEME failed in the child, cannot ptrace in this environment."); + return PROBE_ERROR; + } + if (!WIFSTOPPED(status)) { + log_error("Child did not stop as expected (status=%i).", status); + return PROBE_ERROR; + } + + /* Positive control: without the filter, writing the word back must work, so that a denial below + * really is the filter's. */ + errno = 0; + peek = ptrace(PTRACE_PEEKTEXT, pid.pid, (void*) (uintptr_t) &poke, NULL); + if (peek == -1 && errno != 0) { + log_error_errno(errno, "ptrace(PTRACE_PEEKTEXT) failed, cannot ptrace in this environment: %m"); + return PROBE_ERROR; + } + if (ptrace(PTRACE_POKETEXT, pid.pid, (void*) (uintptr_t) &poke, (void*) (uintptr_t) peek) < 0) { + log_error_errno(errno, "ptrace(PTRACE_POKETEXT) failed before the filter was installed: %m"); + return PROBE_ERROR; + } + + r = seccomp_restrict_ptrace(); + if (r < 0) { + log_error_errno(r, "Failed to install ptrace seccomp filter: %m"); + return PROBE_ERROR; + } + + /* PEEKTEXT (read) must still work: attaching and inspection stay allowed. */ + errno = 0; + peek = ptrace(PTRACE_PEEKTEXT, pid.pid, (void*) (uintptr_t) &poke, NULL); + if (peek == -1 && errno != 0) { + log_error_errno(errno, "ptrace(PTRACE_PEEKTEXT) failed, filter too broad?: %m"); + return PROBE_ERROR; + } + + /* Both writers must be refused. */ + r = poke(pid.pid, PTRACE_POKETEXT, "PTRACE_POKETEXT"); + if (r == PROBE_DENIED) + r = poke(pid.pid, PTRACE_POKEDATA, "PTRACE_POKEDATA"); + + return r; +} + +static int usage(void) { + log_error("Usage: %s attach|check|mmap-exec PATH|anon-mmap-exec|mprotect-exec PATH|" + "mmap-wx PATH|mprotect-cow-exec PATH|mprotect-wx PATH|procmem-cow PATH|poketext", + program_invocation_short_name); + /* Not EXIT_FAILURE, which is PROBE_DENIED. */ + return PROBE_ERROR; +} + +/* The probes need no BPF support. Returns the probe's exit code, or -ENOENT if the verb is not a probe. */ +static int dispatch_probe(int argc, char *argv[]) { + const char *verb = argv[1]; + + if (argc == 2) { + if (streq(verb, "anon-mmap-exec")) + return do_anon_mmap_exec(); + if (streq(verb, "poketext")) + return do_poketext_seccomp(); + return -ENOENT; + } + + if (argc != 3) + return -ENOENT; + + if (streq(verb, "mmap-exec")) + return do_mmap_exec(argv[2]); + if (streq(verb, "mprotect-exec")) + return do_mprotect_exec(argv[2]); + if (streq(verb, "mmap-wx")) + return do_mmap_wx(argv[2]); + if (streq(verb, "mprotect-cow-exec")) + return do_mprotect_cow_exec(argv[2]); + if (streq(verb, "mprotect-wx")) + return do_mprotect_wx(argv[2]); + if (streq(verb, "procmem-cow")) + return do_procmem_cow(argv[2]); + + return -ENOENT; } #if BPF_FRAMEWORK && HAVE_LSM_INTEGRITY_TYPE @@ -166,15 +418,18 @@ static int do_attach(void) { static int do_check(void) { int r; - r = dlopen_bpf(LOG_WARNING); + r = bpf_restrict_fsaccess_check_prerequisites(); if (r < 0) return r; - r = lsm_supported("bpf"); - if (r <= 0) - return log_error_errno(r < 0 ? r : EOPNOTSUPP, "BPF LSM is not available: %m"); - log_info("BPF LSM: supported"); + /* PID 1 refuses the policy without the ptrace filter, mirror that. */ + r = seccomp_restrict_ptrace(); + if (r < 0) + return log_error_errno(r, "Failed to install the ptrace seccomp filter: %m"); + log_info("ptrace seccomp filter: installed"); + /* Last: linking attaches bprm_check for an instant with an empty trust map, during which every + * execve() on the system is denied. Keep that window out of the cheap failure paths above. */ _cleanup_(restrict_fsaccess_bpf_freep) struct restrict_fsaccess_bpf *obj = NULL; r = bpf_restrict_fsaccess_prepare(&obj); if (r < 0) @@ -184,55 +439,49 @@ static int do_check(void) { return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "bpf-restrict-fsaccess: Failed to link program."); - if (!dm_verity_require_signatures()) - return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), "dm-verity require_signatures is not enabled."); - log_info("dm-verity require_signatures: enabled"); - return 0; } int main(int argc, char *argv[]) { + int r; + test_setup_logging(LOG_DEBUG); - if (argc < 2) { - log_error("Usage: %s attach|check|mmap-exec|anon-mmap-exec|mprotect-exec", - program_invocation_short_name); - return EXIT_FAILURE; - } + if (argc < 2) + return usage(); if (streq(argv[1], "attach")) return do_attach() < 0 ? EXIT_FAILURE : EXIT_SUCCESS; if (streq(argv[1], "check")) return do_check() < 0 ? EXIT_FAILURE : EXIT_SUCCESS; - if (streq(argv[1], "mmap-exec") && argc == 3) - return do_mmap_exec(argv[2]) < 0 ? EXIT_FAILURE : EXIT_SUCCESS; - if (streq(argv[1], "anon-mmap-exec")) - return do_anon_mmap_exec() < 0 ? EXIT_FAILURE : EXIT_SUCCESS; - if (streq(argv[1], "mprotect-exec") && argc == 3) - return do_mprotect_exec(argv[2]) < 0 ? EXIT_FAILURE : EXIT_SUCCESS; - log_error("Usage: %s attach|check|mmap-exec PATH|anon-mmap-exec|mprotect-exec PATH", - program_invocation_short_name); - return EXIT_FAILURE; + r = dispatch_probe(argc, argv); + if (r >= 0) + return r; + + return usage(); } #else /* ! BPF_FRAMEWORK || ! HAVE_LSM_INTEGRITY_TYPE */ int main(int argc, char *argv[]) { + int r; + test_setup_logging(LOG_DEBUG); - /* mmap/mprotect probes work without BPF */ - if (argc >= 2) { - if (streq(argv[1], "mmap-exec") && argc == 3) - return do_mmap_exec(argv[2]) < 0 ? EXIT_FAILURE : EXIT_SUCCESS; - if (streq(argv[1], "anon-mmap-exec")) - return do_anon_mmap_exec() < 0 ? EXIT_FAILURE : EXIT_SUCCESS; - if (streq(argv[1], "mprotect-exec") && argc == 3) - return do_mprotect_exec(argv[2]) < 0 ? EXIT_FAILURE : EXIT_SUCCESS; + if (argc < 2) + return usage(); + + if (streq(argv[1], "attach") || streq(argv[1], "check")) { + log_info("BPF framework not available, attach/check not supported"); + return EXIT_TEST_SKIP; } - log_info("BPF framework not available, attach/check not supported"); - return 77; /* skip */ + r = dispatch_probe(argc, argv); + if (r >= 0) + return r; + + return usage(); } #endif diff --git a/src/test/test-seccomp.c b/src/test/test-seccomp.c index 44682b4a2c..e422edc955 100644 --- a/src/test/test-seccomp.c +++ b/src/test/test-seccomp.c @@ -2,13 +2,16 @@ #include #include +#include #include #include #include #include +#include #include #include #include +#include #include #if HAVE_VALGRIND_VALGRIND_H #include @@ -643,6 +646,63 @@ TEST(memory_deny_write_execute_shmat) { } } +TEST(restrict_ptrace) { + int r; + + CHECK_SECCOMP(/* refuse_container= */ false); + + r = ASSERT_OK(pidref_safe_fork("(restrict-ptrace)", FORK_LOG|FORK_WAIT, NULL)); + if (r == 0) { + void *addr = (void*) (uintptr_t) &ptrace; + int status; + pid_t pid; + long word; + + /* A tracee that stops right away; PTRACE_TRACEME is not filtered. */ + pid = fork(); + ASSERT_OK_ERRNO(pid); + if (pid == 0) { + if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) + _exit(EXIT_FAILURE); + raise(SIGSTOP); + _exit(EXIT_SUCCESS); + } + + ASSERT_OK_ERRNO(waitpid(pid, &status, WUNTRACED)); + if (WIFEXITED(status)) { + log_tests_skipped("ptrace() not permitted"); + _exit(EXIT_SUCCESS); + } + ASSERT_TRUE(WIFSTOPPED(status)); + + /* Positive control: without the filter, writing the word back works. */ + errno = 0; + word = ptrace(PTRACE_PEEKTEXT, pid, addr, NULL); + ASSERT_TRUE(word != -1 || errno == 0); + if (ptrace(PTRACE_POKETEXT, pid, addr, (void*) (uintptr_t) word) < 0) { + log_tests_skipped_errno(errno, "PTRACE_POKETEXT not permitted"); + (void) kill(pid, SIGKILL); + _exit(EXIT_SUCCESS); + } + + ASSERT_OK(seccomp_restrict_ptrace()); + + /* Reading stays allowed... */ + errno = 0; + word = ptrace(PTRACE_PEEKTEXT, pid, addr, NULL); + ASSERT_TRUE(word != -1 || errno == 0); + + /* ...writing is refused by the filter. */ + ASSERT_ERROR_ERRNO(ptrace(PTRACE_POKETEXT, pid, addr, (void*) (uintptr_t) word), EPERM); + ASSERT_ERROR_ERRNO(ptrace(PTRACE_POKEDATA, pid, addr, (void*) (uintptr_t) word), EPERM); + + ASSERT_OK_ERRNO(kill(pid, SIGKILL)); + ASSERT_OK_ERRNO(waitpid(pid, &status, 0)); + + _exit(EXIT_SUCCESS); + } +} + TEST(restrict_archs) { int r; diff --git a/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build b/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build index 2f3228f010..36202d7496 100644 --- a/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build +++ b/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build @@ -6,6 +6,10 @@ integration_tests += [ 'name' : fs.name(meson.current_source_dir()), 'vm' : true, 'firmware' : 'auto', + # Keep require_signatures the only gate that fails the helper's "check" here. + 'cmdline' : integration_test_template['cmdline'] + [ + 'proc_mem.force_override=never', + ], 'configuration' : integration_test_template['configuration'] + { 'env' : {'TEST_MATCH_SUBTEST' : 'config'}, }, @@ -14,7 +18,8 @@ integration_tests += [ # Enforce subtest: with require_signatures=1 — tests actual BPF enforcement # via the SecureBoot DB → .platform keyring path (firmware: auto enables -# UEFI/SecureBoot in the test VM). +# UEFI/SecureBoot in the test VM). proc_mem.force_override=never is the +# other command line prerequisite of RestrictFileSystemAccess=. integration_tests += [ integration_test_template + { 'name' : fs.name(meson.current_source_dir()) + '-enforce', @@ -22,6 +27,7 @@ integration_tests += [ 'firmware' : 'auto', 'cmdline' : integration_test_template['cmdline'] + [ 'dm_verity.require_signatures=1', + 'proc_mem.force_override=never', ], 'configuration' : integration_test_template['configuration'] + { 'command' : '/usr/lib/systemd/tests/testdata/units/' + fs.name(meson.current_source_dir()) + '.sh', @@ -41,6 +47,7 @@ integration_tests += [ 'cmdline' : integration_test_template['cmdline'] + [ 'dm_verity.require_signatures=1', 'dm_verity.keyring_unsealed=1', + 'proc_mem.force_override=never', ], 'configuration' : integration_test_template['configuration'] + { 'command' : '/usr/lib/systemd/tests/testdata/units/' + fs.name(meson.current_source_dir()) + '.sh', diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.config.sh b/test/units/TEST-90-RESTRICT-FSACCESS.config.sh index 40f3782f18..88da954d9d 100755 --- a/test/units/TEST-90-RESTRICT-FSACCESS.config.sh +++ b/test/units/TEST-90-RESTRICT-FSACCESS.config.sh @@ -90,13 +90,20 @@ testcase_no_require_signatures_helper() { fi fi - # The helper's "check" command runs the same bpf_restrict_fsaccess_supported() - # and dm_verity_require_signatures() checks that PID1 uses. It must fail - # because require_signatures is not enabled. - if "$HELPER" check; then + # The helper's "check" command runs the same bpf_restrict_fsaccess_check_prerequisites() + # checks that PID1 uses. It must fail because require_signatures is not enabled. + local out + if out=$("$HELPER" check 2>&1); then echo "ERROR: helper check succeeded but require_signatures is not enabled" return 1 fi + # ... and for that reason, not some other broken gate (meson.build sets + # proc_mem.force_override=never for this subtest to keep the others green). + if ! grep -F 'dm-verity require_signatures is not enabled' <<<"$out" >/dev/null; then + echo "ERROR: helper check failed for a reason other than require_signatures:" + echo "$out" + return 1 + fi echo "Helper correctly rejected setup: require_signatures not enabled" } diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh b/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh index 6a69ca9248..0ec62bdda2 100755 --- a/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh +++ b/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh @@ -8,8 +8,9 @@ # while execution from the rootfs continues to work. If dm-verity signing # support is available, also tests execution from a signed verity device. # -# Requires the VM to be booted with dm-verity.require_signatures=1 on the -# kernel command line (set in the test's meson.build). +# Requires the VM to be booted with dm_verity.require_signatures=1 and +# proc_mem.force_override=never on the kernel command line (set in the test's +# meson.build). set -eux set -o pipefail @@ -47,14 +48,19 @@ if [[ ! -x "$HELPER" ]]; then exit 1 fi -# Helper exits 77 when systemd was built with bpf-framework=enabled but no -# vmlinux.h (HAVE_LSM_INTEGRITY_TYPE=0), so the BPF program isn't compiled in. -rc=0 -"$HELPER" check >/dev/null 2>&1 || rc=$? -if [[ "$rc" -eq 77 ]]; then - echo "test-bpf-restrict-fsaccess built without BPF attach support, skipping" - exit 77 -fi +# The helper's probes exit with 0 if the operation was allowed, 1 if it was +# refused with the errno the mechanism under test produces, 2 if it could not +# be attempted and 3 if it was refused with an unexpected errno. Only the exact +# code counts, so a broken probe cannot pass as a denial. +expect_probe() { + local expected="$1" what="$2" rc=0 + shift 2 + "$HELPER" "$@" || rc=$? + if [[ "$rc" -ne "$expected" ]]; then + echo "ERROR: $what: helper '$*' exited with $rc, expected $expected" >&2 + exit 1 + fi +} # require_signatures is read-only — must be set via kernel cmdline if [[ ! -e /sys/module/dm_verity/parameters/require_signatures ]]; then @@ -70,6 +76,39 @@ if [[ "$val" != "Y" && "$val" != "1" ]]; then exit 77 fi +# proc_mem.force_override is __ro_after_init and has no readout: like +# require_signatures it must come from the kernel cmdline (set in meson.build). +if ! grep -E '(^|[[:space:]])proc_mem\.force_override=never([[:space:]]|$)' /proc/cmdline >/dev/null; then + echo "proc_mem.force_override=never not on the kernel cmdline (set in meson.build), skipping" + exit 77 +fi + +# The kernel silently ignores the option if it predates it (Linux 6.12), which "check" below would +# report as a hard failure: skip on such a kernel. On newer ones the /proc/self/mem test below +# asserts that the write really is refused. +if systemd-analyze compare-versions "$(uname -r)" lt 6.12; then + echo "Kernel $(uname -r) predates proc_mem.force_override= (Linux 6.12), skipping" + exit 77 +fi + +# PID1 refuses the policy without seccomp support, and so does the helper's "check". +if ! systemctl --version | grep -F -- "+SECCOMP" >/dev/null; then + echo "seccomp support not compiled in, skipping" + exit 77 +fi + +# Helper exits 77 when systemd was built with bpf-framework=enabled but no +# vmlinux.h (HAVE_LSM_INTEGRITY_TYPE=0), so the BPF program isn't compiled in. +# Run it only once, and only now that dm_verity is loaded so a pass is +# meaningful: a passing "check" briefly attaches the bprm_check program with +# an empty trust map, denying every execve() on the system meanwhile. +CHECK_RC=0 +"$HELPER" check || CHECK_RC=$? +if [[ "$CHECK_RC" -eq 77 ]]; then + echo "test-bpf-restrict-fsaccess built without BPF attach support, skipping" + exit 77 +fi + at_exit() { set +e # Kill the attach helper to detach BPF programs synchronously @@ -90,6 +129,75 @@ at_exit() { } trap at_exit EXIT +# ------ Preconditions: helper "check" must agree with the cmdline ------ +# "check" runs the same gates as PID1: BPF LSM, require_signatures, +# proc_mem.force_override=never on the cmdline plus a /proc/self/mem self-probe +# verifying the kernel honours it, and the ptrace seccomp filter. +# SYSTEMD_PROC_CMDLINE overrides what the helper considers the kernel command +# line; the gates run before any BPF program is loaded, so the rejections +# below are cheap and attach nothing. + +if [[ "$CHECK_RC" -ne 0 ]]; then + echo "ERROR: helper check failed (rc=$CHECK_RC) although all prerequisites are met!" >&2 + exit 1 +fi +echo "Helper check with proc_mem.force_override=never: OK" + +# "never ptrace" being rejected shows the last occurrence wins, like the +# kernel's early_param handling. +for bad in "" "proc_mem.force_override=always" "proc_mem.force_override=ptrace" \ + "proc_mem.force_override=never proc_mem.force_override=ptrace"; do + if out=$(SYSTEMD_PROC_CMDLINE="$bad" "$HELPER" check 2>&1); then + echo "ERROR: helper check should have rejected cmdline '$bad'!" >&2 + exit 1 + fi + if ! grep -F 'proc_mem.force_override is not set to' <<<"$out" >/dev/null; then + echo "ERROR: helper check rejected cmdline '$bad' for the wrong reason:" >&2 + echo "$out" >&2 + exit 1 + fi +done +echo "Helper check rejects always/ptrace/unset and honours the last occurrence: OK" + +# ------ Test: /proc/self/mem cannot rewrite an executable page ------ +# Independent of the BPF programs: with proc_mem.force_override=never the +# kernel drops FOLL_FORCE, so the copy-on-write through /proc/self/mem is +# refused with EIO. + +expect_probe 1 "/proc/self/mem rewrite of an executable page" procmem-cow /usr/bin/true +echo "/proc/self/mem rewrite of executable page refused: OK" + +# ------ Test: the ptrace seccomp filter refuses PTRACE_POKE{TEXT,DATA} ------ +# The helper installs the same filter PID1 uses and pokes a traced child; the +# probe only counts as a denial if PTRACE_PEEKTEXT still worked. + +expect_probe 1 "PTRACE_POKETEXT/PTRACE_POKEDATA" poketext +echo "PTRACE_POKETEXT/PTRACE_POKEDATA refused by seccomp filter: OK" + +# ------ Baseline: W^X probes succeed WITHOUT our BPF ------ +# Another LSM (e.g. SELinux execmem/execmod) may deny these on its own, which +# the helper reports as exit 3 (refused with an errno other than EPERM). A +# denial with BPF attached would then prove nothing, so skip them. Anything +# else is a broken probe or a stray policy and fails the test. + +WX_BASELINE=1 +for probe in mmap-wx mprotect-cow-exec mprotect-wx mprotect-exec; do + rc=0 + "$HELPER" "$probe" /usr/bin/true || rc=$? + case "$rc" in + 0) ;; + 3) + echo "WARNING: $probe denied BEFORE BPF attach by another LSM, skipping W^X tests" >&2 + WX_BASELINE=0 + break + ;; + *) + echo "ERROR: $probe failed BEFORE BPF attach (rc=$rc)!" >&2 + exit 1 + ;; + esac +done + # ------ Baseline: verify tmpfs exec works WITHOUT our BPF ------ # # Keep the destination basename as "true": on systems shipping uutils-coreutils @@ -162,30 +270,37 @@ echo "Execution from tmpfs blocked: OK" # Write a test file on the tmpfs mount for mmap/mprotect tests dd if=/dev/zero of=/tmp/restrict-fsaccess-test/testfile bs=4096 count=1 2>/dev/null -# File-backed PROT_EXEC mmap should be denied. -# The helper exits 0 if mmap succeeds (bad), 1 if denied (good). -if "$HELPER" mmap-exec /tmp/restrict-fsaccess-test/testfile; then - echo "ERROR: PROT_EXEC mmap of tmpfs file should have been blocked!" >&2 - exit 1 -fi +# File-backed PROT_EXEC mmap should be denied (with EPERM, see expect_probe). +expect_probe 1 "PROT_EXEC mmap of tmpfs file" mmap-exec /tmp/restrict-fsaccess-test/testfile echo "PROT_EXEC mmap from tmpfs blocked: OK" # Anonymous PROT_EXEC mmap should be denied (NULL file — mmap_file hook) -if "$HELPER" anon-mmap-exec; then - echo "ERROR: Anonymous PROT_EXEC mmap should have been blocked!" >&2 - exit 1 -fi +expect_probe 1 "anonymous PROT_EXEC mmap" anon-mmap-exec echo "Anonymous PROT_EXEC mmap blocked: OK" # ------ Test: mprotect adding PROT_EXEC is blocked (file_mprotect hook) ------ # mmap PROT_READ then mprotect to PROT_EXEC — the file_mprotect hook should deny this. -if "$HELPER" mprotect-exec /tmp/restrict-fsaccess-test/testfile; then - echo "ERROR: mprotect PROT_EXEC on tmpfs file should have been blocked!" >&2 - exit 1 -fi +expect_probe 1 "mprotect PROT_EXEC on tmpfs file" mprotect-exec /tmp/restrict-fsaccess-test/testfile echo "mprotect PROT_EXEC from tmpfs blocked: OK" +# ------ Test: W^X on a trusted file (mmap_file + file_mprotect hooks) ------ +# /usr/bin/true lives on the trusted rootfs, so a denial here is due to the +# W^X rules, not provenance. + +if [[ "$WX_BASELINE" == 1 ]]; then + for probe in mmap-wx mprotect-cow-exec mprotect-wx; do + expect_probe 1 "$probe on a trusted file" "$probe" /usr/bin/true + echo "W^X probe $probe blocked: OK" + done + + # Positive control: an unmodified trusted file mapping may still be made + # executable — the copy-on-write heuristic must not over-deny. + expect_probe 0 "PROT_EXEC mmap of trusted file" mmap-exec /usr/bin/true + expect_probe 0 "mprotect PROT_EXEC on trusted file" mprotect-exec /usr/bin/true + echo "Executable mappings of unmodified trusted file allowed: OK" +fi + # ------ Test: Execution from signed dm-verity device ------ # Trust path: .platform keyring (SecureBoot DB auto-enrolled by mkosi, made # available by 'firmware': 'auto' in the test's meson.build).