Hi all,
I was starting to work on the memfd-exec[1] feature and observed that Landlock's scoped-IPC features (abstract UNIX sockets and signals) follow a consistent high-level model, which I'm calling a resource-accessor pattern:
Resource Process <-> Accessor Process - Resource process: owns or manages the asset - socket creator (bind/accept) - signal handler - memfd creator - Accessor process: attempts to use the asset - socket client (connect/sendto) - signal sender - memfd executor
RESOURCE-ACCESSOR PATTERN FUNDAMENTALS ======================================
This pattern appears fundamental to Landlock scoping because:
1. Consistent enforcement model: Landlock restrictions are enforced only on the accessor side; the resource side remains unconstrained across all scope types.
2. Reflects actual security boundaries: In practice, sandboxed processes typically need to access resources created by other processes, not the reverse.
3. Scalable design: This model works consistently whether processes are in parent-child relationships or independent peer domains.
4. Real-world usage patterns: Container runtimes and sandbox orchestrators routinely start multiple workers that restrict themselves independently.
CURRENT TEST COVERAGE GAP =========================
Existing self-tests cover hierarchical resource <-> accessor pairs but do not exercise the case where each task enters an independent domain. While 'sibling_domain' tests exist, they still use parent-child relationship patterns rather than true peer domains.
Current Coverage (Linear Hierarchies Only): -------------------------------------------
Type 1: Parent-Child (scoped_domains) P1 ---- P2
Type 2: Three Generations (scoped_vs_unscoped) P1 ---- P2 ---- P3
Variations tested for both types: - No domains - Various scoped domain combinations - Nested domains within inherited domains - Mixed domain types (SCOPE vs OTHER vs NONE)
Missing Coverage (True Sibling Scenarios): ------------------------------------------
Root | +-- Child A [various domain types] | +-- Child B [various domain types]
Missing test scenarios: - A <-> B cross-sibling communication - Mixed sibling domain combinations - Sibling isolation enforcement - Parent -> A, Parent -> B differential access
SOLUTION ========
This series implements the missing sibling pattern using the resource-accessor model. The tests create a fork tree that looks like this:
coordinator (no domain) | +-- resource_proc (Domain X) /* owns the resource */ | +-- accessor_proc (Domain Y) /* tries to access */
This directly addresses the missing coverage by creating two independent child processes that establish peer domains, rather than the hierarchical parent-child domains covered by existing tests.
Both children call landlock_restrict_self() for the first time, so their struct landlock_domain->parent pointers are NULL, creating true peer domains. The harness exposes four test variants:
Variant name | Resource domain | Accessor domain | Result -------------------|-----------------|-----------------|---------- none_to_none | none | none | ALLOW none_to_scoped | none | scoped | DENY scoped_to_none | scoped | none | ALLOW scoped_to_scoped | scoped | scoped (peer) | DENY
The scoped_to_scoped case was missing from current coverage.
TESTING =======
All patches apply cleanly to v6.14-rc2 and pass on landlock/master. The helpers are small and re-use the existing kselftest_harness.h fixture/variant pattern. All patches have been validated with scripts/checkpatch.pl --strict and show no warnings.
This series introduces **no kernel changes**, only selftests additions.
Feedback very welcome.
Thanks, Abhinav
[1] https://github.com/landlock-lsm/linux/issues/37
Links: - Landlock documentation: https://docs.kernel.org/userspace-api/landlock.html - Landlock LSM kernel docs: https://docs.kernel.org/security/landlock.html - Existing tests: tools/testing/selftests/landlock/scoped_*
Signed-off-by: Abhinav Saxena xandfury@gmail.com --- Abhinav Saxena (3): selftests/landlock: move sandbox_type to common selftests/landlock: add cross-domain variants selftests/landlock: add cross-domain signal tests
tools/testing/selftests/landlock/scoped_common.h | 7 + .../landlock/scoped_cross_domain_variants.h | 54 +++++ .../landlock/scoped_multiple_domain_variants.h | 7 - .../selftests/landlock/scoped_signal_test.c | 237 +++++++++++++++++++++ 4 files changed, 298 insertions(+), 7 deletions(-) --- base-commit: 5b74b2eff1eeefe43584e5b7b348c8cd3b723d38 change-id: 20250715-landlock_abstractions-dbc0aabf1063
Best regards,
The enum sandbox_type describes three execution modes for Landlock test tasks: - NO_SANDBOX: no Landlock domain - SCOPE_SANDBOX: scoped Landlock domain - OTHER_SANDBOX: placeholder for future cases
This enum was defined in scoped_multiple_domain_variants.h but is needed by upcoming cross-domain test variants. Rather than duplicate the definition, move it to scoped_common.h which is already included by all scope-related tests.
This is a pure refactor with no functional changes to test binaries.
Signed-off-by: Abhinav Saxena xandfury@gmail.com --- tools/testing/selftests/landlock/scoped_common.h | 7 +++++++ tools/testing/selftests/landlock/scoped_multiple_domain_variants.h | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/tools/testing/selftests/landlock/scoped_common.h b/tools/testing/selftests/landlock/scoped_common.h index a9a912d30c4d..08c7d732650c 100644 --- a/tools/testing/selftests/landlock/scoped_common.h +++ b/tools/testing/selftests/landlock/scoped_common.h @@ -9,6 +9,13 @@
#include <sys/types.h>
+enum sandbox_type { + NO_SANDBOX, + SCOPE_SANDBOX, + /* Any other type of sandboxing domain */ + OTHER_SANDBOX, +}; + static void create_scoped_domain(struct __test_metadata *const _metadata, const __u16 scope) { diff --git a/tools/testing/selftests/landlock/scoped_multiple_domain_variants.h b/tools/testing/selftests/landlock/scoped_multiple_domain_variants.h index bcd9a83805d0..23022c6ebece 100644 --- a/tools/testing/selftests/landlock/scoped_multiple_domain_variants.h +++ b/tools/testing/selftests/landlock/scoped_multiple_domain_variants.h @@ -5,13 +5,6 @@ * Copyright © 2024 Tahera Fahimi fahimitahera@gmail.com */
-enum sandbox_type { - NO_SANDBOX, - SCOPE_SANDBOX, - /* Any other type of sandboxing domain */ - OTHER_SANDBOX, -}; - /* clang-format on */ FIXTURE_VARIANT(scoped_vs_unscoped) {
Add scoped_cross_domain_variants.h providing shared test variants for interactions between two independent Landlock domains. Current tests only cover hierarchical (parent-child) relationships but miss the case where unrelated processes establish peer domains.
The header defines four canonical variants: - none_to_none: both processes unrestricted - none_to_scoped: only accessor process scoped - scoped_to_none: only resource process scoped - scoped_to_scoped: both processes scoped (peer domains)
This abstraction will be shared across signal, abstract UNIX socket, and future scope types (like memfd execution) to ensure comprehensive cross-domain test coverage.
Signed-off-by: Abhinav Saxena xandfury@gmail.com --- .../landlock/scoped_cross_domain_variants.h | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+)
diff --git a/tools/testing/selftests/landlock/scoped_cross_domain_variants.h b/tools/testing/selftests/landlock/scoped_cross_domain_variants.h new file mode 100644 index 000000000000..6068987a52c8 --- /dev/null +++ b/tools/testing/selftests/landlock/scoped_cross_domain_variants.h @@ -0,0 +1,54 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * Landlock self-tests - cross-domain scope variants + * + * Provides one FIXTURE_VARIANT and the four canonical combinations + * (none->none, none->scoped, scoped->none, scoped->scoped). Every test that + * checks interactions between two independently created domains + * includes this header and iterates over the variants. + * + * Variant structure: which domain each side of the interaction lives in. + * resource_domain - process that creates/owns the resource + * accessor_domain - process that uses the resource + * + * Copyright © 2025 Abhinav Saxena xandfury@gmail.com + * + */ + +FIXTURE_VARIANT(cross_domain_scope) +{ + enum sandbox_type resource_domain; + enum sandbox_type accessor_domain; +}; + +/* Four concrete combinations */ +FIXTURE_VARIANT_ADD(cross_domain_scope, none_to_none) { + .resource_domain = NO_SANDBOX, + .accessor_domain = NO_SANDBOX, +}; + +FIXTURE_VARIANT_ADD(cross_domain_scope, none_to_scoped) { + .resource_domain = NO_SANDBOX, + .accessor_domain = SCOPE_SANDBOX, +}; + +FIXTURE_VARIANT_ADD(cross_domain_scope, scoped_to_none) { + .resource_domain = SCOPE_SANDBOX, + .accessor_domain = NO_SANDBOX, +}; + +FIXTURE_VARIANT_ADD(cross_domain_scope, scoped_to_scoped) { + .resource_domain = SCOPE_SANDBOX, + .accessor_domain = SCOPE_SANDBOX, +}; + +/* + * Mapping reminder: + * SIGNAL resource = receiver accessor = sender + * ABSTRACT UNIX resource = server accessor = client + * future scopes resource = creator accessor = user + * + * Only the accessor domain is enforced; tests therefore expect: + * accessor NO_SANDBOX -> ALLOW operation + * accessor SCOPE_SANDBOX -> DENY if resource is outside its domain + */
Add cross_domain_signal test using the new cross-domain variants to validate signal delivery between independent peer domains. This fills a gap in current test coverage which only exercises hierarchical domain relationships.
The test creates a fork tree where both children call landlock_restrict_self() for the first time, ensuring their domain->parent pointers are NULL and creating true peer domains:
coordinator (no domain) | +-- resource_proc (Domain X) /* owns the resource */ | +-- accessor_proc (Domain Y) /* tries to access */
Tests verify that kill(SIGUSR1) behaves correctly across all four domain combinations, with scoped accessors properly denied (-EPERM) when attempting cross-domain signal delivery.
This establishes the resource-accessor test pattern for future scope types where Landlock restrictions apply only to the accessor side.
Signed-off-by: Abhinav Saxena xandfury@gmail.com --- .../selftests/landlock/scoped_signal_test.c | 237 +++++++++++++++++++++ 1 file changed, 237 insertions(+)
diff --git a/tools/testing/selftests/landlock/scoped_signal_test.c b/tools/testing/selftests/landlock/scoped_signal_test.c index d8bf33417619..b52eaf1f3c0a 100644 --- a/tools/testing/selftests/landlock/scoped_signal_test.c +++ b/tools/testing/selftests/landlock/scoped_signal_test.c @@ -559,4 +559,241 @@ TEST_F(fown, sigurg_socket) _metadata->exit_code = KSFT_FAIL; }
+FIXTURE(cross_domain_scope) +{ + int coordinator_to_resource_pipe[2]; /* coordinator -> resource sync */ + int coordinator_to_accessor_pipe[2]; /* coordinator -> accessor sync */ + int result_pipe[2]; /* accessor -> coordinator result */ + pid_t resource_pid; /* Domain X process */ + pid_t accessor_pid; /* Domain Y process */ +}; + +/* Include the cross-domain variants */ +#include "scoped_cross_domain_variants.h" + +FIXTURE_SETUP(cross_domain_scope) +{ + drop_caps(_metadata); + /* Create communication channels */ + ASSERT_EQ(0, pipe2(self->coordinator_to_resource_pipe, O_CLOEXEC)); + ASSERT_EQ(0, pipe2(self->coordinator_to_accessor_pipe, O_CLOEXEC)); + ASSERT_EQ(0, pipe2(self->result_pipe, O_CLOEXEC)); + + signal_received = 0; /* Reset for each test */ + self->resource_pid = -1; + self->accessor_pid = -1; +} + +FIXTURE_TEARDOWN(cross_domain_scope) +{ + close(self->coordinator_to_resource_pipe[0]); + close(self->coordinator_to_resource_pipe[1]); + close(self->coordinator_to_accessor_pipe[0]); + close(self->coordinator_to_accessor_pipe[1]); + close(self->result_pipe[0]); + close(self->result_pipe[1]); +} + +static void cross_domain_signal_handler(int sig) +{ + if (sig == SIGUSR1 || sig == SIGURG) + signal_received = 1; + else if (sig == SIGALRM) + signal_received = 2; /* Alarm timeout */ +} + +/* + * Maybe this should go into common.h or scoped_common.h so that + * we can perhaps test interactions b/w different types of sanboxes + */ +static void create_independent_domain(struct __test_metadata *_metadata, + enum sandbox_type domain_type, + const char *process_role) +{ + if (domain_type == SCOPE_SANDBOX) { + /* + * This is the critical call - first landlock_restrict_self() + * ensures domain->parent == NULL, creating true peer domains + */ + create_scoped_domain(_metadata, LANDLOCK_SCOPE_SIGNAL); + } +} + +TEST_F(cross_domain_scope, cross_domain_signal) +{ + enum sandbox_type resource_domain = variant->resource_domain; + enum sandbox_type accessor_domain = variant->accessor_domain; + + TH_LOG("Resource domain: %s", + resource_domain == NO_SANDBOX ? "unrestricted" : "scoped"); + TH_LOG("Accessor domain: %s", + accessor_domain == NO_SANDBOX ? "unrestricted" : "scoped"); + /* + * Fork tree: + * coordinator (no domain) + * ├── resource_proc (Domain X) + * └── accessor_proc (Domain Y) + */ + + /* === RESOURCE PROCESS (Domain X) === */ + self->resource_pid = fork(); + ASSERT_GE(self->resource_pid, 0); + + if (self->resource_pid == 0) { + /* Close unused pipe ends */ + + /* Don't write to coordinator */ + close(self->coordinator_to_resource_pipe[1]); + /* Don't read accessor pipe */ + close(self->coordinator_to_accessor_pipe[0]); + /* Don't write accessor pipe */ + close(self->coordinator_to_accessor_pipe[1]); + close(self->result_pipe[0]); /* Don't read results */ + close(self->result_pipe[1]); /* Don't write results */ + + /* Create independent domain */ + create_independent_domain(_metadata, resource_domain, + "RESOURCE"); + + /* Install signal handler */ + struct sigaction sa = { + .sa_handler = cross_domain_signal_handler, + .sa_flags = SA_RESTART + }; + + sigemptyset(&sa.sa_mask); + ASSERT_EQ(0, sigaction(SIGUSR1, &sa, NULL)); + ASSERT_EQ(0, sigaction(SIGALRM, &sa, NULL)); + + /* Wait for coordinator signal to start */ + char sync_byte; + ssize_t ret = read(self->coordinator_to_resource_pipe[0], + &sync_byte, 1); + ASSERT_EQ(1, ret); + close(self->coordinator_to_resource_pipe[0]); + + /* Set timeout and wait for signal */ + alarm(3); + pause(); + + /* + * Exit based on what signal was received + * 0=success, 1=timeout/failure + */ + _exit(signal_received == 1 ? 0 : 1); + } + + /* === ACCESSOR PROCESS (Domain Y) === */ + self->accessor_pid = fork(); + ASSERT_GE(self->accessor_pid, 0); + + if (self->accessor_pid == 0) { + /* Close unused pipe ends */ + + /* Don't read resource pipe */ + close(self->coordinator_to_resource_pipe[0]); + /* Don't write resource pipe */ + close(self->coordinator_to_resource_pipe[1]); + /* Don't write to coordinator */ + close(self->coordinator_to_accessor_pipe[1]); + close(self->result_pipe[0]); /* Don't read results */ + + create_independent_domain(_metadata, accessor_domain, + "ACCESSOR"); + + /* Wait for coordinator to signal start */ + char sync_byte; + ssize_t ret = read(self->coordinator_to_accessor_pipe[0], + &sync_byte, 1); + ASSERT_EQ(1, ret); + close(self->coordinator_to_accessor_pipe[0]); + + /* 200ms delay to ensure resource is in pause() */ + usleep(200000); + + /* Attempt cross-domain signal - this is the core test */ + int kill_result = kill(self->resource_pid, SIGUSR1); + int kill_errno = errno; + + /* Send results back to coordinator */ + struct { + int result; + int error; + } test_result = { kill_result, kill_errno }; + + ret = write(self->result_pipe[1], &test_result, + sizeof(test_result)); + ASSERT_EQ(sizeof(test_result), ret); + close(self->result_pipe[1]); + + _exit(0); + } + + /* === COORDINATOR PROCESS (No domain) === */ + + /* Close unused pipe ends */ + close(self->coordinator_to_resource_pipe[0]); /* Don't read from resource */ + close(self->coordinator_to_accessor_pipe[0]); /* Don't read from accessor */ + close(self->result_pipe[1]); /* Don't write results */ + + /* Give processes time to set up domains */ + usleep(100000); /* 100ms */ + + /* Signal both processes to start the test */ + char go_signal = '1'; + + ASSERT_EQ(1, + write(self->coordinator_to_resource_pipe[1], &go_signal, 1)); + + ASSERT_EQ(1, + write(self->coordinator_to_accessor_pipe[1], &go_signal, 1)); + + close(self->coordinator_to_resource_pipe[1]); + close(self->coordinator_to_accessor_pipe[1]); + + /* Collect accessor results */ + struct { + int result; + int error; + } test_result; + + ssize_t ret = + read(self->result_pipe[0], &test_result, sizeof(test_result)); + ASSERT_EQ(sizeof(test_result), ret); + close(self->result_pipe[0]); + + /* Wait for both processes to complete */ + int accessor_status, resource_status; + + /* Accessor should always exit cleanly */ + ASSERT_EQ(self->accessor_pid, + waitpid(self->accessor_pid, &accessor_status, 0)); + + ASSERT_EQ(self->resource_pid, + waitpid(self->resource_pid, &resource_status, 0)); + + EXPECT_EQ(0, WEXITSTATUS(accessor_status)); + /* Determine expected behavior based on your table */ + bool should_succeed = (accessor_domain == NO_SANDBOX); + + if (should_succeed) { + /* Signal should succeed across domains */ + EXPECT_EQ(0, test_result.result); /* kill() succeeds */ + /* resource receives signal */ + EXPECT_EQ(0, WEXITSTATUS(resource_status)); + } else { + /* Signal should be blocked by cross-domain isolation */ + EXPECT_EQ(-1, test_result.result); /* kill() fails */ + EXPECT_EQ(EPERM, test_result.error); /* with EPERM */ + /* resource times out */ + EXPECT_NE(0, WEXITSTATUS(resource_status)); + } +} + +/* Test for socket-based signals (SIGURG) across independent domains */ +TEST_F(cross_domain_scope, DISABLED_file_signal_cross_domain) +{ + SKIP(return, "Skip for now"); +} + TEST_HARNESS_MAIN
linux-kselftest-mirror@lists.linaro.org