From 060628d332421d8796c6dff779e5d33b549389f7 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 10:32:17 +0200 Subject: [PATCH 1/7] bpf-restrict-fsaccess: use direct field access for trusted pointers The bprm_check_security and file_mprotect hooks currently fetch the file pointer with BPF_CORE_READ_INTO(), i.e. bpf_probe_read_kernel(). That hands us back a plain scalar and since 1a5c01d2508a ("bpf: Make KF_TRUSTED_ARGS the default for all kfuncs") in v7.0 such a pointer can't be passed to a kfunc at all. Dereference the fields directly instead. Both bprm->file and vma->vm_file are in the verifier's BTF_TYPE_SAFE_TRUSTED(_OR_NULL) lists so the loads give us PTR_TRUSTED registers, and vmlinux.h pushes preserve_access_index for all records so we don't lose CO-RE. No functional changes. This is just so we can hand the pointers to the bpf_real_data_inode() kfunc in the next patch. Signed-off-by: Christian Brauner (Amutable) --- src/bpf/restrict-fsaccess.bpf.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/bpf/restrict-fsaccess.bpf.c b/src/bpf/restrict-fsaccess.bpf.c index 13e11aff50..3db9c4b218 100644 --- a/src/bpf/restrict-fsaccess.bpf.c +++ b/src/bpf/restrict-fsaccess.bpf.c @@ -142,10 +142,10 @@ static __always_inline int check_trusted_file(struct file *file) 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); + /* 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); } SEC("lsm/mmap_file") @@ -180,8 +180,9 @@ 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(). */ + file = vma->vm_file; if (!file) return -EPERM; From 43914520c1b58df1eedd13b51d9f8e015fe0cb81 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 10:34:01 +0200 Subject: [PATCH 2/7] bpf-restrict-fsaccess: resolve union filesystems to the device hosting the data Execution from an overlayfs mount is currently denied even when every layer sits on a signed dm-verity device. The hooks compare file->f_inode->i_sb->s_dev against the map of trusted devices and for a file on an overlay that's the overlay's own anonymous device, never the backing block device. Kernel v7.2 grew the bpf_real_data_inode() kfunc which resolves a file to the inode hosting its data. Use it from bprm_check_security and mmap_file which both get to see the union-level file. They become lsm.s/ programs because the kfunc is sleepable. That doesn't raise our kernel requirements, both have been in sleepable_lsm_hooks since v5.11. The file_mprotect hook stays non-sleepable. It runs under mmap_write_lock where sleeping in d_real() would invert the documented i_rwsem -> mmap_lock order, and it doesn't need the kfunc anyway. By the time a vma exists ovl_mmap() has already installed the backing file into vma->vm_file. The initramfs trust window keeps comparing the mount device. If the initrd is itself a union mount then userspace only ever recorded the union's anonymous s_dev. The verity map is keyed by block device so that one gets compared against the resolved device. The kfunc is declared __weak __ksym and every call is gated on a const volatile .rodata flag that PID1 only sets when the kernel provides it. So the object still loads on pre-7.2 kernels where files on union filesystems are denied as before. The flag stays off until the loader learns to set it in a followup. The file argument of bpf_lsm_mmap_file() is annotated __nullable since v7.0 so the verifier won't let us hand it to the kfunc unless we branch on it first. Signed-off-by: Christian Brauner (Amutable) --- src/bpf/restrict-fsaccess.bpf.c | 104 ++++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/src/bpf/restrict-fsaccess.bpf.c b/src/bpf/restrict-fsaccess.bpf.c index 3db9c4b218..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,30 +121,88 @@ 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; } +/* 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 inode *inode; + __u32 mnt_dev, data_dev; + + 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); +} + +/* 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/bprm_check_security") +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 @@ -148,7 +211,10 @@ int BPF_PROG(restrict_fsaccess_bprm_check, struct linux_binprm *bprm) return check_trusted_file(bprm->file); } -SEC("lsm/mmap_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) @@ -181,12 +257,14 @@ int BPF_PROG(restrict_fsaccess_file_mprotect, struct vm_area_struct *vma, return 0; /* Anonymous executable mapping — no file backing, deny. Direct - * dereference, see restrict_fsaccess_bprm_check(). */ + * 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 ---- From cc54fb8f732ae5199c247826ffc017bb0c1ecee0 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 10:41:42 +0200 Subject: [PATCH 3/7] bpf-util: add helper to probe the kernel for a kfunc Add bpf_kernel_has_kfunc() which checks whether the kernel's vmlinux BTF advertises a function of a given name. The three btf__* symbols it needs are optional in the dlopen table. btf__load_vmlinux_btf() only exists since libbpf 0.5 and a libbpf that lacks any of them should degrade this probe, not fail dlopen_bpf() and with it all of BPF. Note, libbpf < 1.0 returns error pointers instead of NULL, so the result is checked with bpf_get_error_translated(). Finding the name doesn't tell us all that much though. It tells us that the function exists, not that it is registered as a kfunc for the program type we care about. So callers still have to be prepared for the program load to fail. Signed-off-by: Christian Brauner (Amutable) --- src/shared/bpf-util.c | 41 +++++++++++++++++++++++++++++++++++++++-- src/shared/bpf-util.h | 12 ++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) 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_; From e37151765f013f3d198093f98e811c0f0b49daa9 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 10:41:42 +0200 Subject: [PATCH 4/7] core: gate bpf_real_data_inode() use on kernel support Probe the kernel BTF for bpf_real_data_inode() and set the BPF program's .rodata flag accordingly. The flag has to be written for every open of the skeleton because .rodata is frozen once the object is loaded, so fold the two open and load attempts into a common helper. A positive BTF probe isn't a guarantee. It tells us that the function exists, not that it is registered as an LSM kfunc with the signature we expect. So if loading with resolution enabled fails anyway, retry with the flag off. Files on union filesystems are then denied like they were before which is a lot better than turning kernel drift into a boot failure. Signed-off-by: Christian Brauner (Amutable) --- src/core/bpf-restrict-fsaccess.c | 123 ++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 35 deletions(-) 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 From 8c199d4f1a3bb35615afdab838c9c54021ebec6c Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 10:41:42 +0200 Subject: [PATCH 5/7] test: cover overlayfs in TEST-90-RESTRICT-FSACCESS Have the attach helper print whether the loader enabled union filesystem resolution and assert that this agrees with the kernel BTF. Then mount the signed minimal image as an overlayfs lower layer with a tmpfs upper. On kernels with bpf_real_data_inode() executing through the overlay works while a file created in the upper layer or copied up into it is denied. Without the kfunc execution through the overlay is denied outright. The overlay uses metacopy=off so that the copy-up test really does move the data over to the untrusted upper layer. Signed-off-by: Christian Brauner (Amutable) --- src/test/test-bpf-restrict-fsaccess.c | 1 + .../TEST-90-RESTRICT-FSACCESS.enforce.sh | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) 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 From 24bbacbe7af2d940d0274f4f55324b2c461be635 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 10:41:42 +0200 Subject: [PATCH 6/7] man: document union filesystem resolution for RestrictFileSystemAccess= Replace the paragraph that claims overlayfs is always blocked. Mention that only the file data is checked, keep the btrfs note and mention the new behavior in NEWS. Signed-off-by: Christian Brauner (Amutable) --- NEWS | 6 ++++++ man/systemd-system.conf.xml | 10 ++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) 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. From 7849500cd97e74b515236b3fef9c720f67053614 Mon Sep 17 00:00:00 2001 From: Christian Brauner Date: Thu, 30 Jul 2026 12:09:54 +0200 Subject: [PATCH 7/7] mkosi: install bpftool in Debian/Ubuntu test images The centos-fedora, opensuse and postmarketos image configurations all install bpftool but the Debian/Ubuntu ones never did. Only their build image gets it via the packaging's Build-Depends. So the TEST-90-RESTRICT-FSACCESS guard and overlay subtests, both gated on 'command -v bpftool', just silently skip there. Mirror what the build image does. Plain bpftool everywhere except Ubuntu jammy and noble which still bundle it in linux-tools-generic. Signed-off-by: Christian Brauner (Amutable) --- .../debian-ubuntu/mkosi.conf.d/bpftool.conf | 13 +++++++++++++ .../mkosi.conf.d/linux-tools-generic.conf | 10 ++++++++++ 2 files changed, 23 insertions(+) create mode 100644 mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/bpftool.conf create mode 100644 mkosi/mkosi.conf.d/debian-ubuntu/mkosi.conf.d/linux-tools-generic.conf 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