From: "Günther Noack" <gnoack3000@gmail.com>
To: Bryam Vargas <hexlabsecurity@proton.me>
Cc: "Mickaël Salaün" <mic@digikod.net>,
"Günther Noack" <gnoack@google.com>,
"Justin Suess" <utilityemal77@gmail.com>,
"Christian Brauner" <brauner@kernel.org>,
"Paul Moore" <paul@paul-moore.com>,
"James Morris" <jmorris@namei.org>,
"Serge E . Hallyn" <serge@hallyn.com>,
linux-security-module@vger.kernel.org, stable@vger.kernel.org,
linux-kernel@vger.kernel.org
Subject: Re: [PATCH v5 2/2] selftests/landlock: test SCOPE_SIGNAL on the SIGIO/fowner pgid path
Date: Fri, 5 Jun 2026 13:50:01 +0200 [thread overview]
Message-ID: <20260605.b1f90e8b16bd@gnoack.org> (raw)
In-Reply-To: <43370e89f7a896a583bf33d1cd171d02630e61bf.1780614610.git.hexlabsecurity@proton.me>
On Thu, Jun 04, 2026 at 11:17:05PM +0000, Bryam Vargas wrote:
> Add regression tests for the LANDLOCK_SCOPE_SIGNAL handling of the
> asynchronous SIGIO delivery path (fcntl(F_SETOWN)) with a process-group
> owner.
>
> sigio_to_pgid_members covers the bypass: a sandboxed process at the head
> of its process group's PID hlist (the default after fork()) arms
> F_SETOWN(-pgrp) + O_ASYNC and triggers the fan-out; the in-domain owner
> must be signaled (proving the trigger fired) while the non-sandboxed
> member of the group, outside the domain, must not.
>
> sigio_to_pgid_self covers the same-process guarantee: the owner is
> registered from a sandboxed non-leader thread, whose domain differs from
> the thread-group leader the kernel signals for a process-group owner.
> That leader belongs to the owner's own process and must still be signaled.
>
> Without the fix the first test sees the out-of-domain member signaled and
> the second sees the owner's own leader denied.
>
> Signed-off-by: Bryam Vargas <hexlabsecurity@proton.me>
> ---
> .../selftests/landlock/scoped_signal_test.c | 183 ++++++++++++++++++
> 1 file changed, 183 insertions(+)
>
> diff --git a/tools/testing/selftests/landlock/scoped_signal_test.c b/tools/testing/selftests/landlock/scoped_signal_test.c
> index d8bf33417619..4359e0262dcf 100644
> --- a/tools/testing/selftests/landlock/scoped_signal_test.c
> +++ b/tools/testing/selftests/landlock/scoped_signal_test.c
> @@ -559,4 +559,187 @@ TEST_F(fown, sigurg_socket)
> _metadata->exit_code = KSFT_FAIL;
> }
>
> +/*
> + * Checks that LANDLOCK_SCOPE_SIGNAL is enforced on the asynchronous SIGIO
> + * delivery path (fcntl(F_SETOWN)) when the file owner is a process group.
> + *
> + * A sandboxed process sitting at the head of its process group's PID hlist
> + * (the default position right after fork()) used to escape the
> + * fcntl(F_SETOWN, -pgrp) domain recording: pid_task(pgrp, PIDTYPE_PGID)
> + * resolved to the process itself, so the same-thread-group exemption skipped
> + * recording its Landlock domain. At SIGIO time that domain was then unset and
> + * the signal fanned out to every group member, including non-sandboxed
> + * processes outside the domain.
> + */
> +TEST(sigio_to_pgid_members)
> +{
> + int trigger[2], sync_child[2];
> + char buf;
> + pid_t child;
> + int status, i;
> +
> + drop_caps(_metadata);
> +
> + /*
> + * Isolates the test in its own process group so the SIGIO fan-out stays
> + * bounded to this parent and the child forked below.
> + */
> + ASSERT_EQ(0, setpgid(0, 0));
> +
> + /* The non-sandboxed parent is the protected (out-of-domain) target. */
> + ASSERT_EQ(0, setup_signal_handler(SIGURG));
> + signal_received = 0;
> +
> + ASSERT_EQ(0, pipe2(trigger, O_CLOEXEC));
> + ASSERT_EQ(0, pipe2(sync_child, O_CLOEXEC));
> +
> + child = fork();
> + ASSERT_LE(0, child);
> + if (child == 0) {
> + /*
> + * The child inherits the parent's new process group and, just
> + * attached with hlist_add_head_rcu(), is now the head of the
> + * pgid hlist: this is the case that used to skip the recording.
> + */
> + EXPECT_EQ(0, close(sync_child[0]));
> +
> + /* In-domain positive control: the child must be signaled. */
> + ASSERT_EQ(0, setup_signal_handler(SIGURG));
> + signal_received = 0;
> +
> + create_scoped_domain(_metadata, LANDLOCK_SCOPE_SIGNAL);
> +
> + /* Owns the SIGIO source for the whole process group. */
> + ASSERT_EQ(0, fcntl(trigger[0], F_SETSIG, SIGURG));
> + ASSERT_EQ(0, fcntl(trigger[0], F_SETOWN, -getpgrp()));
> + ASSERT_EQ(0, fcntl(trigger[0], F_SETFL, O_ASYNC));
> +
> + /* Fans SIGURG out to every member of the process group. */
> + ASSERT_EQ(1, write(trigger[1], ".", 1));
> +
> + /*
> + * The sandboxed child is in its own domain and must always be
> + * signaled: this proves the SIGIO actually fired.
> + */
> + for (i = 0; i < 1000 && !signal_received; i++)
> + usleep(1000);
> + EXPECT_EQ(1, signal_received);
> +
> + ASSERT_EQ(1, write(sync_child[1], ".", 1));
> + EXPECT_EQ(0, close(sync_child[1]));
> +
> + _exit(_metadata->exit_code);
> + return;
> + }
> + EXPECT_EQ(0, close(sync_child[1]));
> + EXPECT_EQ(0, close(trigger[0]));
> + EXPECT_EQ(0, close(trigger[1]));
> +
> + /* Waits for the child to generate the SIGIO. */
> + ASSERT_EQ(1, read(sync_child[0], &buf, 1));
> + EXPECT_EQ(0, close(sync_child[0]));
> +
> + /* Lets a delivered-but-pending signal run our handler, if any. */
> + for (i = 0; i < 100 && !signal_received; i++)
> + usleep(1000);
> +
> + /*
> + * SCOPE_SIGNAL must block the fan-out to this non-sandboxed parent,
> + * which is outside the child's Landlock domain. Before the fix the
> + * parent was signaled here.
> + */
> + EXPECT_EQ(0, signal_received);
> +
> + ASSERT_EQ(child, waitpid(child, &status, 0));
> + if (WIFSIGNALED(status) || !WIFEXITED(status) ||
> + WEXITSTATUS(status) != EXIT_SUCCESS)
> + _metadata->exit_code = KSFT_FAIL;
> +}
> +
> +static void *thread_setown_scoped(void *arg)
> +{
> + const int fd = *(int *)arg;
> + int ruleset_fd;
> + const struct landlock_ruleset_attr ruleset_attr = {
> + .scoped = LANDLOCK_SCOPE_SIGNAL,
> + };
> +
> + /* Sandboxes only this non-leader thread (no thread syncing). */
> + ruleset_fd =
> + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0);
> + if (ruleset_fd < 0)
> + return (void *)THREAD_ERROR;
> + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) ||
> + landlock_restrict_self(ruleset_fd, 0)) {
> + close(ruleset_fd);
> + return (void *)THREAD_ERROR;
> + }
> + close(ruleset_fd);
> +
> + /* Makes this process group own the SIGIO source. */
> + if (fcntl(fd, F_SETSIG, SIGURG) || fcntl(fd, F_SETOWN, -getpgrp()) ||
> + fcntl(fd, F_SETFL, O_ASYNC))
> + return (void *)THREAD_ERROR;
> +
> + return (void *)THREAD_SUCCESS;
> +}
> +
> +/*
> + * Checks that the SIGIO fan-out is still delivered to the file owner's own
> + * process when fcntl(F_SETOWN, -pgrp) was issued from a sandboxed non-leader
> + * thread.
> + *
> + * The Landlock domain is recorded for a process-group owner (so out-of-domain
> + * members stay blocked, see sigio_to_pgid_members), but the kernel signals a
> + * process group through its members' thread-group leaders. Here the leader is
> + * not sandboxed and thus has a different domain than the registering thread, so
> + * the registration-time check cannot tell that it belongs to the owner's own
> + * process. hook_file_send_sigiotask() must recognize it through the recorded
> + * thread group and allow the delivery, matching the same-process guarantee of
> + * commit 18eb75f3af40. Without that exemption the leader is wrongly denied and
> + * never signaled.
> + */
> +TEST(sigio_to_pgid_self)
> +{
> + int trigger[2];
> + pthread_t thread;
> + enum thread_return ret = THREAD_INVALID;
> + int i;
> +
> + drop_caps(_metadata);
> +
> + /* Bounds the SIGIO fan-out to this process. */
> + ASSERT_EQ(0, setpgid(0, 0));
> +
> + /* The non-sandboxed thread-group leader is the SIGIO target. */
> + ASSERT_EQ(0, setup_signal_handler(SIGURG));
> + signal_received = 0;
> +
> + ASSERT_EQ(0, pipe2(trigger, O_CLOEXEC));
> +
> + /*
> + * Registers the process-group fowner from a sibling thread that
> + * sandboxes only itself, so its domain differs from the leader's.
> + */
> + ASSERT_EQ(0, pthread_create(&thread, NULL, thread_setown_scoped,
> + &trigger[0]));
> + ASSERT_EQ(0, pthread_join(thread, (void **)&ret));
> + ASSERT_EQ(THREAD_SUCCESS, ret);
> +
> + /* Fans SIGURG out to the process group. */
> + ASSERT_EQ(1, write(trigger[1], ".", 1));
> +
> + for (i = 0; i < 1000 && !signal_received; i++)
> + usleep(1000);
> +
> + /*
> + * Same-process delivery must always be allowed, even though the owner
> + * was registered from a sandboxed sibling thread.
> + */
> + EXPECT_EQ(1, signal_received);
> +
> + EXPECT_EQ(0, close(trigger[0]));
> + EXPECT_EQ(0, close(trigger[1]));
> +}
> +
> TEST_HARNESS_MAIN
> --
> 2.43.0
>
>
Reviewed-by: Günther Noack <gnoack3000@gmail.com>
prev parent reply other threads:[~2026-06-05 11:50 UTC|newest]
Thread overview: 5+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-04 23:16 [PATCH v5 0/2] landlock: fix SCOPE_SIGNAL bypass on the SIGIO/fowner path Bryam Vargas
2026-06-04 23:16 ` [PATCH v5 1/2] landlock: fix LANDLOCK_SCOPE_SIGNAL bypass on the SIGIO path Bryam Vargas
2026-06-05 11:11 ` Günther Noack
2026-06-04 23:17 ` [PATCH v5 2/2] selftests/landlock: test SCOPE_SIGNAL on the SIGIO/fowner pgid path Bryam Vargas
2026-06-05 11:50 ` Günther Noack [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260605.b1f90e8b16bd@gnoack.org \
--to=gnoack3000@gmail.com \
--cc=brauner@kernel.org \
--cc=gnoack@google.com \
--cc=hexlabsecurity@proton.me \
--cc=jmorris@namei.org \
--cc=linux-kernel@vger.kernel.org \
--cc=linux-security-module@vger.kernel.org \
--cc=mic@digikod.net \
--cc=paul@paul-moore.com \
--cc=serge@hallyn.com \
--cc=stable@vger.kernel.org \
--cc=utilityemal77@gmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.