From: Christian Brauner Date: Fri, 8 May 2026 08:52:18 +0000 (+0200) Subject: test: add integration tests for RestrictFileSystemAccess= BPF LSM X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=5439911f59db2aa32799b4c88e5a2c34a61367f6;p=thirdparty%2Fsystemd.git test: add integration tests for RestrictFileSystemAccess= BPF LSM Add TEST-90-RESTRICT-FSACCESS with two subtests: config subtest — Tests PID1's RestrictFileSystemAccess= configuration parsing and failure modes via system.conf drop-ins and daemon-reexec: - Default RestrictFileSystemAccess=no produces no log messages - RestrictFileSystemAccess=yes without BPF LSM logs appropriate warning - RestrictFileSystemAccess=yes without require_signatures is correctly rejected by the test helper binary's precondition check enforce subtest — Tests actual BPF LSM enforcement using a test helper binary (test-bpf-restrict-fsaccess) that loads the BPF skeleton with initramfs_s_dev set to the rootfs s_dev, pins BPF links, and exits: - Execution from rootfs continues to work (trusted via initramfs_s_dev) - Execution from tmpfs is blocked with EPERM - Execution from a signed dm-verity device is allowed, driven via systemd-run -p RootImage= against the pre-built signed minimal_0 images that mkosi ships and signs at image build time (no on-the-fly squashfs / verity hash tree / signature build required) - After BPF detach, enforcement is lifted All tests skip gracefully when prerequisites are not met (BPF LSM, BPF framework, dm-verity tools, signing keys). Signed-off-by: Christian Brauner --- diff --git a/mkosi/mkosi.conf b/mkosi/mkosi.conf index 0fbb81eeed2..ca5c061079b 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/test/meson.build b/src/test/meson.build index 966619c95f6..482f431b80a 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -544,6 +544,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 00000000000..e95e706c276 --- /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 00000000000..2f3228f0100 --- /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 7888283db81..28d8e97d935 100644 --- a/test/integration-tests/meson.build +++ b/test/integration-tests/meson.build @@ -103,6 +103,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 00000000000..81bd0ff4144 --- /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 00000000000..6237483a457 --- /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 00000000000..a9db9748036 --- /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 00000000000..9c2a033aa98 --- /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