The Linux Kernel Mailing List
 help / color / mirror / Atom feed
* [PATCH v5 0/2] landlock: fix SCOPE_SIGNAL bypass on the SIGIO/fowner path
@ 2026-06-04 23:16 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-04 23:17 ` [PATCH v5 2/2] selftests/landlock: test SCOPE_SIGNAL on the SIGIO/fowner pgid path Bryam Vargas
  0 siblings, 2 replies; 5+ messages in thread
From: Bryam Vargas @ 2026-06-04 23:16 UTC (permalink / raw)
  To: Mickaël Salaün, Günther Noack
  Cc: Justin Suess, Christian Brauner, Paul Moore, James Morris,
	Serge E . Hallyn, linux-security-module, stable, linux-kernel

This series fixes a LANDLOCK_SCOPE_SIGNAL bypass on the asynchronous SIGIO
(fcntl(F_SETOWN)) delivery path, and adds regression tests.

A sandboxed process that owns a file or socket can request a signal
(F_SETSIG, e.g. SIGKILL) to be delivered to a whole process group on I/O
readiness (F_SETOWN(-pgid) + O_ASYNC).  When it is the head of its own
process group -- the default after fork() -- that group still contains the
non-sandboxed process that launched it (a supervisor, a security monitor),
so the sandbox can signal processes that SCOPE_SIGNAL is meant to protect
from it.

Patch 1 has two parts:

  - Narrow the same-thread-group exemption in control_current_fowner() so a
    process-group fowner always records the caller's Landlock domain; the
    delivery-time check in hook_file_send_sigiotask() then runs against
    every group member.  This closes the bypass.

  - Recording the domain alone over-blocks one corner: the kernel signals a
    process group through its members' thread-group leaders, and the leader
    of the registrant's own process can carry a different Landlock domain
    than the sibling thread that armed F_SETOWN.  domain_is_scoped() would
    then deny that leader, even though commit 18eb75f3af40 requires
    same-process delivery to be allowed.  hook_task_kill() avoids this by
    checking same_thread_group() live, per recipient; the SIGIO path
    delegated the whole decision to a single registration-time check that a
    fan-out cannot honor.  So patch 1 also records the registrant's thread
    group next to its domain and exempts it at delivery, restoring the
    same-process guarantee while keeping out-of-domain group members
    blocked.

The direct kill() path (hook_task_kill) is unaffected.

Patch 2 adds two regression tests in scoped_signal_test.c:
sigio_to_pgid_members (out-of-domain member must not be signaled) and
sigio_to_pgid_self (the registrant's own process, reached through its
thread-group leader, must still be signaled).

The defect was introduced by commit 18eb75f3af40 ("landlock: Always allow
signals between threads of the same process") in v6.15, and is present in the
stable branches that backported it (6.12.y, 6.13.y, 6.14.y).
control_current_fowner() is identical across those branches.

Verified on 7.1.0-rc5 + CONFIG_SECURITY_LANDLOCK=y (same .config, only the
landlock change differs across arms):

  - unpatched: sigio_to_pgid_members fails (out-of-domain member signaled,
    bypass), sigio_to_pgid_self passes;
  - patch-1-record-only (the v4 hunk): sigio_to_pgid_members passes,
    sigio_to_pgid_self fails (the registrant's own leader is over-blocked);
  - this series: both pass, and the landlock signal-scoping suite is 21/21.

A standalone reproducer of both invariants was also built -m32 and -m64 and
run on each arm: the fix behaves identically through the i386-compat and the
x86-64 native syscall paths.

v4 -> v5 (review feedback from Günther Noack):
  - patch 1: also fix the same-process over-block introduced by recording the
    domain for a process-group fowner -- record the registrant's thread group
    (struct pid) in landlock_file_security and exempt it in
    hook_file_send_sigiotask() (task_tgid(tsk) == fown_tg), restoring the
    18eb75f3af40 guarantee for the registrant's own process;
  - patch 2: add sigio_to_pgid_self covering the non-leader-registrant /
    pgid-includes-self case;
  - drop Tested-by: Justin Suess -- patch 1 gained the delivery-time exemption
    he did not test (re-test welcome);
  - posted as a fresh top-level thread (no In-Reply-To to the v4 review).

  v4: https://lore.kernel.org/all/20260602172741.18760-1-hexlabsecurity@proton.me/
  (v1/v2 were sent to security@kernel.org while embargoed; not in a public
  archive.)

Bryam Vargas (2):
  landlock: fix LANDLOCK_SCOPE_SIGNAL bypass on the SIGIO path
  selftests/landlock: test SCOPE_SIGNAL on the SIGIO/fowner pgid path

 security/landlock/fs.c                        |  15 ++
 security/landlock/fs.h                        |  10 +
 security/landlock/task.c                      |  11 ++
 .../selftests/landlock/scoped_signal_test.c   | 183 ++++++++++++++++++
 4 files changed, 219 insertions(+)


base-commit: 6f3ed7fec72fc8979b2a8c7219c0a9fcfc8d07b5
--
2.43.0


^ permalink raw reply	[flat|nested] 5+ messages in thread

* [PATCH v5 1/2] landlock: fix LANDLOCK_SCOPE_SIGNAL bypass on the SIGIO path
  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 ` 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
  1 sibling, 1 reply; 5+ messages in thread
