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