* [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
* 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
* [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 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 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.