* [RFC PATCH 0/2] Landlock signal scope and TIOCSIG
@ 2026-09-13 22:19 Christopher Lusk
2026-09-13 22:19 ` [RFC PATCH 1/2] tty: mediate TIOCSIG through task_kill LSM hooks Christopher Lusk
2026-09-13 22:19 ` [RFC PATCH 2/2] selftests/landlock: cover TIOCSIG signal scoping Christopher Lusk
0 siblings, 2 replies; 3+ messages in thread
From: Christopher Lusk @ 2026-09-13 22:19 UTC (permalink / raw)
To: Mickaël Salaün
Cc: Günther Noack, Oleg Nesterov, Jiri Slaby, Shuah Khan,
Tahera Fahimi, Paul Moore, Casey Schaufler, John Johansen,
linux-security-module, linux-kernel, linux-serial,
linux-kselftest
Landlock documents LANDLOCK_SCOPE_SIGNAL as limiting signal delivery to
processes in the same or a nested Landlock domain. A retained PTY master
can currently use TIOCSIG to deliver SIGINT, SIGQUIT, or SIGTSTP to an
out-of-domain slave foreground process group because the privileged TTY
signal path never reaches security_task_kill().
This RFC asks two questions before proposing a final interface.
First, should this be classified as SCOPE_SIGNAL under-enforcement, or as
part of Landlock's documented inherited-TTY limitation? The "Current
limitations / IOCTL support" section says that IOCTL_DEV does not affect
pre-existing descriptors, names TIOCSTI and TIOCLINUX, and recommends
closing inherited TTY descriptors. That text discusses the filesystem
IOCTL_DEV right rather than SCOPE_SIGNAL, and unlike the two named ioctls,
TIOCSIG is not CAP_SYS_ADMIN-gated. Commit 4b80320ca7ed fixed the same
effect-level class for SIGIO rather than treating the retained signal
source as exempt.
Second, if this is a bug, should TIOCSIG use the existing task_kill hook as
patch 1 demonstrates, or should it gain a dedicated TTY-signal hook which
Landlock can implement without changing other LSM policies? The prototype
is atomic with process-group delivery and behaviorally narrow to TIOCSIG,
but calling task_kill means SELinux, Smack, AppArmor, BPF LSM programs, and
future implementations also mediate this operation. The series does not
claim that cross-LSM policy change is settled.
The demonstrated generic impact is low:
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:L = 3.8. Scope is changed
because the effect reaches a process outside the sandbox authority, but
the primitive is limited to three job-control signals and no independent
integrity impact has been reproduced.
Patch 1 is the behaviorally validated proof-of-concept fix. Patch 2 is a
minimal regression test; further test polishing should follow the chosen
interface direction.
Validation used the same userspace image against the affected and patched
kernels. Across three boots per image and 32 iterations per cell:
affected: 96/96 cross-domain TIOCSIG deliveries
patched: 96/96 cross-domain TIOCSIG denials
both: 96/96 unconfined deliveries
96/96 same-domain deliveries
96/96 scoped direct-kill denials
The regression test separately fails on the affected image and passes on
the patched image, with exactly one TAP test executed in each run.
No external report or patch has been sent before this RFC. Guidance on
both classification and hook direction would be appreciated.
Christopher Lusk (2):
tty: mediate TIOCSIG through task_kill LSM hooks
selftests/landlock: cover TIOCSIG signal scoping
drivers/tty/pty.c | 8 +-
include/linux/sched/signal.h | 1 +
kernel/signal.c | 30 +++-
.../selftests/landlock/scoped_signal_test.c | 142 ++++++++++++++++++
4 files changed, 177 insertions(+), 4 deletions(-)
--
2.55.0
^ permalink raw reply [flat|nested] 3+ messages in thread
* [RFC PATCH 1/2] tty: mediate TIOCSIG through task_kill LSM hooks
2026-09-13 22:19 [RFC PATCH 0/2] Landlock signal scope and TIOCSIG Christopher Lusk
@ 2026-09-13 22:19 ` Christopher Lusk
2026-09-13 22:19 ` [RFC PATCH 2/2] selftests/landlock: cover TIOCSIG signal scoping Christopher Lusk
1 sibling, 0 replies; 3+ messages in thread
From: Christopher Lusk @ 2026-09-13 22:19 UTC (permalink / raw)
To: Mickaël Salaün
Cc: Günther Noack, Oleg Nesterov, Jiri Slaby, Shuah Khan,
Tahera Fahimi, Paul Moore, Casey Schaufler, John Johansen,
linux-security-module, linux-kernel, linux-serial,
linux-kselftest
TIOCSIG lets a PTY master holder send SIGINT, SIGQUIT, or SIGTSTP to
the slave's foreground process group. pty_signal() currently uses
kill_pgrp(..., priv=1), which represents the signal as SEND_SIG_PRIV.
check_kill_permission() consequently returns before security_task_kill().
This leaves the operation outside every task_kill LSM policy. In
particular, a task restricted with LANDLOCK_SCOPE_SIGNAL can use a
retained PTY master to signal an out-of-domain foreground process group.
Add a kill_pgrp_lsm() variant selected only by pty_signal(). It invokes
security_task_kill() for each process-group member immediately before
delivery while tasklist_lock remains held. This preserves the existing
per-recipient and partial-success semantics without a separate pre-check
race. Ordinary privileged process-group signals continue to use the
unchanged kill_pgrp() path.
This is an RFC because the policy boundary is not settled. Landlock's
IOCTL documentation warns that pre-existing TTY file descriptors remain
dangerous, while LANDLOCK_SCOPE_SIGNAL separately promises to restrict
signals to processes outside the domain hierarchy. The proposed helper
also makes SELinux, Smack, AppArmor, and other task_kill LSMs mediate
TIOCSIG for the first time. Maintainer guidance is requested on whether
this should instead use a dedicated, opt-in TTY signal hook.
Tested on x86-64 QEMU with a held-constant four-cell effect oracle.
Across three boots per image, the unpatched kernel
delivered 96/96 cross-domain scoped TIOCSIG attempts; the patched kernel
denied 96/96. Both images delivered 96/96 unconfined and 96/96
same-domain TIOCSIG controls, and denied 96/96 scoped direct-kill
anchors. There were no indeterminate cases or kernel diagnostics.
Fixes: 54a6e6bbf3be ("landlock: Add signal scoping")
Link: https://lore.kernel.org/r/56bffc24f3d0d08b45a686a48e99766b0a0821fa.1780614610.git.hexlabsecurity@proton.me
Assisted-by: Claude:claude-opus-4-8
Assisted-by: Codex:gpt-5.6-sol
Signed-off-by: Christopher Lusk <clusk@northecho.dev>
---
drivers/tty/pty.c | 8 ++++++--
include/linux/sched/signal.h | 1 +
kernel/signal.c | 30 ++++++++++++++++++++++++++++--
3 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/drivers/tty/pty.c b/drivers/tty/pty.c
index cc7f7091ed9a..8f5eea156ce4 100644
--- a/drivers/tty/pty.c
+++ b/drivers/tty/pty.c
@@ -187,6 +187,7 @@ static int pty_get_pktmode(struct tty_struct *tty, int __user *arg)
/* Send a signal to the slave */
static int pty_signal(struct tty_struct *tty, int sig)
{
+ int ret = 0;
struct pid *pgrp;
if (sig != SIGINT && sig != SIGQUIT && sig != SIGTSTP)
@@ -195,10 +196,13 @@ static int pty_signal(struct tty_struct *tty, int sig)
if (tty->link) {
pgrp = tty_get_pgrp(tty->link);
if (pgrp)
- kill_pgrp(pgrp, sig, 1);
+ ret = kill_pgrp_lsm(pgrp, sig, 1);
put_pid(pgrp);
}
- return 0;
+ /* Preserve the historical success result for an empty process group. */
+ if (ret == -ESRCH)
+ return 0;
+ return ret;
}
static void pty_flush_buffer(struct tty_struct *tty)
diff --git a/include/linux/sched/signal.h b/include/linux/sched/signal.h
index 584ae88b435e..7d6aee7256a3 100644
--- a/include/linux/sched/signal.h
+++ b/include/linux/sched/signal.h
@@ -337,6 +337,7 @@ extern int kill_pid_info(int sig, struct kernel_siginfo *info, struct pid *pid);
extern int kill_pid_usb_asyncio(int sig, int errno, sigval_t addr, struct pid *,
const struct cred *);
extern int kill_pgrp(struct pid *pid, int sig, int priv);
+int kill_pgrp_lsm(struct pid *pid, int sig, int priv);
extern int kill_pid(struct pid *pid, int sig, int priv);
extern __must_check bool do_notify_parent(struct task_struct *, int);
extern void __wake_up_parent(struct task_struct *p, struct task_struct *parent);
diff --git a/kernel/signal.c b/kernel/signal.c
index a5e15bf09d31..758393b7257d 100644
--- a/kernel/signal.c
+++ b/kernel/signal.c
@@ -1426,13 +1426,22 @@ int group_send_sig_info(int sig, struct kernel_siginfo *info,
* control characters do (^C, ^Z etc)
* - the caller must hold at least a readlock on tasklist_lock
*/
-int __kill_pgrp_info(int sig, struct kernel_siginfo *info, struct pid *pgrp)
+static int __kill_pgrp_info_filtered(int sig, struct kernel_siginfo *info,
+ struct pid *pgrp, bool check_lsm)
{
struct task_struct *p = NULL;
int ret = -ESRCH;
do_each_pid_task(pgrp, PIDTYPE_PGID, p) {
- int err = group_send_sig_info(sig, info, p, PIDTYPE_PGID);
+ int err = 0;
+
+ if (check_lsm) {
+ rcu_read_lock();
+ err = security_task_kill(p, info, sig, NULL);
+ rcu_read_unlock();
+ }
+ if (!err)
+ err = group_send_sig_info(sig, info, p, PIDTYPE_PGID);
/*
* If group_send_sig_info() succeeds at least once ret
* becomes 0 and after that the code below has no effect.
@@ -1446,6 +1455,11 @@ int __kill_pgrp_info(int sig, struct kernel_siginfo *info, struct pid *pgrp)
return ret;
}
+int __kill_pgrp_info(int sig, struct kernel_siginfo *info, struct pid *pgrp)
+{
+ return __kill_pgrp_info_filtered(sig, info, pgrp, false);
+}
+
static int kill_pid_info_type(int sig, struct kernel_siginfo *info,
struct pid *pid, enum pid_type type)
{
@@ -1886,6 +1900,18 @@ int kill_pgrp(struct pid *pid, int sig, int priv)
}
EXPORT_SYMBOL(kill_pgrp);
+int kill_pgrp_lsm(struct pid *pid, int sig, int priv)
+{
+ int ret;
+
+ read_lock(&tasklist_lock);
+ ret = __kill_pgrp_info_filtered(sig, __si_special(priv), pid, true);
+ read_unlock(&tasklist_lock);
+
+ return ret;
+}
+EXPORT_SYMBOL_GPL(kill_pgrp_lsm);
+
int kill_pid(struct pid *pid, int sig, int priv)
{
return kill_pid_info(sig, __si_special(priv), pid);
--
2.55.0
^ permalink raw reply related [flat|nested] 3+ messages in thread
* [RFC PATCH 2/2] selftests/landlock: cover TIOCSIG signal scoping
2026-09-13 22:19 [RFC PATCH 0/2] Landlock signal scope and TIOCSIG Christopher Lusk
2026-09-13 22:19 ` [RFC PATCH 1/2] tty: mediate TIOCSIG through task_kill LSM hooks Christopher Lusk
@ 2026-09-13 22:19 ` Christopher Lusk
1 sibling, 0 replies; 3+ messages in thread
From: Christopher Lusk @ 2026-09-13 22:19 UTC (permalink / raw)
To: Mickaël Salaün
Cc: Günther Noack, Oleg Nesterov, Jiri Slaby, Shuah Khan,
Tahera Fahimi, Paul Moore, Casey Schaufler, John Johansen,
linux-security-module, linux-kernel, linux-serial,
linux-kselftest
Add a focused regression test for a sandboxed PTY master holder using
TIOCSIG to signal an out-of-domain slave foreground process group.
The test observes both the ioctl result and the target's signal-handler
effect. It fails on the unpatched base because TIOCSIG succeeds and
SIGTSTP is delivered. It passes with the preceding RFC prototype because
the ioctl fails with EPERM and the target observes no signal.
The identical test binary and initramfs were booted against both kernels
under QEMU. TAP reported one failing test on the affected image and one
passing test on the patched image.
Assisted-by: Claude:claude-opus-4-8
Assisted-by: Codex:gpt-5.6-sol
Signed-off-by: Christopher Lusk <clusk@northecho.dev>
---
.../selftests/landlock/scoped_signal_test.c | 142 ++++++++++++++++++
1 file changed, 142 insertions(+)
diff --git a/tools/testing/selftests/landlock/scoped_signal_test.c b/tools/testing/selftests/landlock/scoped_signal_test.c
index 259cdcc8aa5c..9e1dbcfa07c2 100644
--- a/tools/testing/selftests/landlock/scoped_signal_test.c
+++ b/tools/testing/selftests/landlock/scoped_signal_test.c
@@ -12,6 +12,8 @@
#include <pthread.h>
#include <sched.h>
#include <signal.h>
+#include <stdio.h>
+#include <sys/ioctl.h>
#include <sys/mount.h>
#include <sys/prctl.h>
#include <sys/types.h>
@@ -681,6 +683,146 @@ TEST(sigio_to_pgid_members)
_metadata->exit_code = KSFT_FAIL;
}
+struct tiocsig_result {
+ int ret;
+ int error;
+};
+
+static void handle_tiocsig(int sig)
+{
+ if (sig == SIGTSTP)
+ signal_received = 1;
+}
+
+static int setup_tiocsig_handler(void)
+{
+ struct sigaction action = {
+ .sa_handler = handle_tiocsig,
+ .sa_flags = SA_RESTART,
+ };
+
+ if (sigemptyset(&action.sa_mask))
+ return -1;
+ return sigaction(SIGTSTP, &action, NULL);
+}
+
+static int create_pty_master(char *const slave_path,
+ const size_t slave_path_size)
+{
+ int master_fd, pty_number, unlock = 0;
+
+ master_fd = open("/dev/ptmx", O_RDWR | O_NOCTTY | O_CLOEXEC);
+ if (master_fd < 0)
+ return -1;
+ if (ioctl(master_fd, TIOCSPTLCK, &unlock) < 0 ||
+ ioctl(master_fd, TIOCGPTN, &pty_number) < 0) {
+ const int saved_errno = errno;
+
+ close(master_fd);
+ errno = saved_errno;
+ return -1;
+ }
+ if (snprintf(slave_path, slave_path_size, "/dev/pts/%d", pty_number) >=
+ (int)slave_path_size) {
+ close(master_fd);
+ errno = ENAMETOOLONG;
+ return -1;
+ }
+ return master_fd;
+}
+
+/*
+ * Checks that TIOCSIG cannot bypass LANDLOCK_SCOPE_SIGNAL when a sandboxed
+ * holder of a PTY master targets an out-of-domain foreground process group.
+ */
+TEST(tiocsig_to_foreground_pgrp)
+{
+ struct tiocsig_result result = {};
+ char slave_path[64], byte;
+ int ready[2], release[2], effect[2], report[2];
+ int master_fd, status, target_effect = -1;
+ pid_t attacker, target;
+
+ drop_caps(_metadata);
+ master_fd = create_pty_master(slave_path, sizeof(slave_path));
+ if (master_fd < 0 && errno == ENOENT)
+ SKIP(return, "Unix98 PTY not available");
+ ASSERT_LE(0, master_fd);
+ ASSERT_EQ(0, pipe2(ready, O_CLOEXEC));
+ ASSERT_EQ(0, pipe2(release, O_CLOEXEC));
+ ASSERT_EQ(0, pipe2(effect, O_CLOEXEC));
+ ASSERT_EQ(0, pipe2(report, O_CLOEXEC));
+
+ target = fork();
+ ASSERT_LE(0, target);
+ if (target == 0) {
+ int slave_fd;
+
+ EXPECT_EQ(0, close(master_fd));
+ EXPECT_EQ(0, close(ready[0]));
+ EXPECT_EQ(0, close(release[1]));
+ EXPECT_EQ(0, close(effect[0]));
+ EXPECT_EQ(0, close(report[0]));
+ EXPECT_EQ(0, close(report[1]));
+ ASSERT_LE(0, setsid());
+ slave_fd = open(slave_path, O_RDWR | O_CLOEXEC);
+ ASSERT_LE(0, slave_fd);
+ ASSERT_NE(SIG_ERR, signal(SIGTTOU, SIG_IGN));
+ ASSERT_EQ(0, setup_tiocsig_handler());
+ signal_received = 0;
+ ASSERT_EQ(0, tcsetpgrp(slave_fd, getpgrp()));
+ ASSERT_EQ(1, write(ready[1], ".", 1));
+ ASSERT_EQ(1, read(release[0], &byte, 1));
+ target_effect = signal_received;
+ ASSERT_EQ((ssize_t)sizeof(target_effect),
+ write(effect[1], &target_effect,
+ sizeof(target_effect)));
+ EXPECT_EQ(0, close(slave_fd));
+ _exit(_metadata->exit_code);
+ return;
+ }
+ EXPECT_EQ(0, close(ready[1]));
+ EXPECT_EQ(0, close(release[0]));
+ EXPECT_EQ(0, close(effect[1]));
+ ASSERT_EQ(1, read(ready[0], &byte, 1));
+
+ attacker = fork();
+ ASSERT_LE(0, attacker);
+ if (attacker == 0) {
+ EXPECT_EQ(0, close(ready[0]));
+ EXPECT_EQ(0, close(release[1]));
+ EXPECT_EQ(0, close(effect[0]));
+ EXPECT_EQ(0, close(report[0]));
+ create_scoped_domain(_metadata, LANDLOCK_SCOPE_SIGNAL);
+ errno = 0;
+ result.ret = ioctl(master_fd, TIOCSIG, SIGTSTP);
+ result.error = errno;
+ ASSERT_EQ((ssize_t)sizeof(result),
+ write(report[1], &result, sizeof(result)));
+ _exit(_metadata->exit_code);
+ return;
+ }
+ EXPECT_EQ(0, close(report[1]));
+ ASSERT_EQ((ssize_t)sizeof(result),
+ read(report[0], &result, sizeof(result)));
+ ASSERT_EQ(attacker, waitpid(attacker, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ /* Release the target only after the signal has either fired or failed. */
+ ASSERT_EQ(1, write(release[1], ".", 1));
+ ASSERT_EQ((ssize_t)sizeof(target_effect),
+ read(effect[0], &target_effect, sizeof(target_effect)));
+ ASSERT_EQ(target, waitpid(target, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ EXPECT_EQ(-1, result.ret);
+ EXPECT_EQ(EPERM, result.error);
+ EXPECT_EQ(0, target_effect);
+ EXPECT_EQ(0, close(master_fd));
+}
+
static void *thread_setown_scoped(void *arg)
{
const int fd = *(int *)arg;
--
2.55.0
^ permalink raw reply related [flat|nested] 3+ messages in thread
end of thread, other threads:[~2026-09-13 22:20 UTC | newest]
Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-13 22:19 [RFC PATCH 0/2] Landlock signal scope and TIOCSIG Christopher Lusk
2026-09-13 22:19 ` [RFC PATCH 1/2] tty: mediate TIOCSIG through task_kill LSM hooks Christopher Lusk
2026-09-13 22:19 ` [RFC PATCH 2/2] selftests/landlock: cover TIOCSIG signal scoping Christopher Lusk
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox