diff --git a/NEWS b/NEWS
index 1614fc4940..ccadaa256d 100644
--- a/NEWS
+++ b/NEWS
@@ -60,6 +60,12 @@ CHANGES WITH 262:
allows a container to be started with systemd as PID1 without
installing any unit files.
+ * RestrictFileSystemAccess= now permits execution from overlayfs
+ mounts whose file data resides on a signed and verified
+ dm-verity-protected filesystem, while still denying files in a
+ writable upper layer. This requires kernel v7.2 or newer; on older
+ kernels execution from overlayfs mounts remains denied entirely.
+
Changes in systemd-firstboot:
* The "systemd.firstboot=" kernel command line option now accepts the
diff --git a/man/systemd-system.conf.xml b/man/systemd-system.conf.xml
index e33267409b..9f70dc0c3a 100644
--- a/man/systemd-system.conf.xml
+++ b/man/systemd-system.conf.xml
@@ -553,10 +553,12 @@
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.
+ 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
+ overlayfs mounts is denied entirely. Note that only the file data is checked, so an untrusted upper
+ layer may still control a binary's ownership and setuid bits. Multi-device filesystems such as
+ btrfs are unsupported.
Note that, without further measures to secure the system, kexec can be used to circumvent this.
diff --git a/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/bpftool.conf b/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/bpftool.conf
new file mode 100644
index 0000000000..b41790a621
--- /dev/null
+++ b/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/bpftool.conf
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# bpftool was untangled in resolute; Debian ships it standalone everywhere
+
+[TriggerMatch]
+Distribution=debian
+
+[TriggerMatch]
+Distribution=ubuntu
+Release=!jammy
+Release=!noble
+
+[Content]
+Packages=bpftool
diff --git a/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/linux-tools-generic.conf b/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/linux-tools-generic.conf
new file mode 100644
index 0000000000..b777a41490
--- /dev/null
+++ b/mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/linux-tools-generic.conf
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# bpftool was untangled in resolute, needs linux-tools-generic in older releases
+
+[TriggerMatch]
+Distribution=ubuntu
+Release=|jammy
+Release=|noble
+
+[Content]
+Packages=linux-tools-generic
diff --git a/src/bpf/restrict-fsaccess.bpf.c b/src/bpf/restrict-fsaccess.bpf.c
index 13e11aff50..97d7009161 100644
--- a/src/bpf/restrict-fsaccess.bpf.c
+++ b/src/bpf/restrict-fsaccess.bpf.c
@@ -18,6 +18,11 @@
* - 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
+ *
+ * On kernels providing the bpf_real_data_inode() kfunc (v7.2+), files on
+ * union filesystems (overlayfs) are resolved to the layer hosting their
+ * data, so execution from overlays stacked on signed dm-verity devices
+ * works. Without the kfunc such files are denied, as before.
*/
/* If offsetof() is implemented via __builtin_offset() then it doesn't work on current compilers, since the
@@ -116,39 +121,100 @@ void BPF_PROG(restrict_fsaccess_bdev_free, struct block_device *bdev)
/* ---- 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)
+/* Set by PID1 between skeleton open and load (.rodata is read-only and
+ * frozen after load): true when the kernel provides the
+ * bpf_real_data_inode() kfunc (v7.2+). The verifier constant-folds the flag
+ * and removes the (then unresolvable) kfunc call on kernels without it. */
+const volatile bool have_real_data_inode = false;
+
+/* Returns the inode hosting a regular file's data: on union filesystems
+ * (overlayfs) the backing layer's inode rather than the union inode.
+ * Sleepable, hence only callable from lsm.s/ programs. Weak so that the
+ * object still loads on kernels that lack the kfunc. */
+extern struct inode *bpf_real_data_inode(struct file *file) __weak __ksym;
+
+/* Check whether a file's data lives on a trusted device.
+ * Returns 0 (allow) or -EPERM (deny).
+ *
+ * mnt_dev is the device of the filesystem the file was opened on, data_dev
+ * the device of the filesystem hosting its data; they differ only on union
+ * filesystems. The initramfs window compares mnt_dev: whether a file is on
+ * the initramfs is a question about the mounted filesystem — userspace
+ * recorded the mount's s_dev, which for a union-mount initrd is the union's
+ * anonymous device, not any backing device. The verity map is keyed by
+ * block device, so it is compared against data_dev. */
+static __always_inline int check_trusted_dev(__u32 mnt_dev, __u32 data_dev)
{
- __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)
+ if (initramfs_s_dev != 0 && mnt_dev == initramfs_s_dev)
return 0;
/* Check verity device map */
- sig_valid = bpf_map_lookup_elem(&verity_devices, &s_dev);
+ sig_valid = bpf_map_lookup_elem(&verity_devices, &data_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)
+/* Check a file as the user sees it, resolving union filesystems to the
+ * layer hosting the data. May sleep — lsm.s/ programs only.
+ *
+ * The kfunc follows the data: a metacopy upper on an untrusted layer can
+ * still own the metadata (mode, ownership, setuid) of a binary whose data —
+ * and hence verdict — comes from the trusted lower device. */
+static __always_inline int check_trusted_file(struct file *file)
{
- struct file *file;
+ struct inode *inode;
+ __u32 mnt_dev, data_dev;
- BPF_CORE_READ_INTO(&file, bprm, file);
- return check_trusted_file(file);
+ BPF_CORE_READ_INTO(&mnt_dev, file, f_inode, i_sb, s_dev);
+
+ /* No kfunc: the mount device stands in, denying union filesystems. */
+ data_dev = mnt_dev;
+
+ if (have_real_data_inode) {
+ inode = bpf_real_data_inode(file);
+ if (!inode)
+ return -EPERM;
+
+ BPF_CORE_READ_INTO(&data_dev, inode, i_sb, s_dev);
+ }
+
+ return check_trusted_dev(mnt_dev, data_dev);
}
-SEC("lsm/mmap_file")
+/* Check vma->vm_file. By the time a vma exists, stacking filesystems have
+ * already installed the backing file into it (ovl_mmap() →
+ * backing_file_mmap() → vma_set_file()), so no union resolution is needed —
+ * and unlike re-resolving, vm_file names the file the pages actually come
+ * from even after a later copy-up. */
+static __always_inline int check_trusted_vm_file(struct file *file)
+{
+ __u32 s_dev;
+
+ BPF_CORE_READ_INTO(&s_dev, file, f_inode, i_sb, s_dev);
+
+ return check_trusted_dev(s_dev, s_dev);
+}
+
+/* ---- Enforcement hooks ---- */
+
+SEC("lsm.s/bprm_check_security")
+int BPF_PROG(restrict_fsaccess_bprm_check, struct linux_binprm *bprm)
+{
+ /* Direct dereference (CO-RE-relocated via vmlinux.h's
+ * preserve_access_index) instead of bpf_probe_read(): only direct
+ * loads keep the verifier's trusted-pointer tracking. */
+ return check_trusted_file(bprm->file);
+}
+
+/* Covers more than plain mmap(): binfmt_elf maps the PT_INTERP interpreter
+ * via vm_mmap(), so ld.so is only ever checked here, not by
+ * bprm_check_security. */
+SEC("lsm.s/mmap_file")
int BPF_PROG(restrict_fsaccess_mmap_file, struct file *file, unsigned long reqprot,
unsigned long prot, unsigned long flags)
{
@@ -156,13 +222,23 @@ int BPF_PROG(restrict_fsaccess_mmap_file, struct file *file, unsigned long reqpr
if (!(prot & PROT_EXEC))
return 0;
- /* Anonymous executable mapping — no file backing, deny */
+ /* Anonymous executable mapping — no file backing, deny. The hook's
+ * file argument is __nullable since kernel v7.0 (94e948b7e684), so the
+ * verifier types it trusted-or-NULL and this check is mandatory:
+ * without it, handing file to bpf_real_data_inode() fails to load.
+ * Older kernels lack the annotation and drop the branch, but they lack
+ * the kfunc too, so it never sees a NULL file. */
if (!file)
return -EPERM;
return check_trusted_file(file);
}
+/* Deliberately not sleepable: this hook runs under mmap_write_lock, where
+ * sleeping in d_real()/verity I/O could invert the documented i_rwsem →
+ * mmap_lock order (and bpf_lsm_file_mprotect is not in the kernel's
+ * sleepable_lsm_hooks set anyway). No resolution is needed here, see
+ * check_trusted_vm_file(). */
SEC("lsm/file_mprotect")
int BPF_PROG(restrict_fsaccess_file_mprotect, struct vm_area_struct *vma,
unsigned long reqprot, unsigned long prot)
@@ -180,12 +256,15 @@ int BPF_PROG(restrict_fsaccess_file_mprotect, struct vm_area_struct *vma,
if (vm_flags & VM_EXEC)
return 0;
- /* Anonymous executable mapping — no file backing, deny */
- BPF_CORE_READ_INTO(&file, vma, vm_file);
+ /* 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
+ * and required. */
+ file = vma->vm_file;
if (!file)
return -EPERM;
- return check_trusted_file(file);
+ return check_trusted_vm_file(file);
}
/* ---- PID1 ptrace protection ----
diff --git a/src/core/bpf-restrict-fsaccess.c b/src/core/bpf-restrict-fsaccess.c
index 3e8714a891..ab746bda05 100644
--- a/src/core/bpf-restrict-fsaccess.c
+++ b/src/core/bpf-restrict-fsaccess.c
@@ -109,60 +109,109 @@ static int get_root_s_dev(uint32_t *ret) {
return 0;
}
-int bpf_restrict_fsaccess_prepare(struct restrict_fsaccess_bpf **ret) {
+/* On failure sets *reterr_load_failed to true if the verifier rejected the object, which the caller may
+ * retry with resolution off, and to false for setup errors, which are fatal and already logged. */
+static int restrict_fsaccess_load_variant(
+ bool compat,
+ bool have_real_data_inode,
+ struct restrict_fsaccess_bpf **ret,
+ bool *reterr_load_failed) {
+
_cleanup_(restrict_fsaccess_bpf_freep) struct restrict_fsaccess_bpf *obj = NULL;
int r;
assert(ret);
+ assert(reterr_load_failed);
- /* 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)
+ if (!obj) {
+ *reterr_load_failed = false;
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)
+ if (r < 0) {
+ *reterr_load_failed = false;
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 = sym_bpf_program__set_autoload(compat ? obj->progs.restrict_fsaccess_bdev_setintegrity
+ : obj->progs.restrict_fsaccess_bdev_setintegrity_compat, false);
+ if (r < 0) {
+ *reterr_load_failed = false;
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to disable %s bdev_setintegrity program: %m",
+ compat ? "full" : "compat");
+ }
+
+ /* The .rodata map is read-only and frozen once loaded — unlike the .bss globals, this flag can
+ * only be written between open and load. */
+ obj->rodata->have_real_data_inode = have_real_data_inode;
r = restrict_fsaccess_bpf__load(obj);
- if (r < 0)
- return log_error_errno(r, "bpf-restrict-fsaccess: Failed to load BPF object (compat): %m");
+ if (r < 0) {
+ *reterr_load_failed = true;
+ return r;
+ }
- log_debug("bpf-restrict-fsaccess: Loaded with compat bdev_setintegrity.");
*ret = TAKE_PTR(obj);
return 0;
}
+int bpf_restrict_fsaccess_prepare(struct restrict_fsaccess_bpf **ret) {
+ static int cached_have_real_data_inode = -1;
+ int r;
+
+ assert(ret);
+
+ /* Whether the kernel provides the kfunc cannot change at runtime, and probing it parses the
+ * whole vmlinux BTF. */
+ if (cached_have_real_data_inode < 0)
+ cached_have_real_data_inode = bpf_kernel_has_kfunc("bpf_real_data_inode");
+
+ bool have_real_data_inode = cached_have_real_data_inode;
+
+ /* Try the preferred bdev_setintegrity variant 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.
+ *
+ * Independently of that, union filesystem resolution is enabled when the kernel BTF advertises
+ * bpf_real_data_inode(). BTF only proves the function exists, not that it is registered as an
+ * LSM kfunc with the expected signature, so if loading with the resolution enabled fails, retry
+ * without it: files on union filesystems are then denied (fail closed) instead of the whole
+ * feature — and with it PID1 startup — failing. */
+ for (;;) {
+ bool load_failed;
+
+ r = restrict_fsaccess_load_variant(/* compat= */ false, have_real_data_inode, ret, &load_failed);
+ if (r >= 0) {
+ log_debug("bpf-restrict-fsaccess: Loaded with full const void * access%s.",
+ have_real_data_inode ? " and union filesystem resolution" : "");
+ return 0;
+ }
+ if (!load_failed)
+ return r;
+
+ log_debug_errno(r, "bpf-restrict-fsaccess: Full version failed to load (%m), trying compat variant.");
+
+ r = restrict_fsaccess_load_variant(/* compat= */ true, have_real_data_inode, ret, &load_failed);
+ if (r >= 0) {
+ log_debug("bpf-restrict-fsaccess: Loaded with compat bdev_setintegrity%s.",
+ have_real_data_inode ? " and union filesystem resolution" : "");
+ return 0;
+ }
+ if (!load_failed)
+ return r;
+
+ if (!have_real_data_inode)
+ return log_error_errno(r, "bpf-restrict-fsaccess: Failed to load BPF object (compat): %m");
+
+ log_warning_errno(r, "bpf-restrict-fsaccess: Kernel BTF advertises bpf_real_data_inode() but loading with it failed, "
+ "retrying without union filesystem resolution: %m");
+ have_real_data_inode = false;
+ }
+}
+
/* Partial deserialization (some FDs but not all) is fatal: continuing
* would leave enforcement incomplete. */
static int restrict_fsaccess_have_deserialized_fds(Manager *m) {
@@ -392,6 +441,10 @@ int bpf_restrict_fsaccess_setup(Manager *m) {
if (r < 0)
return r;
+ if (!obj->rodata->have_real_data_inode)
+ log_info("bpf-restrict-fsaccess: Kernel does not provide the bpf_real_data_inode() kfunc, "
+ "files on union filesystems are not resolved to their backing device and will be denied.");
+
/* 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
diff --git a/src/shared/bpf-util.c b/src/shared/bpf-util.c
index ebfc9b30bb..b1a8a25032 100644
--- a/src/shared/bpf-util.c
+++ b/src/shared/bpf-util.c
@@ -54,6 +54,9 @@ static int missing_bpf_token_create(int bpffs_fd, struct bpf_token_create_opts *
return -ENOSYS;
}
DLSYM_PROTOTYPE(bpf_token_create) = missing_bpf_token_create;
+DLSYM_PROTOTYPE(btf__find_by_name_kind) = NULL;
+DLSYM_PROTOTYPE(btf__free) = NULL;
+DLSYM_PROTOTYPE(btf__load_vmlinux_btf) = NULL;
DLSYM_PROTOTYPE(libbpf_set_print) = NULL;
DLSYM_PROTOTYPE(ring_buffer__epoll_fd) = NULL;
DLSYM_PROTOTYPE(ring_buffer__free) = NULL;
@@ -143,12 +146,18 @@ int dlopen_bpf(int log_level) {
"Neither libbpf.so.1 nor libbpf.so.0 are installed, cgroup BPF features disabled.");
/* Version-specific symbols: bpf_create_map exists only in libbpf < 1.0; bpf_map_create and
- * bpf_object__next_map only in 0.7+. bpf_token_create only in 1.5+. Unresolved prototypes keep
- * their initializers (NULL, or a fallback returning -ENOSYS for bpf_token_create). */
+ * bpf_object__next_map only in 0.7+. bpf_token_create only in 1.5+. btf__load_vmlinux_btf only in
+ * 0.5+; its two btf__* companions are older, but keep the whole probe trio optional so a missing
+ * symbol degrades bpf_kernel_has_kfunc() instead of failing dlopen_bpf() entirely. Unresolved
+ * prototypes keep their initializers (NULL, or a fallback returning -ENOSYS for
+ * bpf_token_create). */
DLSYM_OPTIONAL(bpf_dl, bpf_create_map);
DLSYM_OPTIONAL(bpf_dl, bpf_map_create);
DLSYM_OPTIONAL(bpf_dl, bpf_object__next_map);
DLSYM_OPTIONAL(bpf_dl, bpf_token_create);
+ DLSYM_OPTIONAL(bpf_dl, btf__find_by_name_kind);
+ DLSYM_OPTIONAL(bpf_dl, btf__free);
+ DLSYM_OPTIONAL(bpf_dl, btf__load_vmlinux_btf);
/* We set the print helper unconditionally. Otherwise libbpf will emit not useful log messages. */
(void) sym_libbpf_set_print(bpf_print_func);
@@ -177,4 +186,32 @@ int bpf_get_error_translated(const void *ptr) {
return r;
}
}
+
+DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(struct btf *, sym_btf__free, NULL);
+
+bool bpf_kernel_has_kfunc(const char *name) {
+ _cleanup_(sym_btf__freep) struct btf *btf = NULL;
+ int id, r;
+
+ assert(name);
+
+ if (dlopen_bpf(LOG_DEBUG) < 0)
+ return false;
+
+ if (!sym_btf__load_vmlinux_btf || !sym_btf__find_by_name_kind || !sym_btf__free) {
+ log_debug("libbpf too old to probe the kernel BTF for functions, assuming %s() is unavailable.", name);
+ return false;
+ }
+
+ btf = sym_btf__load_vmlinux_btf();
+ r = bpf_get_error_translated(btf); /* libbpf < 1.0 returns error pointers, not NULL; btf__free() copes with both */
+ if (r != 0) {
+ log_debug_errno(r, "Failed to load vmlinux BTF, assuming kfunc %s() is unavailable: %m", name);
+ return false;
+ }
+
+ id = sym_btf__find_by_name_kind(btf, name, BTF_KIND_FUNC);
+ log_debug("Kernel BTF %s %s().", id > 0 ? "provides" : "lacks", name);
+ return id > 0;
+}
#endif
diff --git a/src/shared/bpf-util.h b/src/shared/bpf-util.h
index 45c264773f..42eb9332ae 100644
--- a/src/shared/bpf-util.h
+++ b/src/shared/bpf-util.h
@@ -12,6 +12,7 @@
#endif
#include /* IWYU pragma: export */
+#include /* IWYU pragma: export */
#include /* IWYU pragma: export */
#include "dlfcn-util.h"
@@ -21,6 +22,7 @@
* - bpf_map_create, bpf_object__next_map: available since libbpf 0.7
* - bpf_token_create: available since libbpf 1.5
* - bpf_create_map: removed in libbpf 1.0
+ * - btf__load_vmlinux_btf: available since libbpf 0.5
*/
DISABLE_WARNING_REDUNDANT_DECLS;
/* NOLINTBEGIN(readability-redundant-declaration) */
@@ -30,6 +32,7 @@ extern int bpf_create_map(enum bpf_map_type, int key_size, int value_size, int m
extern int bpf_map_create(enum bpf_map_type, const char *, __u32, __u32, __u32, const struct bpf_map_create_opts *);
extern struct bpf_map* bpf_object__next_map(const struct bpf_object *obj, const struct bpf_map *map);
extern int bpf_token_create(int bpffs_fd, struct bpf_token_create_opts *opts);
+extern struct btf* btf__load_vmlinux_btf(void);
/* NOLINTEND(readability-redundant-declaration) */
REENABLE_WARNING;
@@ -65,6 +68,9 @@ extern DLSYM_PROTOTYPE(bpf_program__attach_lsm);
extern DLSYM_PROTOTYPE(bpf_program__name);
extern DLSYM_PROTOTYPE(bpf_program__set_autoload);
extern DLSYM_PROTOTYPE(bpf_token_create);
+extern DLSYM_PROTOTYPE(btf__find_by_name_kind);
+extern DLSYM_PROTOTYPE(btf__free);
+extern DLSYM_PROTOTYPE(btf__load_vmlinux_btf);
extern DLSYM_PROTOTYPE(libbpf_set_print);
extern DLSYM_PROTOTYPE(ring_buffer__epoll_fd);
extern DLSYM_PROTOTYPE(ring_buffer__free);
@@ -92,6 +98,12 @@ static inline int compat_bpf_map_create(
* this helper instead of libbpf_get_error() to ensure some of the known ones are translated into errnos
* we understand. */
int bpf_get_error_translated(const void *ptr);
+
+/* Probe the kernel's vmlinux BTF for a function of the given name, to check whether a BPF kfunc is
+ * available. This probes vmlinux BTF only, not module BTFs; and BTF advertising the function only proves
+ * it exists, not that it is registered as a kfunc for a given program type — callers must still handle
+ * program load failure. */
+bool bpf_kernel_has_kfunc(const char *name);
#endif
int dlopen_bpf(int log_level) _dlopen_loader_;
diff --git a/src/test/test-bpf-restrict-fsaccess.c b/src/test/test-bpf-restrict-fsaccess.c
index df07222478..51c8722c68 100644
--- a/src/test/test-bpf-restrict-fsaccess.c
+++ b/src/test/test-bpf-restrict-fsaccess.c
@@ -137,6 +137,7 @@ static int do_attach(void) {
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);
+ printf("HAVE_REAL_DATA_INODE=%u\n", (unsigned) obj->rodata->have_real_data_inode);
/* Print comma-separated prog and link IDs for guard tests */
printf("PROG_IDS=\"");
diff --git a/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh b/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh
index ad1093f44f..f5398f62d2 100755
--- a/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh
+++ b/test/units/TEST-90-RESTRICT-FSACCESS.enforce.sh
@@ -79,6 +79,11 @@ at_exit() {
rm -rf /tmp/restrict-fsaccess-test
umount /tmp/restrict-fsaccess-baseline 2>/dev/null || true
rm -rf /tmp/restrict-fsaccess-baseline
+ # Clean up overlay test mounts
+ umount /tmp/restrict-fsaccess-overlay/merged 2>/dev/null || true
+ umount /tmp/restrict-fsaccess-overlay/rw 2>/dev/null || true
+ systemd-dissect --umount /tmp/restrict-fsaccess-overlay/lower 2>/dev/null || true
+ rm -rf /tmp/restrict-fsaccess-overlay
# Clean up background processes
[[ -n "${SLEEP_PID:-}" ]] && kill "$SLEEP_PID" 2>/dev/null || true
rm -rf /tmp/restrict-fsaccess-attach.out
@@ -195,6 +200,81 @@ else
echo "Verity keyring trust not available, skipping positive verity test"
fi
+# ------ Test: overlayfs over a signed dm-verity lower layer ------
+# Kernels with the bpf_real_data_inode() kfunc (v7.2+) resolve files on
+# union filesystems to the layer hosting the data: execution through an
+# overlay whose lower layer sits on a signed verity device is permitted,
+# while files created in (or copied up to) an untrusted upper layer are
+# denied. Without the kfunc, everything on the overlay is denied.
+
+if command -v bpftool >/dev/null 2>&1; then
+ KERNEL_HAS_REAL_DATA_INODE=0
+ if bpftool btf dump file /sys/kernel/btf/vmlinux 2>/dev/null | grep "FUNC 'bpf_real_data_inode'" >/dev/null; then
+ KERNEL_HAS_REAL_DATA_INODE=1
+ fi
+
+ # The loader's BTF probe must agree with the kernel BTF
+ HAVE_RDI="$(sed -n 's/^HAVE_REAL_DATA_INODE=//p' /tmp/restrict-fsaccess-attach.out)"
+ if [[ "$HAVE_RDI" != "$KERNEL_HAS_REAL_DATA_INODE" ]]; then
+ echo "ERROR: Loader probed have_real_data_inode=$HAVE_RDI but kernel BTF says $KERNEL_HAS_REAL_DATA_INODE!" >&2
+ exit 1
+ fi
+
+ if machine_supports_verity_keyring; then
+ OVL=/tmp/restrict-fsaccess-overlay
+ mkdir -p "$OVL"/{lower,rw,merged}
+ systemd-dissect --mount "$MINIMAL.raw" "$OVL/lower"
+ mount -t tmpfs tmpfs "$OVL/rw"
+ mkdir -p "$OVL/rw/upper" "$OVL/rw/work"
+ # metacopy=off: a metadata-only copy-up would keep the data — and
+ # hence the verdict — on the lower layer, breaking the copy-up test
+ mount -t overlay overlay \
+ -o "lowerdir=$OVL/lower/usr,upperdir=$OVL/rw/upper,workdir=$OVL/rw/work,metacopy=off" \
+ "$OVL/merged"
+
+ if [[ "$KERNEL_HAS_REAL_DATA_INODE" == 1 ]]; then
+ # Resolution follows the data to the signed verity lower layer
+ "$OVL/merged/bin/bash" -c 'exit 0'
+ echo "Execution through overlay from signed verity lower: OK"
+
+ # A file created in the (tmpfs) upper layer is untrusted.
+ # Basename must stay "true" for multicall coreutils binaries —
+ # see the baseline comment above.
+ cp /usr/bin/true "$OVL/merged/true"
+ if "$OVL/merged/true" 2>/dev/null; then
+ echo "ERROR: Execution of upper-layer file should have been blocked!" >&2
+ exit 1
+ fi
+ echo "Execution from overlay upper layer blocked: OK"
+
+ # Copy-up moves the data to the untrusted upper layer
+ chmod 700 "$OVL/merged/bin/bash"
+ if "$OVL/merged/bin/bash" -c 'exit 0' 2>/dev/null; then
+ echo "ERROR: Execution of copied-up file should have been blocked!" >&2
+ exit 1
+ fi
+ echo "Execution of copied-up file blocked: OK"
+ else
+ # Without the kfunc all the program sees is the overlay's
+ # anonymous device: deny, even though the lower is trusted
+ if "$OVL/merged/bin/bash" -c 'exit 0' 2>/dev/null; then
+ echo "ERROR: Execution through overlay should have been blocked without bpf_real_data_inode!" >&2
+ exit 1
+ fi
+ echo "Execution through overlay blocked (no bpf_real_data_inode): OK"
+ fi
+
+ umount "$OVL/merged"
+ umount "$OVL/rw"
+ systemd-dissect --umount "$OVL/lower"
+ rm -rf "$OVL"
+ else
+ echo "Verity keyring trust not available, skipping overlay tests"
+ fi
+else
+ echo "bpftool not available, skipping overlay tests"
+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