From: Bryam Vargas @ 2026-06-04 23:16 UTC (permalink / raw)
  To: Mickaël Salaün, Günther Noack
  Cc: Justin Suess, Christian Brauner, Paul Moore, James Morris,
	Serge E . Hallyn, linux-security-module, stable, linux-kernel

LANDLOCK_SCOPE_SIGNAL must prevent a sandboxed process from signaling
processes outside its Landlock domain.  It can be bypassed through the
asynchronous SIGIO delivery path.

A sandboxed process that owns any file or socket can arm it with
fcntl(F_SETOWN, fd, -pgid), fcntl(F_SETSIG, fd, SIGKILL) and O_ASYNC, so
that an I/O event makes the kernel deliver the chosen signal to the whole
process group.  As the head of its own process group -- the default right
after fork() -- that group also holds the non-sandboxed process that
launched it, e.g. a supervisor or a security monitor.  The sandbox can
thus kill or repeatedly signal exactly the processes SCOPE_SIGNAL is meant
to protect from it.

The scope is enforced in hook_file_send_sigiotask() against the Landlock
domain recorded at F_SETOWN time, not the live domain of the sender.
control_current_fowner() decides whether to record that domain and skips
recording it when the fowner target is in the caller's thread group --
safe only when the target is a single process sharing the caller's
credentials (PIDTYPE_PID, PIDTYPE_TGID).  For a process group
(PIDTYPE_PGID) the target resolves to the caller itself when it is the
group head, recording is skipped, and hook_file_send_sigiotask() then lets
the signal fan out to the whole group unchecked.

Record the domain for every non single-process target so the scope is
enforced against each group member at delivery time.

That recording is necessary but not sufficient on its own: the kernel
signals a process group through its members' thread-group leaders, and the
leader of the registrant's own process can carry a different Landlock
domain than the sibling thread that armed the owner.  domain_is_scoped()
would then deny that leader, even though commit 18eb75f3af40 ("landlock:
Always allow signals between threads of the same process") requires
same-process delivery to be allowed.  hook_task_kill() avoids this by
evaluating same_thread_group() live, per recipient; the SIGIO path instead
delegates the whole decision to a single registration-time check, which a
process-group fan-out cannot honor.

So also record the registrant's thread group next to its domain and exempt
it at delivery: hook_file_send_sigiotask() allows the signal whenever the
recipient belongs to the registrant's own process, restoring the
same-process guarantee while keeping out-of-domain group members blocked.
The direct kill() path (hook_task_kill) already evaluates the live domain
and is unaffected.

Fixes: 18eb75f3af40 ("landlock: Always allow signals between threads of the same process")
Cc: stable@vger.kernel.org
Signed-off-by: Bryam Vargas <hexlabsecurity@proton.me>
---
 security/landlock/fs.c   | 15 +++++++++++++++
 security/landlock/fs.h   | 10 ++++++++++
 security/landlock/task.c | 11 +++++++++++
 3 files changed, 36 insertions(+)

diff --git a/security/landlock/fs.c b/security/landlock/fs.c
index c1ecfe239032..ff2c12e38bfc 100644
--- a/security/landlock/fs.c
+++ b/security/landlock/fs.c
@@ -1909,6 +1909,15 @@ static bool control_current_fowner(struct fown_struct *const fown)
 	if (!p)
 		return true;
 
+	/*
+	 * A process-group fowner fans the signal out to every member at
+	 * delivery time, so record the domain for any non single-process
+	 * target -- even when it resolves to current as the group head -- and
+	 * let hook_file_send_sigiotask() check the live scope per recipient.
+	 */
+	if (fown->pid_type != PIDTYPE_PID && fown->pid_type != PIDTYPE_TGID)
+		return true;
+
 	return !same_thread_group(p, current);
 }
 
