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).