diff --git a/.github/workflows/build-test.sh b/.github/workflows/build-test.sh
index 506479a558..68242ac922 100755
--- a/.github/workflows/build-test.sh
+++ b/.github/workflows/build-test.sh
@@ -65,6 +65,7 @@ PACKAGES=(
util-linux
zstd
)
+FEATURES=()
COMPILER="${COMPILER:?}"
COMPILER_VERSION="${COMPILER_VERSION:?}"
LINKER="${LINKER:?}"
@@ -133,6 +134,8 @@ sudo rm -f /etc/apt/sources.list.d/microsoft-prod.{list,sources}
if grep -q 'VERSION_CODENAME=jammy' /usr/lib/os-release; then
sudo add-apt-repository -y --no-update ppa:upstream-systemd-ci/systemd-ci
sudo add-apt-repository -y --no-update --enable-source
+ # Jammy's kernel is too old and there's no vmlinux.h
+ FEATURES+=("-Dbpf-framework=disabled")
else
# add-apt-repository --enable-source does not work on deb822 style sources.
for f in /etc/apt/sources.list.d/*.sources; do
@@ -175,6 +178,7 @@ for args in "${ARGS[@]}"; do
meson setup \
-Dtests=unsafe -Dslow-tests=true -Dfuzz-tests=true --werror \
-Dnobody-group=nogroup -Ddebug=false \
+ "${FEATURES[@]}" \
$args build; then
cat build/meson-logs/meson-log.txt
diff --git a/man/kernel-command-line.xml b/man/kernel-command-line.xml
index 83544b3606..03765010f0 100644
--- a/man/kernel-command-line.xml
+++ b/man/kernel-command-line.xml
@@ -79,6 +79,17 @@
+
+ systemd.restrict_filesystem_access=
+
+ Controls the RestrictFileSystemAccess= execution enforcement policy. For
+ details, see
+ systemd-system.conf5.
+
+
+
+
+
systemd.mask=
systemd.wants=
diff --git a/man/systemd-system.conf.xml b/man/systemd-system.conf.xml
index eb14cb7f30..fb565b0350 100644
--- a/man/systemd-system.conf.xml
+++ b/man/systemd-system.conf.xml
@@ -532,6 +532,49 @@
+
+ RestrictFileSystemAccess=
+
+ Takes a boolean argument or the special value exec. Defaults to
+ no. When enabled, PID 1 loads a BPF LSM program that enforces a deny-default
+ execution policy: only binaries residing on signed dm-verity block devices (and the initramfs during
+ early boot) are permitted to execute. Execution from tmpfs, procfs, sysfs, unsigned dm-verity devices,
+ and anonymous executable memory mappings is denied.
+
+ This setting is intended as one component of an image-based, fully verified system, where the
+ whole boot chain (firmware, kernel image, kernel command line, initramfs) is measured and attested.
+ On a general-purpose system without such guarantees it does not provide a meaningful security
+ boundary on its own: an attacker with sufficient privilege to edit
+ system.conf, modify the kernel command line, or kexec into an unsigned initrd
+ can disable or bypass the policy.
+
+ The enforcement hooks block execve() of untrusted binaries
+ (bprm_check_security), PROT_EXEC memory mappings including
+ shared libraries (mmap_file), and write-to-execute transitions such as JIT
+ compilation (file_mprotect).
+
+ Note that execution from overlayfs mounts is blocked even if the underlying layers reside on
+ signed dm-verity devices, because the BPF program sees the overlay filesystem's anonymous device
+ number rather than the underlying block device. Multi-device filesystems such as btrfs are similarly
+ unsupported.
+
+ Note that, without further measures to secure the system, kexec can be used to circumvent 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.
+
+ The value yes is equivalent to exec. Additional
+ modes may be added in the future.
+
+ This option may also be set via the systemd.restrict_filesystem_access= kernel command
+ line option, see
+ kernel-command-line7.
+
+
+
+
+
SystemCallArchitectures=
diff --git a/mkosi/mkosi.conf b/mkosi/mkosi.conf
index 0fbb81eeed..ca5c061079 100644
--- a/mkosi/mkosi.conf
+++ b/mkosi/mkosi.conf
@@ -101,6 +101,7 @@ Packages=
gzip
jq
kbd
+ keyutils
kmod
less
lsof
diff --git a/src/bpf/meson.build b/src/bpf/meson.build
index 32ab32e93d..13af4c968e 100644
--- a/src/bpf/meson.build
+++ b/src/bpf/meson.build
@@ -311,6 +311,19 @@ endif
conf.set10('HAVE_VMLINUX_H', use_provided_vmlinux_h or use_generated_vmlinux_h)
+# 'enum lsm_integrity_type' was added together with the bdev_setintegrity LSM
+# hook in kernel commit 2deeb6c333e5 (v6.5). The generated vmlinux.h reflects
+# the running kernel's BTF; a provided vmlinux.h can be older, so probe.
+have_lsm_integrity_type = false
+if use_generated_vmlinux_h
+ have_lsm_integrity_type = true
+elif use_provided_vmlinux_h
+ have_lsm_integrity_type = cc.compiles(
+ '#include "@0@"\nenum lsm_integrity_type _t;\n'.format(provided_vmlinux_h_path),
+ name : 'enum lsm_integrity_type in vmlinux.h')
+endif
+conf.set10('HAVE_LSM_INTEGRITY_TYPE', have_lsm_integrity_type)
+
conf.set10('ENABLE_SYSCTL_BPF', conf.get('HAVE_VMLINUX_H') == 1 and libbpf.version().version_compare('>= 0.7'))
bpf_programs = [
@@ -318,6 +331,11 @@ bpf_programs = [
'source' : files('bind-iface.bpf.c'),
'condition' : 'BPF_FRAMEWORK',
},
+ {
+ 'source' : files('restrict-fsaccess.bpf.c'),
+ 'condition' : 'HAVE_LSM_INTEGRITY_TYPE',
+ 'depends' : vmlinux_h_dependency,
+ },
{
'source' : files('restrict-fs.bpf.c'),
'condition' : 'BPF_FRAMEWORK',
diff --git a/src/bpf/restrict-fsaccess.bpf.c b/src/bpf/restrict-fsaccess.bpf.c
new file mode 100644
index 0000000000..13e11aff50
--- /dev/null
+++ b/src/bpf/restrict-fsaccess.bpf.c
@@ -0,0 +1,300 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/* The SPDX header above is actually correct in claiming this was
+ * LGPL-2.1-or-later, because it is. Since the kernel doesn't consider that
+ * compatible with GPL we will claim this to be GPL however, which should be
+ * fine given that LGPL-2.1-or-later downgrades to GPL if needed.
+ */
+
+/* Trusted Execution BPF LSM program.
+ *
+ * Enforces that only binaries from signed dm-verity block devices (or the
+ * initramfs during early boot) can be executed.
+ *
+ * Architecture:
+ * - bdev_setintegrity hook: self-populates a map of trusted devices when
+ * 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
+ */
+
+/* If offsetof() is implemented via __builtin_offset() then it doesn't work on current compilers, since the
+ * built-ins do not understand CO-RE. Let's undefine any such macros here, to force bpf_helpers.h to define
+ * its own definitions for this. (In new versions it will do so automatically, but at least in libbpf 1.1.0
+ * it does not.) */
+#undef offsetof
+#undef container_of
+
+#include "vmlinux.h"
+
+#include /* IWYU pragma: keep */
+#include
+#include
+#include
+
+#define PROT_EXEC 0x4
+#define VM_EXEC 0x00000004
+#define PTRACE_MODE_ATTACH 0x02
+
+/* ---- Maps ---- */
+
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 0); /* placeholder */
+ __type(key, __u32); /* dev_t from bdev->bd_dev */
+ __type(value, __u8); /* 1 = signature valid */
+} verity_devices SEC(".maps");
+
+/* ---- Globals (set by PID1 via skeleton) ---- */
+
+/* Device number of the initramfs superblock. PID1 sets this at load time and
+ * clears it (to 0) after switch_root. A value of 0 means "no initramfs trust
+ * — the window is closed." */
+volatile __u32 initramfs_s_dev;
+
+/* ---- Self-protection guard globals (set by PID1 after attach) ----
+ *
+ * While all IDs are 0 (the .bss default), the guard is inactive — no real BPF
+ * object has ID 0, so no comparisons match. PID1 populates these after
+ * attaching all programs. */
+volatile __u32 protected_map_id_verity;
+volatile __u32 protected_map_id_bss;
+
+/* Must equal _RESTRICT_FILESYSTEM_ACCESS_LINK_MAX in bpf-restrict-fsaccess.h — update when adding programs */
+#define NUM_PROTECTED_OBJS 9 /* 5 enforcement + 4 guard (bpf, bpf_map, bpf_prog, ptrace) */
+volatile __u32 protected_prog_ids[NUM_PROTECTED_OBJS];
+volatile __u32 protected_link_ids[NUM_PROTECTED_OBJS];
+
+/* ---- Integrity tracking hooks ---- */
+
+/* Preferred version: reads both value and size for defense-in-depth.
+ * Requires kernel v6.16+ or the backport of 1271a40eeafa ("bpf: Allow
+ * access to const void pointer arguments in tracing programs").
+ * On older kernels btf_ctx_access() rejects loads from const void *
+ * arguments because it fails to skip the CONST modifier when checking
+ * for void pointers. prepare_restrict_fsaccess_bpf() tries this version
+ * first and falls back to the _compat variant below if loading fails. */
+SEC("lsm/bdev_setintegrity")
+int BPF_PROG(restrict_fsaccess_bdev_setintegrity, struct block_device *bdev,
+ enum lsm_integrity_type type, const void *value, __u64 size)
+{
+ if (type == LSM_INT_DMVERITY_SIG_VALID) {
+ __u32 dev = bdev->bd_dev;
+ __u8 valid = value && size > 0;
+ bpf_map_update_elem(&verity_devices, &dev, &valid, BPF_ANY);
+ }
+
+ return 0;
+}
+
+/* Compatibility version for kernels without 1271a40eeafa: does not
+ * read the const void *value argument (ctx[2]) to avoid the verifier
+ * rejection. Reads size (ctx[3]) directly from the raw context instead.
+ * This is safe because dm-verity guarantees value!=NULL iff size>0. */
+#define BDEV_SETINTEGRITY_SIZE_CTX_IDX 3 /* bdev_setintegrity(bdev, type, value, size) */
+SEC("lsm/bdev_setintegrity")
+int BPF_PROG(restrict_fsaccess_bdev_setintegrity_compat, struct block_device *bdev,
+ enum lsm_integrity_type type)
+{
+ if (type == LSM_INT_DMVERITY_SIG_VALID) {
+ __u32 dev = bdev->bd_dev;
+ __u8 valid = ctx[BDEV_SETINTEGRITY_SIZE_CTX_IDX] > 0;
+ bpf_map_update_elem(&verity_devices, &dev, &valid, BPF_ANY);
+ }
+
+ return 0;
+}
+
+SEC("lsm/bdev_free_security")
+void BPF_PROG(restrict_fsaccess_bdev_free, struct block_device *bdev)
+{
+ __u32 dev = bdev->bd_dev;
+ bpf_map_delete_elem(&verity_devices, &dev);
+}
+
+/* ---- Enforcement helpers ---- */
+
+/* Check whether a file is from a trusted source.
+ * Returns 0 (allow) or -EPERM (deny). */
+static __always_inline int check_trusted_file(struct file *file)
+{
+ __u32 s_dev;
+ __u8 *sig_valid;
+
+ BPF_CORE_READ_INTO(&s_dev, file, f_inode, i_sb, s_dev);
+
+ /* Check initramfs trust (active only during early boot) */
+ if (initramfs_s_dev != 0 && s_dev == initramfs_s_dev)
+ return 0;
+
+ /* Check verity device map */
+ sig_valid = bpf_map_lookup_elem(&verity_devices, &s_dev);
+ if (sig_valid && *sig_valid)
+ return 0;
+
+ return -EPERM;
+}
+
+/* ---- Enforcement hooks ---- */
+
+SEC("lsm/bprm_check_security")
+int BPF_PROG(restrict_fsaccess_bprm_check, struct linux_binprm *bprm)
+{
+ struct file *file;
+
+ BPF_CORE_READ_INTO(&file, bprm, file);
+ return check_trusted_file(file);
+}
+
+SEC("lsm/mmap_file")
+int BPF_PROG(restrict_fsaccess_mmap_file, struct file *file, unsigned long reqprot,
+ unsigned long prot, unsigned long flags)
+{
+ /* Only enforce on executable mappings */
+ if (!(prot & PROT_EXEC))
+ return 0;
+
+ /* Anonymous executable mapping — no file backing, deny */
+ if (!file)
+ return -EPERM;
+
+ return check_trusted_file(file);
+}
+
+SEC("lsm/file_mprotect")
+int BPF_PROG(restrict_fsaccess_file_mprotect, struct vm_area_struct *vma,
+ unsigned long reqprot, unsigned long prot)
+{
+ struct file *file;
+ unsigned long vm_flags;
+
+ /* 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 */
+ BPF_CORE_READ_INTO(&vm_flags, vma, vm_flags);
+ if (vm_flags & VM_EXEC)
+ return 0;
+
+ /* Anonymous executable mapping — no file backing, deny */
+ BPF_CORE_READ_INTO(&file, vma, vm_file);
+ if (!file)
+ return -EPERM;
+
+ return check_trusted_file(file);
+}
+
+/* ---- PID1 ptrace protection ----
+ *
+ * Blocks PTRACE_MODE_ATTACH access to PID1 from any other process. This
+ * prevents ptrace(PTRACE_ATTACH), /proc/1/mem, process_vm_readv(), and
+ * pidfd_getfd() from extracting sensitive state from PID1's address space.
+ *
+ * PTRACE_MODE_READ is allowed — monitoring tools and systemctl need
+ * /proc/1/status, /proc/1/fd/, /proc/1/ns/ *, etc.
+ *
+ * PID1 accessing itself is allowed. */
+
+SEC("lsm/ptrace_access_check")
+int BPF_PROG(restrict_fsaccess_ptrace_guard, struct task_struct *child,
+ unsigned int mode)
+{
+ /* We only care about PID 1 and its threads (There are none but still.). */
+ if (child->tgid != 1)
+ return 0;
+
+ /* We only care about dangerous operations. */
+ if (!(mode & PTRACE_MODE_ATTACH))
+ return 0;
+
+ /* PID1 (any thread) accessing itself is allowed. */
+ if ((bpf_get_current_pid_tgid() >> 32) == 1)
+ return 0;
+
+ return -EPERM;
+}
+
+/* ---- Self-protection guard ----
+ *
+ * Three hooks protect our BPF objects from non-PID1 processes:
+ *
+ * lsm/bpf_map — fires inside bpf_map_new_fd(), the chokepoint for ALL
+ * code paths that produce a map FD (BPF_MAP_GET_FD_BY_ID,
+ * BPF_OBJ_GET, BPF_MAP_CREATE). Blocks the primary attack:
+ * obtaining an FD to verity_devices to inject fake trusted
+ * devices via BPF_MAP_UPDATE_ELEM.
+ *
+ * lsm/bpf_prog — fires inside bpf_prog_new_fd(), same chokepoint coverage
+ * for programs. Defense-in-depth.
+ *
+ * lsm/bpf — handles BPF_LINK_GET_FD_BY_ID only. There is no
+ * security_bpf_link() hook in the kernel, so link
+ * protection uses the command-level bpf() hook. This is
+ * sufficient: we don't pin links in production, so
+ * BPF_OBJ_GET is not an attack vector for links. */
+
+SEC("lsm/bpf_map")
+int BPF_PROG(restrict_fsaccess_bpf_map_guard, struct bpf_map *map,
+ unsigned int fmode)
+{
+ __u32 id;
+
+ if ((bpf_get_current_pid_tgid() >> 32) == 1)
+ return 0;
+
+ id = map->id;
+ if (id != 0 && (id == protected_map_id_verity ||
+ id == protected_map_id_bss))
+ return -EPERM;
+
+ return 0;
+}
+
+SEC("lsm/bpf_prog")
+int BPF_PROG(restrict_fsaccess_bpf_prog_guard, struct bpf_prog *prog)
+{
+ __u32 id;
+
+ if ((bpf_get_current_pid_tgid() >> 32) == 1)
+ return 0;
+
+ id = BPF_CORE_READ(prog, aux, id);
+ if (id == 0)
+ return 0;
+
+ for (int i = 0; i < NUM_PROTECTED_OBJS; i++)
+ if (id == protected_prog_ids[i])
+ return -EPERM;
+
+ return 0;
+}
+
+SEC("lsm/bpf")
+int BPF_PROG(restrict_fsaccess_bpf_guard, int cmd, union bpf_attr *attr,
+ unsigned int size)
+{
+ __u32 id;
+
+ if ((bpf_get_current_pid_tgid() >> 32) == 1)
+ return 0;
+
+ if (cmd != BPF_LINK_GET_FD_BY_ID)
+ return 0;
+
+ /* link_id/map_id/prog_id share the same offset in the bpf_attr union */
+ id = attr->link_id;
+ if (id == 0)
+ return 0;
+
+ for (int i = 0; i < NUM_PROTECTED_OBJS; i++)
+ if (id == protected_link_ids[i])
+ return -EPERM;
+
+ return 0;
+}
+
+static const char _license[] SEC("license") = "GPL";
diff --git a/src/core/bpf-restrict-fs.c b/src/core/bpf-restrict-fs.c
index e60e6ca7ef..d3c6adc145 100644
--- a/src/core/bpf-restrict-fs.c
+++ b/src/core/bpf-restrict-fs.c
@@ -30,19 +30,6 @@ static struct restrict_fs_bpf *restrict_fs_bpf_free(struct restrict_fs_bpf *obj)
DEFINE_TRIVIAL_CLEANUP_FUNC(struct restrict_fs_bpf *, restrict_fs_bpf_free);
-static bool bpf_can_link_lsm_program(struct bpf_program *prog) {
- _cleanup_(bpf_link_freep) struct bpf_link *link = NULL;
-
- assert(prog);
-
- link = sym_bpf_program__attach_lsm(prog);
-
- /* If bpf_program__attach_lsm fails the resulting value stores libbpf error code instead of memory
- * pointer. That is the case when the helper is called on architectures where BPF trampoline (hence
- * BPF_LSM_MAC attach type) is not supported. */
- return bpf_get_error_translated(link) == 0;
-}
-
static int prepare_restrict_fs_bpf(struct restrict_fs_bpf **ret_obj) {
_cleanup_(restrict_fs_bpf_freep) struct restrict_fs_bpf *obj = NULL;
_cleanup_close_ int inner_map_fd = -EBADF;
diff --git a/src/core/bpf-restrict-fsaccess.c b/src/core/bpf-restrict-fsaccess.c
new file mode 100644
index 0000000000..be66f7da01
--- /dev/null
+++ b/src/core/bpf-restrict-fsaccess.c
@@ -0,0 +1,562 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include
+#include
+#include
+
+#include "bpf-restrict-fsaccess.h"
+#include "fd-util.h"
+#include "fileio.h"
+#include "initrd-util.h"
+#include "log.h"
+#include "lsm-util.h"
+#include "manager.h"
+#include "memory-util.h"
+#include "serialize.h"
+#include "string-table.h"
+
+/* DMVERITY_DEVICES_MAX lives in bpf-restrict-fsaccess.h for sharing with tests. */
+
+static const char* const restrict_filesystem_access_table[_RESTRICT_FILESYSTEM_ACCESS_MAX] = {
+ [RESTRICT_FILESYSTEM_ACCESS_NO] = "no",
+ [RESTRICT_FILESYSTEM_ACCESS_EXEC] = "exec",
+};
+
+DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN(restrict_filesystem_access, RestrictFileSystemAccess, RESTRICT_FILESYSTEM_ACCESS_EXEC);
+
+const char* const restrict_fsaccess_link_names[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX] = {
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BDEV_SETINTEGRITY] = "restrict-fsaccess-bdev-setintegrity-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BDEV_FREE] = "restrict-fsaccess-bdev-free-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPRM_CHECK] = "restrict-fsaccess-bprm-check-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_MMAP_FILE] = "restrict-fsaccess-mmap-file-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_FILE_MPROTECT] = "restrict-fsaccess-file-mprotect-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_PTRACE_GUARD] = "restrict-fsaccess-ptrace-guard-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_MAP_GUARD] = "restrict-fsaccess-bpf-map-guard-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_PROG_GUARD] = "restrict-fsaccess-bpf-prog-guard-link",
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_GUARD] = "restrict-fsaccess-bpf-guard-link",
+};
+
+#if BPF_FRAMEWORK && HAVE_LSM_INTEGRITY_TYPE
+#include "bpf-dlopen.h"
+#include "bpf-link.h"
+#include "restrict-fsaccess-skel.h"
+
+static struct restrict_fsaccess_bpf *restrict_fsaccess_bpf_free(struct restrict_fsaccess_bpf *obj) {
+ restrict_fsaccess_bpf__destroy(obj);
+ return NULL;
+}
+
+DEFINE_TRIVIAL_CLEANUP_FUNC(struct restrict_fsaccess_bpf *, restrict_fsaccess_bpf_free);
+
+/* Verify that restrict_fsaccess_bss matches the skeleton's .bss layout. The sizeof
+ * check catches field additions/removals; the offsetof checks catch field
+ * reordering. Field order in restrict_fsaccess_bss must match the BPF global
+ * declaration order in restrict-fsaccess.bpf.c — this is what bpftool uses for the
+ * generated struct. The read-modify-write in restrict_fsaccess_clear_initramfs_trust()
+ * depends on this layout. */
+assert_cc(sizeof(struct restrict_fsaccess_bss) == sizeof_field(struct restrict_fsaccess_bpf, bss[0]));
+assert_cc(offsetof(struct restrict_fsaccess_bss, initramfs_s_dev) ==
+ offsetof(typeof_field(struct restrict_fsaccess_bpf, bss[0]), initramfs_s_dev));
+assert_cc(offsetof(struct restrict_fsaccess_bss, protected_map_id_verity) ==
+ offsetof(typeof_field(struct restrict_fsaccess_bpf, bss[0]), protected_map_id_verity));
+assert_cc(offsetof(struct restrict_fsaccess_bss, protected_map_id_bss) ==
+ offsetof(typeof_field(struct restrict_fsaccess_bpf, bss[0]), protected_map_id_bss));
+
+/* Build the skeleton links array indexed by the link enum.
+ * For BDEV_SETINTEGRITY, use whichever variant was loaded (full or compat).
+ * This compat logic can be removed once the kernel baseline includes
+ * 1271a40eeafa ("bpf: Allow access to const void pointer arguments"). */
+#define RESTRICT_FSACCESS_LINKS(obj) { \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BDEV_SETINTEGRITY] = (obj)->links.restrict_fsaccess_bdev_setintegrity ?: \
+ (obj)->links.restrict_fsaccess_bdev_setintegrity_compat, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BDEV_FREE] = (obj)->links.restrict_fsaccess_bdev_free, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPRM_CHECK] = (obj)->links.restrict_fsaccess_bprm_check, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_MMAP_FILE] = (obj)->links.restrict_fsaccess_mmap_file, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_FILE_MPROTECT] = (obj)->links.restrict_fsaccess_file_mprotect, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_PTRACE_GUARD] = (obj)->links.restrict_fsaccess_ptrace_guard, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_MAP_GUARD] = (obj)->links.restrict_fsaccess_bpf_map_guard, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_PROG_GUARD] = (obj)->links.restrict_fsaccess_bpf_prog_guard, \
+ [RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_GUARD] = (obj)->links.restrict_fsaccess_bpf_guard, \
+}
+
+bool dm_verity_require_signatures(void) {
+ int r;
+
+ r = read_boolean_file("/sys/module/dm_verity/parameters/require_signatures");
+ if (r < 0) {
+ if (r != -ENOENT)
+ log_warning_errno(r, "bpf-restrict-fsaccess: Failed to read dm-verity require_signatures: %m");
+ return false;
+ }
+
+ return r > 0;
+}
+
+static int get_root_s_dev(uint32_t *ret) {
+ struct stat st;
+
+ assert(ret);
+
+ /* Stat /usr/ rather than / — executable code lives in /usr/ and we push toward
+ * a writable non-executable /. On systems with a separate /usr partition this
+ * means / is intentionally not trusted. */
+ if (stat("/usr/", &st) < 0)
+ return log_error_errno(errno, "bpf-restrict-fsaccess: Failed to stat /usr/ filesystem: %m");
+
+ *ret = STAT_DEV_TO_KERNEL(st.st_dev);
+ return 0;
+}
+
+int bpf_restrict_fsaccess_prepare(struct restrict_fsaccess_bpf **ret) {
+ _cleanup_(restrict_fsaccess_bpf_freep) struct restrict_fsaccess_bpf *obj = NULL;
+ int r;
+
+ assert(ret);
+
+ /* Try the preferred version first — it reads the const void *value
+ * argument for defense-in-depth. On kernels before v6.16 (missing
+ * 1271a40eeafa) the verifier rejects loads from const void * context
+ * arguments, so we fall back to the _compat variant that only reads
+ * the size argument via raw ctx access. */
+ obj = restrict_fsaccess_bpf__open();
+ if (!obj)
+ return log_error_errno(errno, "bpf-restrict-fsaccess: Failed to open BPF object: %m");
+
+ r = sym_bpf_map__set_max_entries(obj->maps.verity_devices, DMVERITY_DEVICES_MAX);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to size hash table: %m");
+
+ r = sym_bpf_program__set_autoload(obj->progs.restrict_fsaccess_bdev_setintegrity_compat, false);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to disable compat program: %m");
+
+ r = restrict_fsaccess_bpf__load(obj);
+ if (r >= 0) {
+ log_debug("bpf-restrict-fsaccess: Loaded with full const void * access.");
+ *ret = TAKE_PTR(obj);
+ return 0;
+ }
+
+ log_debug_errno(r, "bpf-restrict-fsaccess: Full version failed to load (%m), trying compat variant.");
+ obj = restrict_fsaccess_bpf_free(obj);
+
+ obj = restrict_fsaccess_bpf__open();
+ if (!obj)
+ return log_error_errno(errno, "bpf-restrict-fsaccess: Failed to reopen BPF object: %m");
+
+ r = sym_bpf_map__set_max_entries(obj->maps.verity_devices, DMVERITY_DEVICES_MAX);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to size hash table: %m");
+
+ r = sym_bpf_program__set_autoload(obj->progs.restrict_fsaccess_bdev_setintegrity, false);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to disable full program: %m");
+
+ r = restrict_fsaccess_bpf__load(obj);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to load BPF object (compat): %m");
+
+ log_debug("bpf-restrict-fsaccess: Loaded with compat bdev_setintegrity.");
+ *ret = TAKE_PTR(obj);
+ return 0;
+}
+
+bool bpf_restrict_fsaccess_supported(void) {
+ _cleanup_(restrict_fsaccess_bpf_freep) struct restrict_fsaccess_bpf *obj = NULL;
+ static int supported = -1;
+ int r;
+
+ if (supported >= 0)
+ return supported;
+ if (dlopen_bpf(LOG_WARNING) < 0)
+ return (supported = false);
+
+ r = lsm_supported("bpf");
+ if (r == -ENOPKG) {
+ log_debug_errno(r, "bpf-restrict-fsaccess: securityfs not mounted, BPF LSM not available.");
+ return (supported = false);
+ }
+ if (r < 0) {
+ log_warning_errno(r, "bpf-restrict-fsaccess: Can't determine whether the BPF LSM module is used: %m");
+ return (supported = false);
+ }
+ if (r == 0) {
+ log_info("bpf-restrict-fsaccess: BPF LSM hook not enabled in the kernel, not supported.");
+ return (supported = false);
+ }
+
+ r = bpf_restrict_fsaccess_prepare(&obj);
+ if (r < 0)
+ return (supported = false);
+
+ if (!bpf_can_link_lsm_program(obj->progs.restrict_fsaccess_bprm_check)) {
+ log_warning("bpf-restrict-fsaccess: Failed to link program; assuming BPF LSM is not available.");
+ return (supported = false);
+ }
+
+ return (supported = true);
+}
+
+/* Partial deserialization (some FDs but not all) is fatal: continuing
+ * would leave enforcement incomplete. */
+static int restrict_fsaccess_have_deserialized_fds(Manager *m) {
+ size_t count = 0;
+
+ assert(m);
+
+ FOREACH_ELEMENT(fd, m->restrict_fsaccess_link_fds)
+ if (*fd >= 0)
+ count++;
+
+ if (count == 0)
+ return 0;
+ if (count == ELEMENTSOF(m->restrict_fsaccess_link_fds))
+ return 1;
+
+ return log_error_errno(SYNTHETIC_ERRNO(EBADFD),
+ "bpf-restrict-fsaccess: Only %zu of %zu link FDs deserialized, refusing to continue with partial enforcement.",
+ count, ELEMENTSOF(m->restrict_fsaccess_link_fds));
+}
+
+/* Close the initramfs trust window after switch_root by clearing initramfs_s_dev
+ * in the BPF .bss map. The .bss is a BPF_F_MMAPABLE array map — mmap it and do
+ * a single aligned 4-byte store instead of a full-value read-modify-write via
+ * bpf_map_update_elem, which would needlessly rewrite the guard globals too. */
+static int restrict_fsaccess_clear_initramfs_trust(int bss_map_fd) {
+ void *p;
+
+ assert(bss_map_fd >= 0);
+ assert_cc(offsetof(struct restrict_fsaccess_bss, initramfs_s_dev) == 0);
+
+ p = mmap(NULL, page_size(), PROT_READ | PROT_WRITE, MAP_SHARED, bss_map_fd, 0);
+ if (p == MAP_FAILED)
+ return log_error_errno(errno, "bpf-restrict-fsaccess: Failed to mmap .bss map: %m");
+
+ /* initramfs_s_dev is at offset 0 in the .bss layout. Single aligned
+ * 32-bit store is atomic — BPF programs see either the old or new value,
+ * no torn reads possible. Guard globals are untouched. */
+ *(uint32_t *) p = 0;
+
+ /* munmap failure here is harmless: the clear above already landed in
+ * the kernel, and the mapping is discarded by exec anyway. */
+ if (munmap(p, page_size()) < 0)
+ log_warning_errno(errno, "bpf-restrict-fsaccess: Failed to munmap .bss map, ignoring: %m");
+
+ log_info("bpf-restrict-fsaccess: Cleared initramfs trust window after switch_root.");
+ return 0;
+}
+
+static int bpf_get_map_id(int fd, uint32_t *ret_id) {
+ struct bpf_map_info info = {};
+ uint32_t len = sizeof(info);
+ int r;
+
+ if (fd < 0)
+ return -EBADF;
+
+ assert(ret_id);
+
+ r = sym_bpf_obj_get_info_by_fd(fd, &info, &len);
+ if (r < 0)
+ return r;
+
+ *ret_id = info.id;
+ return 0;
+}
+
+static int bpf_get_link_ids(int fd, uint32_t *ret_link_id, uint32_t *ret_prog_id) {
+ struct bpf_link_info info = {};
+ uint32_t len = sizeof(info);
+ int r;
+
+ if (fd < 0)
+ return -EBADF;
+
+ r = sym_bpf_obj_get_info_by_fd(fd, &info, &len);
+ if (r < 0)
+ return r;
+
+ if (ret_link_id)
+ *ret_link_id = info.id;
+ if (ret_prog_id)
+ *ret_prog_id = info.prog_id;
+
+ return 0;
+}
+
+/* Populate guard globals with kernel-assigned IDs so the guard hooks block
+ * non-PID1 access to our maps/progs/links via the bpf() syscall. */
+int bpf_restrict_fsaccess_populate_guard(struct restrict_fsaccess_bpf *obj) {
+ int r;
+
+ assert(obj);
+
+ struct bpf_link *links[] = RESTRICT_FSACCESS_LINKS(obj);
+ assert_cc(ELEMENTSOF(links) == _RESTRICT_FILESYSTEM_ACCESS_LINK_MAX);
+
+ /* Map IDs */
+ r = bpf_get_map_id(sym_bpf_map__fd(obj->maps.verity_devices), &obj->bss->protected_map_id_verity);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to get verity_devices map ID: %m");
+
+ r = bpf_get_map_id(sym_bpf_map__fd(obj->maps.bss), &obj->bss->protected_map_id_bss);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to get .bss map ID: %m");
+
+ /* Link and program IDs (each link knows its associated program) */
+ FOREACH_ELEMENT(link, links) {
+ size_t idx = link - links;
+
+ /* BDEV_SETINTEGRITY slot resolves via ?: between full and compat
+ * variants; assert at least one was attached. */
+ if (!*link)
+ return log_error_errno(SYNTHETIC_ERRNO(ENODATA),
+ "bpf-restrict-fsaccess: %s link missing after attach.",
+ restrict_fsaccess_link_names[idx]);
+
+ r = bpf_get_link_ids(sym_bpf_link__fd(*link),
+ &obj->bss->protected_link_ids[idx],
+ &obj->bss->protected_prog_ids[idx]);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to get link/prog IDs for %s: %m",
+ restrict_fsaccess_link_names[idx]);
+ }
+
+ log_info("bpf-restrict-fsaccess: Guard globals populated (verity_map=%u, bss_map=%u)",
+ (unsigned) obj->bss->protected_map_id_verity,
+ (unsigned) obj->bss->protected_map_id_bss);
+ return 0;
+}
+
+/* Validate that deserialized FDs actually reference our LSM BPF links. A
+ * corrupted serialization file could leave FDs pointing at arbitrary kernel
+ * objects; a stale FD could point at a BPF link of an entirely different type
+ * (e.g. kprobe-multi). Verify both link type and attach type so a substituted
+ * FD that happens to be a BPF link still fails the check. */
+static int restrict_fsaccess_validate_deserialized_fds(Manager *m) {
+ int r;
+
+ assert(m);
+
+ r = dlopen_bpf(LOG_WARNING);
+ if (r < 0)
+ return log_error_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE),
+ "bpf-restrict-fsaccess: Failed to load libbpf for FD validation, aborting.");
+
+ FOREACH_ELEMENT(fd, m->restrict_fsaccess_link_fds) {
+ struct bpf_link_info info = {};
+ uint32_t len = sizeof(info);
+ const char *name = restrict_fsaccess_link_names[fd - m->restrict_fsaccess_link_fds];
+
+ r = sym_bpf_obj_get_info_by_fd(*fd, &info, &len);
+ if (r < 0)
+ return log_error_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE),
+ "bpf-restrict-fsaccess: Deserialized FD for %s is not a valid BPF object, aborting.",
+ name);
+
+ if (info.type != BPF_LINK_TYPE_TRACING || info.tracing.attach_type != BPF_LSM_MAC)
+ return log_error_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE),
+ "bpf-restrict-fsaccess: Deserialized FD for %s is not an LSM tracing link (type=%u attach=%u), aborting.",
+ name, info.type, info.tracing.attach_type);
+ }
+
+ if (m->restrict_fsaccess_bss_map_fd >= 0) {
+ uint32_t id;
+
+ r = bpf_get_map_id(m->restrict_fsaccess_bss_map_fd, &id);
+ if (r < 0)
+ return log_error_errno(SYNTHETIC_ERRNO(ENOTRECOVERABLE),
+ "bpf-restrict-fsaccess: Deserialized FD for .bss map is not a valid BPF map, aborting.");
+ }
+
+ return 0;
+}
+
+int bpf_restrict_fsaccess_setup(Manager *m) {
+ _cleanup_(restrict_fsaccess_bpf_freep) struct restrict_fsaccess_bpf *obj = NULL;
+ int r;
+
+ assert(m);
+
+ if (!MANAGER_IS_SYSTEM(m) || m->restrict_filesystem_access <= RESTRICT_FILESYSTEM_ACCESS_NO)
+ return 0;
+
+ r = restrict_fsaccess_have_deserialized_fds(m);
+ if (r < 0)
+ return r;
+ if (r > 0) {
+ log_info("bpf-restrict-fsaccess: Recovered link FDs from previous exec, programs still attached.");
+
+ r = restrict_fsaccess_validate_deserialized_fds(m);
+ if (r < 0)
+ return r;
+ if (m->switching_root) {
+ if (m->restrict_fsaccess_bss_map_fd < 0)
+ return log_error_errno(SYNTHETIC_ERRNO(EBADF),
+ "bpf-restrict-fsaccess: Cannot clear initramfs trust after switch_root.");
+ r = restrict_fsaccess_clear_initramfs_trust(m->restrict_fsaccess_bss_map_fd);
+ if (r < 0)
+ return r;
+ }
+
+ return 0;
+ }
+
+ /* Fresh setup: verify BPF LSM is available */
+ if (!bpf_restrict_fsaccess_supported())
+ return log_warning_errno(SYNTHETIC_ERRNO(EOPNOTSUPP),
+ "bpf-restrict-fsaccess: BPF LSM is not available.");
+
+ /* 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.");
+
+ r = bpf_restrict_fsaccess_prepare(&obj);
+ if (r < 0)
+ return r;
+
+ /* If we're still in the initramfs, allow execution from it by recording
+ * its s_dev. After switch_root, PID1 re-execs and in_initrd() returns
+ * false — initramfs_s_dev stays at 0 (its default), closing the trust
+ * window. */
+ if (in_initrd()) {
+ uint32_t root_dev;
+
+ r = get_root_s_dev(&root_dev);
+ if (r < 0)
+ return r;
+
+ obj->bss->initramfs_s_dev = root_dev;
+ log_info("bpf-restrict-fsaccess: Initramfs trusted (s_dev=%" PRIu32 ":%" PRIu32 ")",
+ root_dev >> 20, root_dev & 0xFFFFF);
+ }
+
+ r = restrict_fsaccess_bpf__attach(obj);
+ if (r < 0)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to attach BPF programs: %m");
+
+ log_info("bpf-restrict-fsaccess: LSM BPF programs attached");
+
+ /* Now that all programs are attached, populate the guard's globals with
+ * the kernel-assigned IDs of our maps, programs, and links. From this
+ * point on, non-PID1 processes cannot obtain FDs to our BPF objects. */
+ r = bpf_restrict_fsaccess_populate_guard(obj);
+ if (r < 0)
+ return r;
+
+ /* Extract owned FDs from the skeleton. These keep the kernel BPF objects
+ * alive after the skeleton is destroyed. Destroying the skeleton unmaps
+ * the .bss page from our address space so no BPF state (guard globals,
+ * map IDs, initramfs_s_dev) is reachable via /proc/1/mem. */
+ struct bpf_link *links[] = RESTRICT_FSACCESS_LINKS(obj);
+ FOREACH_ELEMENT(link, links) {
+ size_t idx = link - links;
+
+ if (!*link) {
+ r = log_error_errno(SYNTHETIC_ERRNO(ENODATA),
+ "bpf-restrict-fsaccess: %s link missing after attach.",
+ restrict_fsaccess_link_names[idx]);
+ goto fail;
+ }
+
+ m->restrict_fsaccess_link_fds[idx] = fcntl(sym_bpf_link__fd(*link), F_DUPFD_CLOEXEC, 3);
+ if (m->restrict_fsaccess_link_fds[idx] < 0) {
+ r = log_error_errno(errno, "bpf-restrict-fsaccess: Failed to dup link FD for %s: %m",
+ restrict_fsaccess_link_names[idx]);
+ goto fail;
+ }
+ }
+
+ m->restrict_fsaccess_bss_map_fd = fcntl(sym_bpf_map__fd(obj->maps.bss), F_DUPFD_CLOEXEC, 3);
+ if (m->restrict_fsaccess_bss_map_fd < 0) {
+ r = log_error_errno(errno, "bpf-restrict-fsaccess: Failed to dup .bss map FD: %m");
+ goto fail;
+ }
+
+ return 0;
+
+fail:
+ /* Close partial FDs so we don't leave a half-baked policy attached
+ * once the skeleton is destroyed by _cleanup_. */
+ FOREACH_ELEMENT(fd, m->restrict_fsaccess_link_fds)
+ *fd = safe_close(*fd);
+ m->restrict_fsaccess_bss_map_fd = safe_close(m->restrict_fsaccess_bss_map_fd);
+ return r;
+}
+
+int bpf_restrict_fsaccess_close_initramfs_trust(Manager *m) {
+ assert(m);
+
+ /* Clear initramfs_s_dev in the BPF .bss map BEFORE switch_root unmounts
+ * the initramfs. This eliminates the dev_t recycling window: the anonymous
+ * dev_t is still held by the mounted initramfs superblock, so no other
+ * filesystem can recycle it yet. Anonymous dev_t recycling is immediate
+ * and lowest-first, so a stale initramfs_s_dev is a near-certain trust
+ * bypass — fail closed. */
+ if (!in_initrd() || m->restrict_fsaccess_bss_map_fd < 0)
+ return 0;
+
+ return restrict_fsaccess_clear_initramfs_trust(m->restrict_fsaccess_bss_map_fd);
+}
+
+int bpf_restrict_fsaccess_serialize(Manager *m, FILE *f, FDSet *fds) {
+ int r;
+
+ assert(m);
+ assert(f);
+ assert(fds);
+
+ if (!MANAGER_IS_SYSTEM(m) || m->restrict_filesystem_access <= RESTRICT_FILESYSTEM_ACCESS_NO)
+ return 0;
+
+ FOREACH_ELEMENT(fd, m->restrict_fsaccess_link_fds) {
+ r = serialize_fd(f, fds, restrict_fsaccess_link_names[fd - m->restrict_fsaccess_link_fds], *fd);
+ if (r < 0)
+ return r;
+ }
+
+ r = serialize_fd(f, fds, "restrict-fsaccess-bss-map", m->restrict_fsaccess_bss_map_fd);
+ if (r < 0)
+ return r;
+
+ return 0;
+}
+
+#else /* ! BPF_FRAMEWORK || ! HAVE_LSM_INTEGRITY_TYPE */
+
+bool dm_verity_require_signatures(void) {
+ return false;
+}
+
+bool bpf_restrict_fsaccess_supported(void) {
+ return false;
+}
+
+int bpf_restrict_fsaccess_setup(Manager *m) {
+ if (!MANAGER_IS_SYSTEM(m) || m->restrict_filesystem_access <= RESTRICT_FILESYSTEM_ACCESS_NO)
+ return 0;
+
+ return log_warning_errno(SYNTHETIC_ERRNO(EOPNOTSUPP),
+ "bpf-restrict-fsaccess: RestrictFileSystemAccess= requested but BPF framework is not compiled in.");
+}
+
+int bpf_restrict_fsaccess_prepare(struct restrict_fsaccess_bpf **ret) {
+ return -EOPNOTSUPP;
+}
+
+int bpf_restrict_fsaccess_populate_guard(struct restrict_fsaccess_bpf *obj) {
+ return 0;
+}
+
+int bpf_restrict_fsaccess_close_initramfs_trust(Manager *m) {
+ return 0;
+}
+
+int bpf_restrict_fsaccess_serialize(Manager *m, FILE *f, FDSet *fds) {
+ return 0;
+}
+
+#endif
diff --git a/src/core/bpf-restrict-fsaccess.h b/src/core/bpf-restrict-fsaccess.h
new file mode 100644
index 0000000000..a39f602539
--- /dev/null
+++ b/src/core/bpf-restrict-fsaccess.h
@@ -0,0 +1,61 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#pragma once
+
+#include
+
+#include "core-forward.h"
+#include "macro.h"
+#include "shared-forward.h"
+
+typedef enum RestrictFileSystemAccess {
+ RESTRICT_FILESYSTEM_ACCESS_NO,
+ RESTRICT_FILESYSTEM_ACCESS_EXEC,
+ _RESTRICT_FILESYSTEM_ACCESS_MAX,
+ _RESTRICT_FILESYSTEM_ACCESS_INVALID = -EINVAL,
+} RestrictFileSystemAccess;
+
+const char* restrict_filesystem_access_to_string(RestrictFileSystemAccess i) _const_;
+RestrictFileSystemAccess restrict_filesystem_access_from_string(const char *s) _pure_;
+
+enum {
+ RESTRICT_FILESYSTEM_ACCESS_LINK_BDEV_SETINTEGRITY,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_BDEV_FREE,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_BPRM_CHECK,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_MMAP_FILE,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_FILE_MPROTECT,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_PTRACE_GUARD,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_MAP_GUARD,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_PROG_GUARD,
+ RESTRICT_FILESYSTEM_ACCESS_LINK_BPF_GUARD,
+ _RESTRICT_FILESYSTEM_ACCESS_LINK_MAX,
+};
+
+/* Maximum number of dm-verity devices tracked in the BPF hash map. */
+#define DMVERITY_DEVICES_MAX (16U*1024U)
+
+/* Convert userspace dev_t (from stat()) to kernel dev_t encoding (MKDEV).
+ * stat() returns new_encode_dev(s_dev); the BPF program reads s_dev directly
+ * which uses MKDEV(major, minor) = (major << 20) | minor. */
+#define STAT_DEV_TO_KERNEL(dev) \
+ ((uint32_t)major(dev) << 20 | (uint32_t)minor(dev))
+
+/* Mirrors the BPF program's .bss section layout for read-modify-write via
+ * bpf_map_lookup_elem/bpf_map_update_elem on the serialized .bss map FD. */
+struct restrict_fsaccess_bss {
+ uint32_t initramfs_s_dev; /* kernel dev_t encoding: (major << 20) | minor */
+ uint32_t protected_map_id_verity;
+ uint32_t protected_map_id_bss;
+ uint32_t protected_prog_ids[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX];
+ uint32_t protected_link_ids[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX];
+};
+
+extern const char* const restrict_fsaccess_link_names[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX];
+
+bool dm_verity_require_signatures(void);
+bool bpf_restrict_fsaccess_supported(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);
+
+int bpf_restrict_fsaccess_close_initramfs_trust(Manager *m);
+int bpf_restrict_fsaccess_serialize(Manager *m, FILE *f, FDSet *fds);
diff --git a/src/core/core-forward.h b/src/core/core-forward.h
index 446cf501e4..14bcc142a2 100644
--- a/src/core/core-forward.h
+++ b/src/core/core-forward.h
@@ -52,3 +52,4 @@ typedef struct Unit Unit;
typedef struct UnitRef UnitRef;
struct restrict_fs_bpf;
+struct restrict_fsaccess_bpf;
diff --git a/src/core/main.c b/src/core/main.c
index 3bdce441a8..c10df7d87a 100644
--- a/src/core/main.c
+++ b/src/core/main.c
@@ -22,6 +22,7 @@
#include "apparmor-setup.h"
#include "architecture.h"
#include "argv-util.h"
+#include "bpf-restrict-fsaccess.h"
#include "build.h"
#include "bus-error.h"
#include "capability-util.h"
@@ -150,6 +151,7 @@ static char **arg_manager_environment;
static uint64_t arg_capability_bounding_set;
static bool arg_no_new_privs;
static int arg_protect_system;
+static RestrictFileSystemAccess arg_restrict_filesystem_access;
static nsec_t arg_timer_slack_nsec;
static Set* arg_syscall_archs;
static FILE* arg_serialization;
@@ -566,6 +568,17 @@ static int parse_proc_cmdline_item(const char *key, const char *value, void *dat
return 0;
}
+ } else if (proc_cmdline_key_streq(key, "systemd.restrict_filesystem_access")) {
+
+ if (value) {
+ r = restrict_filesystem_access_from_string(value);
+ if (r < 0)
+ log_warning_errno(r, "Failed to parse systemd.restrict_filesystem_access= argument '%s', ignoring: %m", value);
+ else
+ arg_restrict_filesystem_access = r;
+ } else
+ arg_restrict_filesystem_access = RESTRICT_FILESYSTEM_ACCESS_EXEC;
+
} else if (streq(key, "quiet") && !value) {
if (arg_show_status == _SHOW_STATUS_INVALID)
@@ -717,6 +730,29 @@ static int config_parse_protect_system_pid1(
return 0;
}
+static int config_parse_restrict_filesystem_access(
+ const char *unit,
+ const char *filename,
+ unsigned line,
+ const char *section,
+ unsigned section_line,
+ const char *lvalue,
+ int ltype,
+ const char *rvalue,
+ void *data,
+ void *userdata) {
+
+ RestrictFileSystemAccess *v = ASSERT_PTR(data);
+ RestrictFileSystemAccess re;
+
+ re = restrict_filesystem_access_from_string(rvalue);
+ if (re < 0)
+ return log_syntax_parse_error(unit, filename, line, re, lvalue, rvalue);
+
+ *v = re;
+ return 0;
+}
+
static int config_parse_crash_reboot(
const char *unit,
const char *filename,
@@ -774,6 +810,7 @@ static int parse_config_file(void) {
{ "Manager", "CapabilityBoundingSet", config_parse_capability_set, 0, &arg_capability_bounding_set },
{ "Manager", "NoNewPrivileges", config_parse_bool, 0, &arg_no_new_privs },
{ "Manager", "ProtectSystem", config_parse_protect_system_pid1, 0, &arg_protect_system },
+ { "Manager", "RestrictFileSystemAccess", config_parse_restrict_filesystem_access, 0, &arg_restrict_filesystem_access },
#if HAVE_SECCOMP
{ "Manager", "SystemCallArchitectures", config_parse_syscall_archs, 0, &arg_syscall_archs },
#else
@@ -925,6 +962,7 @@ static void set_manager_settings(Manager *m) {
manager_set_show_status(m, arg_show_status, "command line");
m->status_unit_format = arg_status_unit_format;
+ m->restrict_filesystem_access = arg_restrict_filesystem_access;
}
static int parse_argv(int argc, char *argv[]) {
@@ -1247,6 +1285,16 @@ static int prepare_reexecute(
m->n_reloading++;
bus_manager_send_reloading(m, true);
+ /* Only close the initramfs trust window when actually switching root.
+ * During a plain daemon-reexec in the initrd, PID1 still needs to
+ * execv() itself from the initramfs — clearing trust here would cause
+ * the BPF bprm_check_security hook to deny the exec. */
+ if (switching_root) {
+ r = bpf_restrict_fsaccess_close_initramfs_trust(m);
+ if (r < 0)
+ return r;
+ }
+
r = manager_open_serialization(m, &f);
if (r < 0)
return log_error_errno(r, "Failed to create serialization file: %m");
@@ -2834,6 +2882,7 @@ static void reset_arguments(void) {
arg_capability_bounding_set = CAP_MASK_ALL;
arg_no_new_privs = false;
arg_protect_system = -1;
+ arg_restrict_filesystem_access = RESTRICT_FILESYSTEM_ACCESS_NO;
arg_timer_slack_nsec = NSEC_INFINITY;
arg_syscall_archs = set_free(arg_syscall_archs);
diff --git a/src/core/manager-serialize.c b/src/core/manager-serialize.c
index c794888164..528540e57e 100644
--- a/src/core/manager-serialize.c
+++ b/src/core/manager-serialize.c
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include "alloc-util.h"
+#include "bpf-restrict-fsaccess.h"
#include "dbus.h"
#include "dynamic-user.h"
#include "fd-util.h"
@@ -180,6 +181,10 @@ int manager_serialize(
if (r < 0)
return r;
+ r = bpf_restrict_fsaccess_serialize(m, f, fds);
+ if (r < 0)
+ return r;
+
(void) fputc('\n', f);
HASHMAP_FOREACH_KEY(u, t, m->units) {
@@ -386,6 +391,38 @@ static void manager_deserialize_gid_refs_one(Manager *m, const char *value) {
manager_deserialize_uid_refs_one_internal(&m->gid_refs, value);
}
+static void deserialize_restrict_fsaccess(Manager *m, const char *l, FDSet *fds) {
+ const char *val;
+ int fd;
+
+ FOREACH_ELEMENT(name, restrict_fsaccess_link_names) {
+ val = startswith(l, *name);
+ if (!val)
+ continue;
+ val = startswith(val, "=");
+ if (!val)
+ continue;
+ fd = deserialize_fd(fds, val);
+ if (fd < 0) {
+ log_warning_errno(fd, "bpf-restrict-fsaccess: Failed to deserialize FD for %s: %m", *name);
+ return;
+ }
+ close_and_replace(m->restrict_fsaccess_link_fds[name - restrict_fsaccess_link_names], fd);
+ return;
+ }
+
+ val = startswith(l, "restrict-fsaccess-bss-map=");
+ if (!val)
+ return;
+
+ fd = deserialize_fd(fds, val);
+ if (fd < 0) {
+ log_warning_errno(fd, "bpf-restrict-fsaccess: Failed to deserialize FD for .bss map: %m");
+ return;
+ }
+ close_and_replace(m->restrict_fsaccess_bss_map_fd, fd);
+}
+
int manager_deserialize(Manager *m, FILE *f, FDSet *fds) {
int r;
@@ -616,7 +653,9 @@ int manager_deserialize(Manager *m, FILE *f, FDSet *fds) {
else
(void) varlink_server_deserialize_one(m->varlink_server, val, fds);
- } else if ((val = startswith(l, "dump-ratelimit=")))
+ } else if (startswith(l, "restrict-fsaccess-"))
+ deserialize_restrict_fsaccess(m, l, fds);
+ else if ((val = startswith(l, "dump-ratelimit=")))
deserialize_ratelimit(&m->dump_ratelimit, "dump-ratelimit", val);
else if ((val = startswith(l, "reload-reexec-ratelimit=")))
deserialize_ratelimit(&m->reload_reexec_ratelimit, "reload-reexec-ratelimit", val);
diff --git a/src/core/manager.c b/src/core/manager.c
index da4e9ca408..0e33273c3d 100644
--- a/src/core/manager.c
+++ b/src/core/manager.c
@@ -21,6 +21,7 @@
#include "audit-fd.h"
#include "boot-timestamps.h"
#include "bpf-restrict-fs.h"
+#include "bpf-restrict-fsaccess.h"
#include "build-path.h"
#include "bus-common-errors.h"
#include "bus-error.h"
@@ -941,8 +942,13 @@ int manager_new(RuntimeScope runtime_scope, ManagerTestRunFlags test_run_flags,
.dump_ratelimit = (const RateLimit) { .interval = 10 * USEC_PER_MINUTE, .burst = 10 },
.executor_fd = -EBADF,
+
+ .restrict_fsaccess_bss_map_fd = -EBADF,
};
+ FOREACH_ELEMENT(fd, m->restrict_fsaccess_link_fds)
+ *fd = -EBADF;
+
unit_defaults_init(&m->defaults, runtime_scope);
#if ENABLE_EFI
@@ -1784,6 +1790,8 @@ Manager* manager_free(Manager *m) {
#if BPF_FRAMEWORK
bpf_restrict_fs_destroy(m->restrict_fs);
#endif
+ close_many(m->restrict_fsaccess_link_fds, ELEMENTSOF(m->restrict_fsaccess_link_fds));
+ safe_close(m->restrict_fsaccess_bss_map_fd);
safe_close(m->executor_fd);
free(m->executor_path);
@@ -2140,6 +2148,13 @@ int manager_startup(Manager *m, FILE *serialization, FDSet *fds, const char *roo
m->send_reloading_done = true;
}
+ /* Set up RestrictFileSystemAccess= BPF LSM after deserialization (so we can detect deserialized link FDs)
+ * and before clearing switching_root (so we can close the initramfs trust window). This must
+ * run after set_manager_settings() has set m->restrict_filesystem_access. */
+ r = bpf_restrict_fsaccess_setup(m);
+ if (r < 0)
+ return r;
+
manager_ready(m);
manager_set_switching_root(m, false);
diff --git a/src/core/manager.h b/src/core/manager.h
index 9afc70b39a..fb65705d32 100644
--- a/src/core/manager.h
+++ b/src/core/manager.h
@@ -3,6 +3,7 @@
#include "sd-event.h"
+#include "bpf-restrict-fsaccess.h"
#include "cgroup.h"
#include "common-signal.h"
#include "execute.h"
@@ -479,6 +480,16 @@ typedef struct Manager {
/* Reference to RestrictFileSystems= BPF program */
struct restrict_fs_bpf *restrict_fs;
+ /* Reference to RestrictFileSystemAccess= BPF LSM program */
+ RestrictFileSystemAccess restrict_filesystem_access;
+
+ /* Raw BPF FDs extracted from the skeleton after attach. The kernel
+ * reference chain (link FD -> bpf_link -> bpf_prog -> bpf_map) keeps
+ * programs attached and map data alive. The .bss map FD is used for
+ * targeted writes (clearing initramfs_s_dev after switch_root). */
+ int restrict_fsaccess_link_fds[_RESTRICT_FILESYSTEM_ACCESS_LINK_MAX];
+ int restrict_fsaccess_bss_map_fd;
+
/* 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/core/meson.build b/src/core/meson.build
index eef53be94b..7f5845244e 100644
--- a/src/core/meson.build
+++ b/src/core/meson.build
@@ -7,6 +7,7 @@ libcore_sources = files(
'bpf-firewall.c',
'bpf-foreign.c',
'bpf-restrict-fs.c',
+ 'bpf-restrict-fsaccess.c',
'bpf-restrict-ifaces.c',
'bpf-socket-bind.c',
'bpf-bind-iface.c',
@@ -86,6 +87,10 @@ if conf.get('BPF_FRAMEWORK') == 1
endforeach
endif
+if conf.get('HAVE_LSM_INTEGRITY_TYPE') == 1
+ libcore_sources += bpf_programs_by_name['restrict-fsaccess']
+endif
+
sources += libcore_sources
load_fragment_gperf_gperf = custom_target(
diff --git a/src/core/system.conf.in b/src/core/system.conf.in
index 63d2805930..35c7cec6ef 100644
--- a/src/core/system.conf.in
+++ b/src/core/system.conf.in
@@ -41,6 +41,7 @@
#CapabilityBoundingSet=
#NoNewPrivileges=no
#ProtectSystem=auto
+#RestrictFileSystemAccess=no
#SystemCallArchitectures=
#TimerSlackNSec=
#StatusUnitFormat={{STATUS_UNIT_FORMAT_DEFAULT_STR}}
diff --git a/src/shared/bpf-dlopen.c b/src/shared/bpf-dlopen.c
index 1d2fdef781..ca74682823 100644
--- a/src/shared/bpf-dlopen.c
+++ b/src/shared/bpf-dlopen.c
@@ -38,6 +38,7 @@ DLSYM_PROTOTYPE(bpf_map_delete_elem) = NULL;
DLSYM_PROTOTYPE(bpf_map_get_fd_by_id) = NULL;
DLSYM_PROTOTYPE(bpf_map_lookup_elem) = NULL;
DLSYM_PROTOTYPE(bpf_map_update_elem) = NULL;
+DLSYM_PROTOTYPE(bpf_obj_get_info_by_fd) = NULL;
DLSYM_PROTOTYPE(bpf_object__attach_skeleton) = NULL;
DLSYM_PROTOTYPE(bpf_object__destroy_skeleton) = NULL;
DLSYM_PROTOTYPE(bpf_object__detach_skeleton) = NULL;
@@ -49,6 +50,7 @@ DLSYM_PROTOTYPE(bpf_program__attach) = NULL;
DLSYM_PROTOTYPE(bpf_program__attach_cgroup) = NULL;
DLSYM_PROTOTYPE(bpf_program__attach_lsm) = NULL;
DLSYM_PROTOTYPE(bpf_program__name) = NULL;
+DLSYM_PROTOTYPE(bpf_program__set_autoload) = NULL;
DLSYM_PROTOTYPE(libbpf_set_print) = NULL;
DLSYM_PROTOTYPE(ring_buffer__epoll_fd) = NULL;
DLSYM_PROTOTYPE(ring_buffer__free) = NULL;
@@ -154,6 +156,7 @@ int dlopen_bpf(int log_level) {
DLSYM_ARG(bpf_map_get_fd_by_id),
DLSYM_ARG(bpf_map_lookup_elem),
DLSYM_ARG(bpf_map_update_elem),
+ DLSYM_ARG(bpf_obj_get_info_by_fd),
DLSYM_ARG(bpf_object__attach_skeleton),
DLSYM_ARG(bpf_object__destroy_skeleton),
DLSYM_ARG(bpf_object__detach_skeleton),
@@ -172,6 +175,7 @@ int dlopen_bpf(int log_level) {
DLSYM_ARG_FORCE(bpf_program__attach_lsm),
#endif
DLSYM_ARG(bpf_program__name),
+ DLSYM_ARG(bpf_program__set_autoload),
DLSYM_ARG(libbpf_get_error),
DLSYM_ARG(libbpf_set_print),
DLSYM_ARG(ring_buffer__epoll_fd),
diff --git a/src/shared/bpf-dlopen.h b/src/shared/bpf-dlopen.h
index b3d14f9b5f..62ea9cf707 100644
--- a/src/shared/bpf-dlopen.h
+++ b/src/shared/bpf-dlopen.h
@@ -25,6 +25,7 @@ extern DLSYM_PROTOTYPE(bpf_map_delete_elem);
extern DLSYM_PROTOTYPE(bpf_map_get_fd_by_id);
extern DLSYM_PROTOTYPE(bpf_map_lookup_elem);
extern DLSYM_PROTOTYPE(bpf_map_update_elem);
+extern DLSYM_PROTOTYPE(bpf_obj_get_info_by_fd);
/* The *_skeleton APIs are autogenerated by bpftool, the targets can be found
* in ./build/src/core/bpf/socket-bind/socket-bind.skel.h */
extern DLSYM_PROTOTYPE(bpf_object__attach_skeleton);
@@ -38,6 +39,7 @@ extern DLSYM_PROTOTYPE(bpf_program__attach);
extern DLSYM_PROTOTYPE(bpf_program__attach_cgroup);
extern DLSYM_PROTOTYPE(bpf_program__attach_lsm);
extern DLSYM_PROTOTYPE(bpf_program__name);
+extern DLSYM_PROTOTYPE(bpf_program__set_autoload);
extern DLSYM_PROTOTYPE(libbpf_set_print);
extern DLSYM_PROTOTYPE(ring_buffer__epoll_fd);
extern DLSYM_PROTOTYPE(ring_buffer__free);
diff --git a/src/shared/bpf-link.c b/src/shared/bpf-link.c
index 95f7256a56..80a6db47de 100644
--- a/src/shared/bpf-link.c
+++ b/src/shared/bpf-link.c
@@ -19,6 +19,22 @@ bool bpf_can_link_program(struct bpf_program *prog) {
return bpf_get_error_translated(link) == -EBADF;
}
+bool bpf_can_link_lsm_program(struct bpf_program *prog) {
+ _cleanup_(bpf_link_freep) struct bpf_link *link = NULL;
+
+ assert(prog);
+
+ if (dlopen_bpf(LOG_DEBUG) < 0)
+ return false;
+
+ link = sym_bpf_program__attach_lsm(prog);
+
+ /* If bpf_program__attach_lsm fails the resulting value stores libbpf error code instead of memory
+ * pointer. That is the case when the helper is called on architectures where BPF trampoline (hence
+ * BPF_LSM_MAC attach type) is not supported. */
+ return bpf_get_error_translated(link) == 0;
+}
+
int bpf_serialize_link(FILE *f, FDSet *fds, const char *key, struct bpf_link *link) {
assert(key);
diff --git a/src/shared/bpf-link.h b/src/shared/bpf-link.h
index 79da1c2fea..4de95eb2e1 100644
--- a/src/shared/bpf-link.h
+++ b/src/shared/bpf-link.h
@@ -8,6 +8,7 @@
#include "shared-forward.h"
bool bpf_can_link_program(struct bpf_program *prog);
+bool bpf_can_link_lsm_program(struct bpf_program *prog);
int bpf_serialize_link(FILE *f, FDSet *fds, const char *key, struct bpf_link *link);
diff --git a/src/test/meson.build b/src/test/meson.build
index f13868d4e1..faf075c103 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -539,6 +539,11 @@ executables += [
'sources' : files('test-bpf-restrict-fs.c'),
'dependencies' : common_test_dependencies,
},
+ core_test_template + {
+ 'sources' : files('test-bpf-restrict-fsaccess.c'),
+ 'dependencies' : common_test_dependencies,
+ 'type' : 'manual',
+ },
core_test_template + {
'sources' : files('test-bpf-token.c'),
'dependencies' : common_test_dependencies + libbpf,
diff --git a/src/test/test-bpf-restrict-fsaccess.c b/src/test/test-bpf-restrict-fsaccess.c
new file mode 100644
index 0000000000..e95e706c27
--- /dev/null
+++ b/src/test/test-bpf-restrict-fsaccess.c
@@ -0,0 +1,222 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/*
+ * Test helper for RestrictFileSystemAccess= BPF enforcement tests.
+ *
+ * Usage:
+ * 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 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
+ *
+ * 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
+ * the rootfs) continues to work. The process holds all link FDs and blocks;
+ * when killed, close() drops the last reference synchronously.
+ */
+
+#include
+#include
+#include
+#include
+#include
+
+#include "bpf-restrict-fsaccess.h"
+#include "fd-util.h"
+#include "log.h"
+#include "string-util.h"
+#include "tests.h"
+
+/* ---- mmap/mprotect 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. */
+
+static int do_mmap_exec(const char *path) {
+ _cleanup_close_ int fd = -EBADF;
+ void *addr;
+
+ fd = open(path, O_RDONLY | O_CLOEXEC);
+ if (fd < 0)
+ return log_error_errno(errno, "Failed to open %s: %m", path);
+
+ 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;
+}
+
+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) {
+ _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);
+
+ 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;
+
+ (void) munmap(addr, 4096);
+
+ if (r < 0)
+ return log_info_errno(r, "mprotect PROT_EXEC on %s denied: %m", path);
+
+ log_info("mprotect PROT_EXEC on %s succeeded", path);
+ return 0;
+}
+
+#if BPF_FRAMEWORK && HAVE_LSM_INTEGRITY_TYPE
+#include "bpf-dlopen.h"
+#include "restrict-fsaccess-skel.h"
+
+static struct restrict_fsaccess_bpf *restrict_fsaccess_bpf_free(struct restrict_fsaccess_bpf *obj) {
+ restrict_fsaccess_bpf__destroy(obj);
+ return NULL;
+}
+
+DEFINE_TRIVIAL_CLEANUP_FUNC(struct restrict_fsaccess_bpf *, restrict_fsaccess_bpf_free);
+
+static int do_attach(void) {
+ _cleanup_(restrict_fsaccess_bpf_freep) struct restrict_fsaccess_bpf *obj = NULL;
+ struct stat st;
+ int r;
+
+ r = dlopen_bpf(LOG_ERR);
+ if (r < 0)
+ return log_error_errno(r, "Failed to dlopen libbpf: %m");
+
+ r = bpf_restrict_fsaccess_prepare(&obj);
+ if (r < 0)
+ return r;
+
+ /* Set initramfs_s_dev to rootfs s_dev so the test script keeps running */
+ if (stat("/", &st) < 0)
+ return log_error_errno(errno, "Failed to stat /: %m");
+
+ obj->bss->initramfs_s_dev = STAT_DEV_TO_KERNEL(st.st_dev);
+ log_info("Set initramfs_s_dev to %u:%u (kernel dev_t=0x%x)",
+ major(st.st_dev), minor(st.st_dev), obj->bss->initramfs_s_dev);
+
+ r = restrict_fsaccess_bpf__attach(obj);
+ if (r < 0)
+ return log_error_errno(r, "Failed to attach BPF programs: %m");
+
+ /* Populate guard globals so the guard protects our BPF objects */
+ r = bpf_restrict_fsaccess_populate_guard(obj);
+ if (r < 0)
+ return log_error_errno(r, "Failed to populate guard globals: %m");
+
+ printf("VERITY_MAP_ID=%u\n", (unsigned) obj->bss->protected_map_id_verity);
+ printf("BSS_MAP_ID=%u\n", (unsigned) obj->bss->protected_map_id_bss);
+
+ /* Print comma-separated prog and link IDs for guard tests */
+ printf("PROG_IDS=\"");
+ for (size_t i = 0; i < _RESTRICT_FILESYSTEM_ACCESS_LINK_MAX; i++)
+ printf("%s%u", i > 0 ? "," : "", (unsigned) obj->bss->protected_prog_ids[i]);
+ printf("\"\n");
+
+ printf("LINK_IDS=\"");
+ for (size_t i = 0; i < _RESTRICT_FILESYSTEM_ACCESS_LINK_MAX; i++)
+ printf("%s%u", i > 0 ? "," : "", (unsigned) obj->bss->protected_link_ids[i]);
+ printf("\"\n");
+
+ fflush(stdout);
+
+ /* Block until killed. The _cleanup_ destructor holds all link FDs via
+ * the skeleton. When this process is killed, close() on the FDs goes
+ * through bpf_link_put_direct() which synchronously detaches the
+ * trampoline before the process exits. No bpffs pins needed. */
+ log_info("BPF programs attached, waiting for signal to detach...");
+ for (;;)
+ pause();
+
+ /* unreachable — cleanup happens via signal/exit */
+}
+
+static int do_check(void) {
+ if (!bpf_restrict_fsaccess_supported()) {
+ log_error("BPF LSM is not available");
+ return -EOPNOTSUPP;
+ }
+ log_info("BPF LSM: supported");
+
+ if (!dm_verity_require_signatures()) {
+ log_error("dm-verity require_signatures is not enabled");
+ return -ENOKEY;
+ }
+ log_info("dm-verity require_signatures: enabled");
+
+ return 0;
+}
+
+int main(int argc, char *argv[]) {
+ 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 (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;
+}
+
+#else /* ! BPF_FRAMEWORK || ! HAVE_LSM_INTEGRITY_TYPE */
+
+int main(int argc, char *argv[]) {
+ 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;
+ }
+
+ log_info("BPF framework not available, attach/check not supported");
+ return 77; /* skip */
+}
+
+#endif
diff --git a/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build b/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build
new file mode 100644
index 0000000000..2f3228f010
--- /dev/null
+++ b/test/integration-tests/TEST-90-RESTRICT-FSACCESS/meson.build
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+# Config subtest: no require_signatures — tests error paths and config parsing.
+integration_tests += [
+ integration_test_template + {
+ 'name' : fs.name(meson.current_source_dir()),
+ 'vm' : true,
+ 'firmware' : 'auto',
+ 'configuration' : integration_test_template['configuration'] + {
+ 'env' : {'TEST_MATCH_SUBTEST' : 'config'},
+ },
+ },
+]
+
+# 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).
+integration_tests += [
+ integration_test_template + {
+ 'name' : fs.name(meson.current_source_dir()) + '-enforce',
+ 'vm' : true,
+ 'firmware' : 'auto',
+ 'cmdline' : integration_test_template['cmdline'] + [
+ 'dm_verity.require_signatures=1',
+ ],
+ 'configuration' : integration_test_template['configuration'] + {
+ 'command' : '/usr/lib/systemd/tests/testdata/units/' + fs.name(meson.current_source_dir()) + '.sh',
+ 'env' : {'TEST_MATCH_SUBTEST' : 'enforce'},
+ },
+ },
+]
+
+# dm-verity keyring subtest: exercise the .dm-verity keyring provisioning path
+# (kernel commit 033724b1c627, v7.0+) without UEFI/SecureBoot — boots with
+# linux-noinitrd so the .platform keyring stays empty and the only way to
+# trust the signed verity image is via the dedicated .dm-verity keyring.
+integration_tests += [
+ integration_test_template + {
+ 'name' : fs.name(meson.current_source_dir()) + '-dm-verity-keyring',
+ 'vm' : true,
+ 'cmdline' : integration_test_template['cmdline'] + [
+ 'dm_verity.require_signatures=1',
+ 'dm_verity.keyring_unsealed=1',
+ ],
+ 'configuration' : integration_test_template['configuration'] + {
+ 'command' : '/usr/lib/systemd/tests/testdata/units/' + fs.name(meson.current_source_dir()) + '.sh',
+ 'env' : {'TEST_MATCH_SUBTEST' : 'dm-verity-keyring'},
+ },
+ },
+]
diff --git a/test/integration-tests/meson.build b/test/integration-tests/meson.build
index 37ce391598..dd9eba9b67 100644
--- a/test/integration-tests/meson.build
+++ b/test/integration-tests/meson.build
@@ -102,6 +102,7 @@ foreach dirname : [
'TEST-87-AUX-UTILS-VM',
'TEST-88-UPGRADE',
'TEST-89-RESOLVED-MDNS',
+ 'TEST-90-RESTRICT-FSACCESS',
]
subdir(dirname)
endforeach
diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.config.sh b/test/units/TEST-90-RESTRICT-FSACCESS.config.sh
new file mode 100755
index 0000000000..81bd0ff414
--- /dev/null
+++ b/test/units/TEST-90-RESTRICT-FSACCESS.config.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Test RestrictFileSystemAccess= configuration parsing and graceful failure modes.
+#
+# Runs in a VM WITHOUT dm_verity.require_signatures=1, so enabling RestrictFileSystemAccess
+# triggers the require_signatures error path without activating enforcement.
+set -eux
+set -o pipefail
+
+# shellcheck source=test/units/util.sh
+. "$(dirname "$0")"/util.sh
+# shellcheck source=test/units/test-control.sh
+. "$(dirname "$0")"/test-control.sh
+
+# RestrictFileSystemAccess= requires +BPF_FRAMEWORK at compile time
+if systemctl --version | grep -F -- "-BPF_FRAMEWORK" >/dev/null; then
+ echo "BPF framework not compiled in, skipping"
+ exit 0
+fi
+
+HELPER=/usr/lib/systemd/tests/unit-tests/manual/test-bpf-restrict-fsaccess
+CURSOR_FILE=/tmp/restrict-fsaccess-config.cursor
+
+cleanup() {
+ rm -f "$CURSOR_FILE"
+ rm -f /run/systemd/system.conf.d/50-restrict-fsaccess.conf
+}
+trap cleanup EXIT
+
+disable_restrict_fsaccess() {
+ rm -f /run/systemd/system.conf.d/50-restrict-fsaccess.conf
+}
+
+# ------ Test case 1: Default (RestrictFileSystemAccess=no) — no log messages ------
+
+testcase_default_no_messages() {
+ disable_restrict_fsaccess
+
+ # Save a journal cursor so we only check messages from after this point.
+ journalctl -q -n 0 --cursor-file="$CURSOR_FILE"
+
+ systemctl daemon-reexec
+ # daemon-reexec is synchronous: PID1 has completed startup (including any
+ # RestrictFileSystemAccess= setup) and is back on D-Bus by the time it returns. PID1
+ # logs to kmsg synchronously, so messages are already in the journal.
+
+ # No RestrictFileSystemAccess-related messages should appear
+ if journalctl --cursor-file="$CURSOR_FILE" -o cat _PID=1 | grep "bpf-restrict-fsaccess" >/dev/null 2>&1; then
+ echo "Unexpected RestrictFileSystemAccess log messages with RestrictFileSystemAccess=no"
+ return 1
+ fi
+}
+
+# ------ Test case 2: require_signatures check via helper binary ------
+#
+# The helper binary runs the same precondition checks as PID1 (BPF LSM
+# availability, dm-verity require_signatures). When require_signatures is
+# off the check must fail — this verifies the C code gate without going
+# through daemon-reexec (which would kill PID1).
+
+testcase_no_require_signatures_helper() {
+ if ! kernel_supports_lsm bpf; then
+ echo "BPF LSM not available, skipping require_signatures test"
+ return 0
+ fi
+
+ # Check that the kernel has the bdev_setintegrity LSM hook in BTF.
+ # Without it the skeleton fails to load and the check reports "BPF LSM
+ # is not available" which masks the real reason.
+ if command -v bpftool >/dev/null 2>&1; then
+ if ! bpftool btf dump file /sys/kernel/btf/vmlinux 2>/dev/null | grep 'bpf_lsm_bdev_setintegrity' >/dev/null; then
+ echo "Kernel lacks bdev_setintegrity LSM hook, skipping require_signatures test"
+ return 0
+ fi
+ fi
+
+ if [[ ! -x "$HELPER" ]]; then
+ echo "Helper binary not found, skipping"
+ return 0
+ fi
+
+ # This VM boots WITHOUT require_signatures.
+ if [[ -e /sys/module/dm_verity/parameters/require_signatures ]]; then
+ local val
+ val="$(cat /sys/module/dm_verity/parameters/require_signatures)"
+ if [[ "$val" == "Y" || "$val" == "1" ]]; then
+ echo "require_signatures already enabled, skipping (enforce VM covers this)"
+ return 0
+ 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
+ echo "ERROR: helper check succeeded but require_signatures is not enabled"
+ return 1
+ fi
+ echo "Helper correctly rejected setup: require_signatures not enabled"
+}
+
+run_testcases
diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh b/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh
new file mode 100755
index 0000000000..6237483a45
--- /dev/null
+++ b/test/units/TEST-90-RESTRICT-FSACCESS.dm-verity-keyring.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Exercise the dedicated .dm-verity keyring trust path (kernel commit
+# 033724b1c627, v7.0+): boot with linux-noinitrd so .platform stays empty,
+# provision the mkosi cert into .dm-verity via keyctl, then verify a signed
+# verity image still loads and execs under the BPF policy.
+set -eux
+set -o pipefail
+
+# shellcheck source=test/units/util.sh
+. "$(dirname "$0")"/util.sh
+
+if systemctl --version | grep -F -- "-BPF_FRAMEWORK" >/dev/null; then
+ echo "BPF framework not compiled in, skipping"
+ exit 0
+fi
+
+if ! kernel_supports_lsm bpf; then
+ echo "BPF LSM not available in kernel, skipping"
+ exit 0
+fi
+
+if command -v bpftool >/dev/null 2>&1; then
+ if ! bpftool btf dump file /sys/kernel/btf/vmlinux 2>/dev/null | grep 'bpf_lsm_bdev_setintegrity' >/dev/null; then
+ echo "Kernel lacks bdev_setintegrity LSM hook, skipping"
+ exit 0
+ fi
+fi
+
+if [[ -v ASAN_OPTIONS ]]; then
+ echo "Skipping under sanitizers"
+ exit 0
+fi
+
+HELPER="/usr/lib/systemd/tests/unit-tests/manual/test-bpf-restrict-fsaccess"
+if [[ ! -x "$HELPER" ]]; then
+ echo "ERROR: test-bpf-restrict-fsaccess helper not found at $HELPER" >&2
+ 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 0
+fi
+
+if [[ ! -e /sys/module/dm_verity/parameters/require_signatures ]]; then
+ modprobe dm_verity 2>/dev/null || true
+fi
+val="$(cat /sys/module/dm_verity/parameters/require_signatures 2>/dev/null || echo)"
+if [[ "$val" != "Y" && "$val" != "1" ]]; then
+ echo "require_signatures not enabled, skipping"
+ exit 0
+fi
+
+# Provision the .dm-verity keyring. Empty description lets the kernel derive
+# one from the X.509 subject so machine_supports_verity_keyring finds the CN.
+keyid=$(openssl x509 -in /usr/share/mkosi.crt -outform DER |
+ keyctl padd asymmetric '' %:.dm-verity 2>/dev/null) || keyid=""
+if [[ -z "$keyid" ]]; then
+ echo ".dm-verity keyring not provisionable (kernel < v7.0?), skipping"
+ exit 0
+fi
+if ! keyctl restrict_keyring %:.dm-verity; then
+ keyctl unlink "$keyid" %:.dm-verity 2>/dev/null || true
+ echo "ERROR: keyctl restrict_keyring failed" >&2
+ exit 1
+fi
+echo "Provisioned .dm-verity keyring with mkosi.crt"
+
+at_exit() {
+ set +e
+ [[ -n "${HELPER_PID:-}" ]] && kill "$HELPER_PID" 2>/dev/null && wait "$HELPER_PID" 2>/dev/null || true
+ rm -rf /tmp/restrict-fsaccess-dvk-attach.out
+}
+trap at_exit EXIT
+
+HELPER_PID=
+exec 3< <(exec "$HELPER" attach)
+HELPER_PID=$!
+while IFS= read -r -t 60 line <&3; do
+ echo "$line"
+ [[ "$line" == LINK_IDS=* ]] && break
+done > /tmp/restrict-fsaccess-dvk-attach.out
+
+# Fail closed if helper died before printing the full handshake: an unattached
+# program would let the subsequent verity exec test pass trivially.
+if ! kill -0 "$HELPER_PID" 2>/dev/null; then
+ echo "ERROR: helper exited before BPF programs were attached" >&2
+ exit 1
+fi
+grep -E '^LINK_IDS="[^"]+"' /tmp/restrict-fsaccess-dvk-attach.out >/dev/null || {
+ echo "ERROR: helper did not report LINK_IDS, BPF programs not attached" >&2
+ exit 1
+}
+
+# Run a binary off the signed minimal_0 verity image. Trust path is exclusively
+# the .dm-verity keyring we just provisioned; .platform is empty under
+# linux-noinitrd.
+systemd-run --pipe --wait \
+ --property RootImage=/usr/share/minimal_0.raw \
+ bash --version >/dev/null
+echo "Execution from signed dm-verity device (via .dm-verity keyring): OK"
diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh b/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh
new file mode 100755
index 0000000000..a9db974803
--- /dev/null
+++ b/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh
@@ -0,0 +1,311 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# Test RestrictFileSystemAccess= BPF enforcement.
+#
+# Uses a C test helper to load the BPF program with initramfs_s_dev set to the
+# current rootfs s_dev, then verifies that execution from tmpfs is blocked
+# 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).
+set -eux
+set -o pipefail
+
+# shellcheck source=test/units/util.sh
+. "$(dirname "$0")"/util.sh
+
+# Skip if prerequisites not met
+if systemctl --version | grep -F -- "-BPF_FRAMEWORK" >/dev/null; then
+ echo "BPF framework not compiled in, skipping"
+ exit 0
+fi
+
+if ! kernel_supports_lsm bpf; then
+ echo "BPF LSM not available in kernel, skipping"
+ exit 0
+fi
+
+# Check that the kernel has the bdev_setintegrity LSM hook in BTF.
+# Older kernels (e.g., CentOS 9 with 5.14) lack this hook entirely.
+if command -v bpftool >/dev/null 2>&1; then
+ if ! bpftool btf dump file /sys/kernel/btf/vmlinux 2>/dev/null | grep 'bpf_lsm_bdev_setintegrity' >/dev/null; then
+ echo "Kernel lacks bdev_setintegrity LSM hook (required for RestrictFileSystemAccess=), skipping"
+ exit 0
+ fi
+fi
+
+if [[ -v ASAN_OPTIONS ]]; then
+ echo "Skipping enforcement test under sanitizers"
+ exit 0
+fi
+
+HELPER="/usr/lib/systemd/tests/unit-tests/manual/test-bpf-restrict-fsaccess"
+if [[ ! -x "$HELPER" ]]; then
+ echo "ERROR: test-bpf-restrict-fsaccess helper not found at $HELPER" >&2
+ 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 0
+fi
+
+# require_signatures is read-only — must be set via kernel cmdline
+if [[ ! -e /sys/module/dm_verity/parameters/require_signatures ]]; then
+ modprobe dm_verity 2>/dev/null || true
+fi
+if [[ ! -e /sys/module/dm_verity/parameters/require_signatures ]]; then
+ echo "dm_verity module not available, skipping enforcement test"
+ exit 0
+fi
+val="$(cat /sys/module/dm_verity/parameters/require_signatures)"
+if [[ "$val" != "Y" && "$val" != "1" ]]; then
+ echo "require_signatures not enabled (need dm-verity.require_signatures=1 on cmdline), skipping"
+ exit 0
+fi
+
+at_exit() {
+ set +e
+ # Kill the attach helper to detach BPF programs synchronously
+ [[ -n "${HELPER_PID:-}" ]] && kill "$HELPER_PID" 2>/dev/null && wait "$HELPER_PID" 2>/dev/null || true
+ # Clean up tmpfs test directories
+ umount /tmp/restrict-fsaccess-test 2>/dev/null || true
+ rm -rf /tmp/restrict-fsaccess-test
+ umount /tmp/restrict-fsaccess-baseline 2>/dev/null || true
+ rm -rf /tmp/restrict-fsaccess-baseline
+ # Clean up background processes
+ [[ -n "${SLEEP_PID:-}" ]] && kill "$SLEEP_PID" 2>/dev/null || true
+ rm -rf /tmp/restrict-fsaccess-attach.out
+}
+trap at_exit EXIT
+
+# ------ Baseline: verify tmpfs exec works WITHOUT our BPF ------
+#
+# Keep the destination basename as "true": on systems shipping uutils-coreutils
+# (or busybox) as a multicall binary, /usr/bin/true is a symlink and cp
+# dereferences it, copying the multicall binary. The dispatcher selects the
+# subcommand from basename(argv[0]), so the copy only behaves as true when
+# invoked under that name.
+
+mkdir -p /tmp/restrict-fsaccess-baseline
+mount -t tmpfs tmpfs /tmp/restrict-fsaccess-baseline
+cp /usr/bin/true /tmp/restrict-fsaccess-baseline/true
+chmod +x /tmp/restrict-fsaccess-baseline/true
+if ! /tmp/restrict-fsaccess-baseline/true 2>/dev/null; then
+ echo "WARNING: tmpfs exec blocked BEFORE BPF attach (another LSM?)" >&2
+ echo "Skipping enforcement test, baseline tmpfs exec fails"
+ umount /tmp/restrict-fsaccess-baseline; rm -rf /tmp/restrict-fsaccess-baseline
+ exit 0
+fi
+echo "Baseline: tmpfs exec works without BPF"
+umount /tmp/restrict-fsaccess-baseline; rm -rf /tmp/restrict-fsaccess-baseline
+
+# ------ Attach BPF with rootfs trusted ------
+# The helper attaches, prints map/prog/link IDs, then blocks holding FDs.
+# Kill it to detach synchronously (close() drops last ref via bpf_link_put_direct).
+
+HELPER_PID=
+exec 3< <(exec "$HELPER" attach)
+HELPER_PID=$!
+
+# Read helper output line by line until LINK_IDS= (the last line before pause()).
+# read -t 60 handles both timeout and helper crash (EOF on death).
+while IFS= read -r -t 60 line <&3; do
+ echo "$line"
+ [[ "$line" == LINK_IDS=* ]] && break
+done > /tmp/restrict-fsaccess-attach.out
+
+VERITY_MAP_ID=$(sed -n 's/^VERITY_MAP_ID=//p' /tmp/restrict-fsaccess-attach.out)
+BSS_MAP_ID=$(sed -n 's/^BSS_MAP_ID=//p' /tmp/restrict-fsaccess-attach.out)
+PROG_IDS=$(sed -n 's/^PROG_IDS="\(.*\)"$/\1/p' /tmp/restrict-fsaccess-attach.out)
+LINK_IDS=$(sed -n 's/^LINK_IDS="\(.*\)"$/\1/p' /tmp/restrict-fsaccess-attach.out)
+[[ -n "$VERITY_MAP_ID" ]] || { echo "ERROR: Failed to capture VERITY_MAP_ID from helper output" >&2; exit 1; }
+[[ -n "$BSS_MAP_ID" ]] || { echo "ERROR: Failed to capture BSS_MAP_ID from helper output" >&2; exit 1; }
+[[ -n "$PROG_IDS" ]] || { echo "ERROR: Failed to capture PROG_IDS from helper output" >&2; exit 1; }
+[[ -n "$LINK_IDS" ]] || { echo "ERROR: Failed to capture LINK_IDS from helper output" >&2; exit 1; }
+
+# ------ Test: Rootfs execution still works ------
+
+/usr/bin/true
+echo "Rootfs execution: OK"
+
+# ------ Test: Execution from tmpfs is blocked ------
+
+mkdir -p /tmp/restrict-fsaccess-test
+mount -t tmpfs tmpfs /tmp/restrict-fsaccess-test
+
+# Copy a binary to tmpfs. Basename must stay "true" for multicall coreutils
+# binaries (uutils, busybox) — see the baseline comment above.
+cp /usr/bin/true /tmp/restrict-fsaccess-test/true
+chmod +x /tmp/restrict-fsaccess-test/true
+
+# This should fail with EPERM
+if /tmp/restrict-fsaccess-test/true 2>/dev/null; then
+ echo "ERROR: Execution from tmpfs should have been blocked!" >&2
+ exit 1
+fi
+echo "Execution from tmpfs blocked: OK"
+
+# ------ Test: PROT_EXEC mmap from tmpfs is blocked (mmap_file hook) ------
+
+# 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
+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
+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
+echo "mprotect PROT_EXEC from tmpfs blocked: OK"
+
+# ------ 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).
+
+MINIMAL=/usr/share/minimal_0
+if machine_supports_verity_keyring; then
+ systemd-run --pipe --wait \
+ --property RootImage="$MINIMAL.raw" \
+ bash --version >/dev/null
+ echo "Execution from signed dm-verity device: OK"
+else
+ echo "Verity keyring trust not available, skipping positive verity test"
+fi
+
+# ------ Test: Guard blocks non-PID1 from obtaining BPF object FDs by ID ------
+
+if command -v bpftool >/dev/null 2>&1 && [[ -n "${VERITY_MAP_ID:-}" ]]; then
+ # bpftool uses BPF_MAP_GET_FD_BY_ID / BPF_PROG_GET_FD_BY_ID /
+ # BPF_LINK_GET_FD_BY_ID internally. The guard should block these for
+ # our protected IDs since we're not PID1.
+
+ # -- Map ID guard --
+ if bpftool map show id "$VERITY_MAP_ID" 2>/dev/null; then
+ echo "ERROR: bpftool should not be able to access verity_devices map (ID $VERITY_MAP_ID)!" >&2
+ exit 1
+ fi
+ echo "Guard blocked verity_devices map access: OK (ID $VERITY_MAP_ID)"
+
+ if [[ -n "${BSS_MAP_ID:-}" ]]; then
+ if bpftool map show id "$BSS_MAP_ID" 2>/dev/null; then
+ echo "ERROR: bpftool should not be able to access .bss map (ID $BSS_MAP_ID)!" >&2
+ exit 1
+ fi
+ echo "Guard blocked .bss map access: OK (ID $BSS_MAP_ID)"
+ fi
+
+ # -- Prog ID guard (defense-in-depth) --
+ if [[ -n "${PROG_IDS:-}" ]]; then
+ IFS=',' read -ra prog_ids <<< "$PROG_IDS"
+ for prog_id in "${prog_ids[@]}"; do
+ if bpftool prog show id "$prog_id" 2>/dev/null; then
+ echo "ERROR: bpftool should not be able to access protected prog (ID $prog_id)!" >&2
+ exit 1
+ fi
+ done
+ echo "Guard blocked prog access: OK (${#prog_ids[@]} IDs)"
+ fi
+
+ # -- Link ID guard (defense-in-depth) --
+ if [[ -n "${LINK_IDS:-}" ]]; then
+ IFS=',' read -ra link_ids <<< "$LINK_IDS"
+ for lid in "${link_ids[@]}"; do
+ if bpftool link show id "$lid" 2>/dev/null; then
+ echo "ERROR: bpftool should not be able to access protected link (ID $lid)!" >&2
+ exit 1
+ fi
+ done
+ echo "Guard blocked link access: OK (${#link_ids[@]} IDs)"
+ fi
+
+ # Verify the guard doesn't block unrelated BPF operations.
+ # bpftool prog list uses BPF_PROG_GET_NEXT_ID which the guard doesn't
+ # intercept (it only blocks *_GET_FD_BY_ID for specific IDs).
+ bpftool prog list >/dev/null 2>&1 || true
+ echo "Unrelated BPF operations still work: OK"
+else
+ echo "bpftool not available or map IDs not captured, skipping guard test"
+fi
+
+# ------ Test: ptrace attach to PID1 is blocked ------
+
+# dd from /proc/1/mem uses PTRACE_MODE_ATTACH_FSCREDS via mm_access().
+# Read from a valid mapped address (not offset 0 which is the unmapped NULL
+# page and would fail with -EIO even without the guard).
+PID1_ADDR=$(awk '/r-xp/ { split($1, a, "-"); print a[1]; exit }' /proc/1/maps)
+if [[ -n "$PID1_ADDR" ]]; then
+ PID1_OFFSET=$((16#$PID1_ADDR))
+ if ! dd if=/proc/1/mem of=/dev/null bs=1 count=1 skip="$PID1_OFFSET" iflag=skip_bytes 2>/dev/null; then
+ echo "Ptrace ATTACH access to PID1 blocked: OK"
+ else
+ echo "ERROR: /proc/1/mem read should have been blocked!" >&2
+ exit 1
+ fi
+else
+ echo "WARNING: Could not determine mapped address for PID1, skipping ptrace test"
+fi
+
+# Verify READ-level access to PID1 still works (monitoring tools need this)
+if cat /proc/1/status >/dev/null 2>&1; then
+ echo "Ptrace READ access to PID1 allowed: OK"
+else
+ echo "ERROR: /proc/1/status should still be readable!" >&2
+ exit 1
+fi
+
+# Verify ptrace to non-PID1 processes is unaffected
+SLEEP_PID=
+sleep 60 &
+SLEEP_PID=$!
+if cat /proc/$SLEEP_PID/status >/dev/null 2>&1; then
+ echo "Ptrace access to non-PID1 unaffected: OK"
+else
+ echo "ERROR: /proc/$SLEEP_PID/status should be readable!" >&2
+ kill "$SLEEP_PID" 2>/dev/null || true
+ exit 1
+fi
+kill "$SLEEP_PID" 2>/dev/null || true
+wait "$SLEEP_PID" 2>/dev/null || true
+SLEEP_PID=
+
+# ------ Detach and verify enforcement is lifted ------
+# Kill the helper process. close() on the link FDs goes through
+# bpf_link_put_direct() which synchronously detaches the trampoline.
+
+kill "$HELPER_PID"
+wait "$HELPER_PID" 2>/dev/null || true
+HELPER_PID=
+echo "Helper killed, BPF programs detached synchronously"
+
+if [[ -x /tmp/restrict-fsaccess-test/true ]]; then
+ /tmp/restrict-fsaccess-test/true
+ echo "Execution from tmpfs after detach: OK"
+fi
+
+umount /tmp/restrict-fsaccess-test 2>/dev/null || true
+rm -rf /tmp/restrict-fsaccess-test
+
+echo "All enforcement tests passed"
diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.sh b/test/units/TEST-90-RESTRICT-FSACCESS.sh
new file mode 100755
index 0000000000..9c2a033aa9
--- /dev/null
+++ b/test/units/TEST-90-RESTRICT-FSACCESS.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+set -eux
+set -o pipefail
+
+# shellcheck source=test/units/test-control.sh
+. "$(dirname "$0")"/test-control.sh
+
+run_subtests
+
+touch /testok