@@ -1916,6 +1925,7 @@ static void hook_file_set_fowner(struct file *file)
 {
 	struct landlock_ruleset *prev_dom;
 	struct landlock_cred_security fown_subject = {};
+	struct pid *prev_tg, *fown_tg = NULL;
 	size_t fown_layer = 0;
 
 	if (control_current_fowner(file_f_owner(file))) {
@@ -1928,21 +1938,26 @@ static void hook_file_set_fowner(struct file *file)
 		if (new_subject) {
 			landlock_get_ruleset(new_subject->domain);
 			fown_subject = *new_subject;
+			fown_tg = get_pid(task_tgid(current));
 		}
 	}
 
 	prev_dom = landlock_file(file)->fown_subject.domain;
+	prev_tg = landlock_file(file)->fown_tg;
 	landlock_file(file)->fown_subject = fown_subject;
+	landlock_file(file)->fown_tg = fown_tg;
 #ifdef CONFIG_AUDIT
 	landlock_file(file)->fown_layer = fown_layer;
 #endif /* CONFIG_AUDIT*/
 
 	/* May be called in an RCU read-side critical section. */
 	landlock_put_ruleset_deferred(prev_dom);
+	put_pid(prev_tg);
 }
 
 static void hook_file_free_security(struct file *file)
 {
+	put_pid(landlock_file(file)->fown_tg);
 	landlock_put_ruleset_deferred(landlock_file(file)->fown_subject.domain);
 }
 
diff --git a/security/landlock/fs.h b/security/landlock/fs.h
index bf9948941f2f..911b83669e20 100644
--- a/security/landlock/fs.h
+++ b/security/landlock/fs.h
@@ -78,6 +78,16 @@ struct landlock_file_security {
 	 * euid.
 	 */
 	struct landlock_cred_security fown_subject;
+	/**
+	 * @fown_tg: Thread group of the task that set the file owner, pinned
+	 * while @fown_subject holds a domain.  It lets
+	 * hook_file_send_sigiotask() always allow a SIGIO delivered to the
+	 * owner's own process -- e.g. the thread-group leader reached through a
+	 * process-group owner -- matching the same-process exemption of
+	 * hook_task_kill().  NULL when no domain is recorded.  Protected by
+	 * file->f_owner->lock, like @fown_subject.
+	 */
+	struct pid *fown_tg;
 };
 
 #ifdef CONFIG_AUDIT
diff --git a/security/landlock/task.c b/security/landlock/task.c
index 6d46042132ce..7ddf211f75c3 100644
--- a/security/landlock/task.c
+++ b/security/landlock/task.c
@@ -411,6 +411,17 @@ static int hook_file_send_sigiotask(struct task_struct *tsk,
 	if (!subject->domain)
 		return 0;
 
+	/*
+	 * Always allow delivery to the file owner's own process, including a
+	 * thread-group leader reached through a process-group owner.  This
+	 * mirrors hook_task_kill()'s same-process exemption and preserves the
+	 * guarantee of commit 18eb75f3af40 ("landlock: Always allow signals
+	 * between threads of the same process"), which the registration-time
+	 * check cannot honor for a process-group target.
+	 */
+	if (task_tgid(tsk) == landlock_file(fown->file)->fown_tg)
+		return 0;
+
 	scoped_guard(rcu)
 	{
 		is_scoped = domain_is_scoped(subject->domain,
-- 
2.43.0



^ permalink raw reply related	[flat|nested] 5+ messages in thread

* [PATCH v5 2/2] selftests/landlock: test SCOPE_SIGNAL on the SIGIO/fowner pgid path
  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-04 23:17 ` Bryam Vargas
  2026-06-05 11:50   ` Günther Noack
  1 sibling, 1 reply; 5+ messages in thread
From: Bryam Vargas @ 2026-06-04 23:17 UTC (permalink / raw)
  To: Mickaël Salaün, Günther Noack
  Cc: Justin Suess, Christian Brauner, Paul Moore, James Morris,
	Serge E . Hallyn, linux-security-module, stable, linux-kernel

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



^ permalink raw reply related	[flat|nested] 5+ messages in thread

* Re: [PATCH v5 1/2] landlock: fix LANDLOCK_SCOPE_SIGNAL bypass on the SIGIO path
  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
  0 siblings, 0 replies; 5+ messages in thread
From: Günther Noack @ 2026-06-05 11:11 UTC (permalink / raw)
  To: Bryam Vargas
  Cc: Mickaël Salaün, Günther Noack, Justin Suess,
	Christian Brauner, Paul Moore, James Morris, Serge E . Hallyn,
	linux-security-module, stable, linux-kernel

On Thu, Jun 04, 2026 at 11:16:56PM +0000, Bryam Vargas wrote:
> LANDLOCK_SCOPE_SIGNAL must prevent a sandboxed process from signaling
> processes outside its Landlock domain.  It can be bypassed through the
> asynchronous SIGIO delivery path.
> 
> A sandboxed process that owns any file or socket can arm it with
> fcntl(F_SETOWN, fd, -pgid), fcntl(F_SETSIG, fd, SIGKILL) and O_ASYNC, so
> that an I/O event makes the kernel deliver the chosen signal to the whole
> process group.  As the head of its own process group -- the default right
> after fork() -- that group also holds the non-sandboxed process that
> launched it, e.g. a supervisor or a security monitor.  The sandbox can
> thus kill or repeatedly signal exactly the processes SCOPE_SIGNAL is meant
> to protect from it.
> 
> The scope is enforced in hook_file_send_sigiotask() against the Landlock
> domain recorded at F_SETOWN time, not the live domain of the sender.
> control_current_fowner() decides whether to record that domain and skips
> recording it when the fowner target is in the caller's thread group --
> safe only when the target is a single process sharing the caller's
> credentials (PIDTYPE_PID, PIDTYPE_TGID).  For a process group
> (PIDTYPE_PGID) the target resolves to the caller itself when it is the
> group head, recording is skipped, and hook_file_send_sigiotask() then lets
> the signal fan out to the whole group unchecked.
> 
> Record the domain for every non single-process target so the scope is
> enforced against each group member at delivery time.
> 
> That recording is necessary but not sufficient on its own: the kernel
> signals a process group through its members' thread-group leaders, and the
> leader of the registrant's own process can carry a different Landlock
> domain than the sibling thread that armed the owner.  domain_is_scoped()
> would then deny that leader, even though commit 18eb75f3af40 ("landlock:
> Always allow signals between threads of the same process") requires
> same-process delivery to be allowed.  hook_task_kill() avoids this by
> evaluating same_thread_group() live, per recipient; the SIGIO path instead
> delegates the whole decision to a single registration-time check, which a
> process-group fan-out cannot honor.
> 
> So also record the registrant's thread group next to its domain and exempt
> it at delivery: hook_file_send_sigiotask() allows the signal whenever the
> recipient belongs to the registrant's own process, restoring the
> same-process guarantee while keeping out-of-domain group members blocked.
> The direct kill() path (hook_task_kill) already evaluates the live domain
> and is unaffected.
> 
> Fixes: 18eb75f3af40 ("landlock: Always allow signals between threads of the same process")
> Cc: stable@vger.kernel.org
> Signed-off-by: Bryam Vargas <hexlabsecurity@proton.me>
> ---
>  security/landlock/fs.c   | 15 +++++++++++++++
>  security/landlock/fs.h   | 10 ++++++++++
>  security/landlock/task.c | 11 +++++++++++
>  3 files changed, 36 insertions(+)
> 
> diff --git a/security/landlock/fs.c b/security/landlock/fs.c
> index c1ecfe239032..ff2c12e38bfc 100644
> --- a/security/landlock/fs.c
> +++ b/security/landlock/fs.c
> @@ -1909,6 +1909,15 @@ static bool control_current_fowner(struct fown_struct *const fown)
>  	if (!p)
>  		return true;
>  
> +	/*
> +	 * A process-group fowner fans the signal out to every member at
> +	 * delivery time, so record the domain for any non single-process
> +	 * target -- even when it resolves to current as the group head -- and
> +	 * let hook_file_send_sigiotask() check the live scope per recipient.
> +	 */
> +	if (fown->pid_type != PIDTYPE_PID && fown->pid_type != PIDTYPE_TGID)
> +		return true;
> +
>  	return !same_thread_group(p, current);
>  }
>  
> @@ -1916,6 +1925,7 @@ static void hook_file_set_fowner(struct file *file)
>  {
>  	struct landlock_ruleset *prev_dom;
>  	struct landlock_cred_security fown_subject = {};
> +	struct pid *prev_tg, *fown_tg = NULL;
>  	size_t fown_layer = 0;
>  
>  	if (control_current_fowner(file_f_owner(file))) {
> @@ -1928,21 +1938,26 @@ static void hook_file_set_fowner(struct file *file)
>  		if (new_subject) {
>  			landlock_get_ruleset(new_subject->domain);
>  			fown_subject = *new_subject;
> +			fown_tg = get_pid(task_tgid(current));
>  		}
>  	}
>  
>  	prev_dom = landlock_file(file)->fown_subject.domain;
> +	prev_tg = landlock_file(file)->fown_tg;
>  	landlock_file(file)->fown_subject = fown_subject;
> +	landlock_file(file)->fown_tg = fown_tg;
>  #ifdef CONFIG_AUDIT
>  	landlock_file(file)->fown_layer = fown_layer;
>  #endif /* CONFIG_AUDIT*/
>  
>  	/* May be called in an RCU read-side critical section. */
>  	landlock_put_ruleset_deferred(prev_dom);
> +	put_pid(prev_tg);
>  }
>  
>  static void hook_file_free_security(struct file *file)
>  {
> +	put_pid(landlock_file(file)->fown_tg);
>  	landlock_put_ruleset_deferred(landlock_file(file)->fown_subject.domain);
>  }
>  
> diff --git a/security/landlock/fs.h b/security/landlock/fs.h
> index bf9948941f2f..911b83669e20 100644
> --- a/security/landlock/fs.h
> +++ b/security/landlock/fs.h
> @@ -78,6 +78,16 @@ struct landlock_file_security {
>  	 * euid.
>  	 */
>  	struct landlock_cred_security fown_subject;
> +	/**
> +	 * @fown_tg: Thread group of the task that set the file owner, pinned
> +	 * while @fown_subject holds a domain.  It lets
> +	 * hook_file_send_sigiotask() always allow a SIGIO delivered to the
> +	 * owner's own process -- e.g. the thread-group leader reached through a
> +	 * process-group owner -- matching the same-process exemption of
> +	 * hook_task_kill().  NULL when no domain is recorded.  Protected by
> +	 * file->f_owner->lock, like @fown_subject.
> +	 */
> +	struct pid *fown_tg;
>  };
>  
>  #ifdef CONFIG_AUDIT
> diff --git a/security/landlock/task.c b/security/landlock/task.c
> index 6d46042132ce..7ddf211f75c3 100644
> --- a/security/landlock/task.c
> +++ b/security/landlock/task.c
> @@ -411,6 +411,17 @@ static int hook_file_send_sigiotask(struct task_struct *tsk,
>  	if (!subject->domain)
>  		return 0;
>  
> +	/*
> +	 * Always allow delivery to the file owner's own process, including a
> +	 * thread-group leader reached through a process-group owner.  This
> +	 * mirrors hook_task_kill()'s same-process exemption and preserves the
> +	 * guarantee of commit 18eb75f3af40 ("landlock: Always allow signals
> +	 * between threads of the same process"), which the registration-time
> +	 * check cannot honor for a process-group target.
> +	 */
> +	if (task_tgid(tsk) == landlock_file(fown->file)->fown_tg)
> +		return 0;
> +
>  	scoped_guard(rcu)
>  	{
>  		is_scoped = domain_is_scoped(subject->domain,
> -- 
> 2.43.0
> 
> 

Reviewed-by: Günther Noack <gnoack3000@gmail.com>

Thank you, this looks good!
–Günther

^ permalink raw reply	[flat|nested] 5+ messages in thread

* Re: [PATCH v5 2/2] selftests/landlock: test SCOPE_SIGNAL on the SIGIO/fowner pgid path
  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
  0 siblings, 0 replies; 5+ messages in thread
From: Günther Noack @ 2026-06-05 11:50 UTC (permalink / raw)
  To: Bryam Vargas
  Cc: Mickaël Salaün, Günther Noack, Justin Suess,
	Christian Brauner, Paul Moore, James Morris, Serge E . Hallyn,
	linux-security-module, stable, linux-kernel

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>

^ permalink raw reply	[flat|nested] 5+ messages in thread

end of thread, other threads:[~2026-06-05 11:50 UTC | newest]

Thread overview: 5+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox