From: Abhinav Saxena xandfury@gmail.com
TIOCSTI is a TTY ioctl command that allows inserting characters into the terminal input queue, making it appear as if the user typed those characters.
Add a test suite with four tests to verify TIOCSTI behaviour in different scenarios when dev.tty.legacy_tiocsti is both enabled and disabled:
- Test TIOCSTI functionality when legacy support is enabled - Test TIOCSTI rejection when legacy support is disabled - Test capability requirements for TIOCSTI usage - Test TIOCSTI security with file descriptor passing
The tests validate proper enforcement of the legacy_tiocsti sysctl introduced in commit 83efeeeb3d04 ("tty: Allow TIOCSTI to be disabled"). See tty_ioctl(4) for details on TIOCSTI behavior and security requirements.
Signed-off-by: Abhinav Saxena xandfury@gmail.com --- This patch adds comprehensive selftests for the TIOCSTI ioctl to validate proper behaviour under different system configurations.
===============
The TIOCSTI ioctl allows inserting characters into the terminal input queue, making it appear as if the user typed those characters. This functionality has security implications and behaviour that varies based on system configuration.
Background ==========
CONFIG_LEGACY_TIOCSTI controls the default value for the dev.tty.legacy_tiocsti sysctl, which remains runtime-configurable. The dev.tty.legacy_tiocsti sysctl was introduced in commit 83efeeeb3d04 ("tty: Allow TIOCSTI to be disabled") to provide administrators control over TIOCSTI usage.
When legacy_tiocsti is disabled, TIOCSTI requires CAP_SYS_ADMIN capability. However, the current implementation only checks the current process's credentials via capable(CAP_SYS_ADMIN), which doesn't validate against the file opener's credentials stored in file->f_cred.
This creates a potential security scenario where an unprivileged process can open a TTY fd and pass it to a privileged process via SCM_RIGHTS.
Testing =======
The test suite includes four comprehensive tests: - Test TIOCSTI functionality when legacy support is enabled - Test TIOCSTI rejection when legacy support is disabled - Test capability requirements for TIOCSTI usage - Test TIOCSTI security with file descriptor passing
All patches have been validated using: - scripts/checkpatch.pl --strict (0 errors, 0 warnings) - Functional testing on kernel v6.16-rc2 - File descriptor passing security test scenarios
The fd_passing_security test demonstrates the security concern. To verify, disable legacy TIOCSTI and run the test:
$ echo "0" | sudo tee /proc/sys/dev/tty/legacy_tiocsti $ sudo ./tools/testing/selftests/tty/tty_tiocsti_test -t fd_passing_security
Patch Overview ==============
PATCH 1/1: selftests/tty: add TIOCSTI test suite Comprehensive test suite demonstrating the issue and fix validation
References ==========
- tty_ioctl(4) - documents TIOCSTI ioctl and capability requirements - commit 83efeeeb3d04 ("tty: Allow TIOCSTI to be disabled") - Documentation/security/credentials.rst - https://github.com/KSPP/linux/issues/156 - https://lore.kernel.org/linux-hardening/Y0m9l52AKmw6Yxi1@hostpad/ - drivers/tty/Kconfig
Configuration References: [1] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/driv... [2] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/driv... [3] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/driv...
Signed-off-by: Abhinav Saxena xandfury@gmail.com
Changes in v2: - Focused series on selftests only - Removed SELinux capability checking patch for separate submission - Link to v1: https://lore.kernel.org/r/20250622-toicsti-bug-v1-0-f374373b04b2@gmail.com --- tools/testing/selftests/tty/Makefile | 6 +- tools/testing/selftests/tty/config | 1 + tools/testing/selftests/tty/tty_tiocsti_test.c | 421 +++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-)
diff --git a/tools/testing/selftests/tty/Makefile b/tools/testing/selftests/tty/Makefile index 50d7027b2ae3..7f6fbe5a0cd5 100644 --- a/tools/testing/selftests/tty/Makefile +++ b/tools/testing/selftests/tty/Makefile @@ -1,5 +1,9 @@ # SPDX-License-Identifier: GPL-2.0 CFLAGS = -O2 -Wall -TEST_GEN_PROGS := tty_tstamp_update +TEST_GEN_PROGS := tty_tstamp_update tty_tiocsti_test +LDLIBS += -lcap
include ../lib.mk + +# Add libcap for TIOCSTI test +$(OUTPUT)/tty_tiocsti_test: LDLIBS += -lcap diff --git a/tools/testing/selftests/tty/config b/tools/testing/selftests/tty/config new file mode 100644 index 000000000000..c6373aba6636 --- /dev/null +++ b/tools/testing/selftests/tty/config @@ -0,0 +1 @@ +CONFIG_LEGACY_TIOCSTI=y diff --git a/tools/testing/selftests/tty/tty_tiocsti_test.c b/tools/testing/selftests/tty/tty_tiocsti_test.c new file mode 100644 index 000000000000..6a4b497078b0 --- /dev/null +++ b/tools/testing/selftests/tty/tty_tiocsti_test.c @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * TTY Tests - TIOCSTI + * + * Copyright © 2025 Abhinav Saxena xandfury@gmail.com + */ + +#include <stdio.h> +#include <stdlib.h> +#include <unistd.h> +#include <fcntl.h> +#include <sys/ioctl.h> +#include <errno.h> +#include <stdbool.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/wait.h> +#include <pwd.h> +#include <termios.h> +#include <grp.h> +#include <sys/capability.h> +#include <sys/prctl.h> + +#include "../kselftest_harness.h" + +/* Helper function to send FD via SCM_RIGHTS */ +static int send_fd_via_socket(int socket_fd, int fd_to_send) +{ + struct msghdr msg = { 0 }; + struct cmsghdr *cmsg; + char cmsg_buf[CMSG_SPACE(sizeof(int))]; + char dummy_data = 'F'; + struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 }; + + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf; + msg.msg_controllen = sizeof(cmsg_buf); + + cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + + memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int)); + + return sendmsg(socket_fd, &msg, 0) < 0 ? -1 : 0; +} + +/* Helper function to receive FD via SCM_RIGHTS */ +static int recv_fd_via_socket(int socket_fd) +{ + struct msghdr msg = { 0 }; + struct cmsghdr *cmsg; + char cmsg_buf[CMSG_SPACE(sizeof(int))]; + char dummy_data; + struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 }; + int received_fd = -1; + + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf; + msg.msg_controllen = sizeof(cmsg_buf); + + if (recvmsg(socket_fd, &msg, 0) < 0) + return -1; + + for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level == SOL_SOCKET && + cmsg->cmsg_type == SCM_RIGHTS) { + memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int)); + break; + } + } + + return received_fd; +} + +static inline bool has_cap_sys_admin(void) +{ + cap_t caps = cap_get_proc(); + + if (!caps) + return false; + + cap_flag_value_t cap_val; + bool has_cap = (cap_get_flag(caps, CAP_SYS_ADMIN, CAP_EFFECTIVE, + &cap_val) == 0) && + (cap_val == CAP_SET); + + cap_free(caps); + return has_cap; +} + +/* + * Simple privilege drop that just changes uid/gid in current process + * and also capabilities like CAP_SYS_ADMIN + */ +static inline bool drop_to_nobody(void) +{ + /* Drop supplementary groups */ + if (setgroups(0, NULL) != 0) { + printf("setgroups failed: %s", strerror(errno)); + return false; + } + + /* Change group to nobody */ + if (setgid(65534) != 0) { + printf("setgid failed: %s", strerror(errno)); + return false; + } + + /* Change user to nobody (this drops capabilities) */ + if (setuid(65534) != 0) { + printf("setuid failed: %s", strerror(errno)); + return false; + } + + /* Verify we no longer have CAP_SYS_ADMIN */ + if (has_cap_sys_admin()) { + printf("ERROR: Still have CAP_SYS_ADMIN after changing to nobody"); + return false; + } + + printf("Successfully changed to nobody (uid:%d gid:%d)\n", getuid(), + getgid()); + return true; +} + +static inline int get_legacy_tiocsti_setting(void) +{ + FILE *fp; + int value = -1; + + fp = fopen("/proc/sys/dev/tty/legacy_tiocsti", "r"); + if (!fp) { + if (errno == ENOENT) { + printf("legacy_tiocsti sysctl not available (kernel < 6.2)\n"); + } else { + printf("Cannot read legacy_tiocsti: %s\n", + strerror(errno)); + } + return -1; + } + + if (fscanf(fp, "%d", &value) == 1) { + printf("legacy_tiocsti setting=%d\n", value); + + if (value < 0 || value > 1) { + printf("legacy_tiocsti unexpected value %d\n", value); + value = -1; + } else { + printf("legacy_tiocsti=%d (%s mode)\n", value, + value == 0 ? "restricted" : "permissive"); + } + } else { + printf("Failed to parse legacy_tiocsti value"); + value = -1; + } + + fclose(fp); + return value; +} + +static inline int test_tiocsti_injection(int fd) +{ + int ret; + char test_char = 'X'; + + ret = ioctl(fd, TIOCSTI, &test_char); + if (ret == 0) { + /* Clear the injected character */ + printf("TIOCSTI injection succeeded\n"); + } else { + printf("TIOCSTI injection failed: %s (errno=%d)\n", + strerror(errno), errno); + } + return ret == 0 ? 0 : -1; +} + +FIXTURE(tty_tiocsti) +{ + int tty_fd; + char *tty_name; + bool has_tty; + bool initial_cap_sys_admin; + int legacy_tiocsti_setting; +}; + +FIXTURE_SETUP(tty_tiocsti) +{ + TH_LOG("Running as UID: %d with effective UID: %d", getuid(), + geteuid()); + + self->tty_fd = open("/dev/tty", O_RDWR); + self->has_tty = (self->tty_fd >= 0); + + if (self->tty_fd < 0) + TH_LOG("Cannot open /dev/tty: %s", strerror(errno)); + + self->tty_name = ttyname(STDIN_FILENO); + TH_LOG("Current TTY: %s", self->tty_name ? self->tty_name : "none"); + + self->initial_cap_sys_admin = has_cap_sys_admin(); + TH_LOG("Initial CAP_SYS_ADMIN: %s", + self->initial_cap_sys_admin ? "yes" : "no"); + + self->legacy_tiocsti_setting = get_legacy_tiocsti_setting(); +} + +FIXTURE_TEARDOWN(tty_tiocsti) +{ + if (self->has_tty && self->tty_fd >= 0) + close(self->tty_fd); +} + +/* Test case 1: legacy_tiocsti != 0 (permissive mode) */ +TEST_F(tty_tiocsti, permissive_mode) +{ + // clang-format off + if (self->legacy_tiocsti_setting < 0) + SKIP(return, + "legacy_tiocsti sysctl not available (kernel < 6.2)"); + + if (self->legacy_tiocsti_setting == 0) + SKIP(return, + "Test requires permissive mode (legacy_tiocsti=1)"); + // clang-format on + + ASSERT_TRUE(self->has_tty); + + if (self->initial_cap_sys_admin) { + ASSERT_TRUE(drop_to_nobody()); + ASSERT_FALSE(has_cap_sys_admin()); + } + + /* In permissive mode, TIOCSTI should work without CAP_SYS_ADMIN */ + EXPECT_EQ(test_tiocsti_injection(self->tty_fd), 0) + { + TH_LOG("TIOCSTI should succeed in permissive mode without CAP_SYS_ADMIN"); + } +} + +/* Test case 2: legacy_tiocsti == 0, without CAP_SYS_ADMIN (should fail) */ +TEST_F(tty_tiocsti, restricted_mode_nopriv) +{ + // clang-format off + if (self->legacy_tiocsti_setting < 0) + SKIP(return, + "legacy_tiocsti sysctl not available (kernel < 6.2)"); + + if (self->legacy_tiocsti_setting != 0) + SKIP(return, + "Test requires restricted mode (legacy_tiocsti=0)"); + // clang-format on + + ASSERT_TRUE(self->has_tty); + + if (self->initial_cap_sys_admin) { + ASSERT_TRUE(drop_to_nobody()); + ASSERT_FALSE(has_cap_sys_admin()); + } + /* In restricted mode, TIOCSTI should fail without CAP_SYS_ADMIN */ + EXPECT_EQ(test_tiocsti_injection(self->tty_fd), -1); + + /* + * it might fail with either EPERM or EIO + * EXPECT_TRUE(errno == EPERM || errno == EIO) + * { + * TH_LOG("Expected EPERM, got: %s", strerror(errno)); + * } + */ +} + +/* Test case 3: legacy_tiocsti == 0, with CAP_SYS_ADMIN (should succeed) */ +TEST_F(tty_tiocsti, restricted_mode_priv) +{ + // clang-format off + if (self->legacy_tiocsti_setting < 0) + SKIP(return, + "legacy_tiocsti sysctl not available (kernel < 6.2)"); + + if (self->legacy_tiocsti_setting != 0) + SKIP(return, + "Test requires restricted mode (legacy_tiocsti=0)"); + // clang-format on + + /* Must have CAP_SYS_ADMIN for this test */ + if (!self->initial_cap_sys_admin) + SKIP(return, "Test requires CAP_SYS_ADMIN"); + + ASSERT_TRUE(self->has_tty); + ASSERT_TRUE(has_cap_sys_admin()); + + /* In restricted mode, TIOCSTI should succeed with CAP_SYS_ADMIN */ + EXPECT_EQ(test_tiocsti_injection(self->tty_fd), 0) + { + TH_LOG("TIOCSTI should succeed in restricted mode with CAP_SYS_ADMIN"); + } +} + +/* Test TIOCSTI security with file descriptor passing */ +TEST_F(tty_tiocsti, fd_passing_security) +{ + // clang-format off + if (self->legacy_tiocsti_setting < 0) + SKIP(return, + "legacy_tiocsti sysctl not available (kernel < 6.2)"); + + if (self->legacy_tiocsti_setting != 0) + SKIP(return, + "Test requires restricted mode (legacy_tiocsti=0)"); + // clang-format on + + /* Must start with CAP_SYS_ADMIN */ + if (!self->initial_cap_sys_admin) + SKIP(return, "Test requires initial CAP_SYS_ADMIN"); + + int sockpair[2]; + pid_t child_pid; + + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sockpair), 0); + + child_pid = fork(); + ASSERT_GE(child_pid, 0) + TH_LOG("Fork failed: %s", strerror(errno)); + + if (child_pid == 0) { + /* Child process - become unprivileged, open TTY, send FD to parent */ + close(sockpair[0]); + + TH_LOG("Child: Dropping privileges..."); + + /* Drop to nobody user (loses all capabilities) */ + drop_to_nobody(); + + /* Verify we no longer have CAP_SYS_ADMIN */ + if (has_cap_sys_admin()) { + TH_LOG("Child: Failed to drop CAP_SYS_ADMIN"); + _exit(1); + } + + TH_LOG("Child: Opening TTY as unprivileged user..."); + + int unprivileged_tty_fd = open("/dev/tty", O_RDWR); + + if (unprivileged_tty_fd < 0) { + TH_LOG("Child: Cannot open TTY: %s", strerror(errno)); + _exit(1); + } + + /* Test that we can't use TIOCSTI directly (should fail) */ + + char test_char = 'X'; + + if (ioctl(unprivileged_tty_fd, TIOCSTI, &test_char) == 0) { + TH_LOG("Child: ERROR - Direct TIOCSTI succeeded unexpectedly!"); + close(unprivileged_tty_fd); + _exit(1); + } + TH_LOG("Child: Good - Direct TIOCSTI failed as expected: %s", + strerror(errno)); + + /* Send the TTY FD to privileged parent via SCM_RIGHTS */ + TH_LOG("Child: Sending TTY FD to privileged parent..."); + if (send_fd_via_socket(sockpair[1], unprivileged_tty_fd) != 0) { + TH_LOG("Child: Failed to send FD"); + close(unprivileged_tty_fd); + _exit(1); + } + + close(unprivileged_tty_fd); + close(sockpair[1]); + _exit(0); /* Child success */ + + } else { + /* Parent process - keep CAP_SYS_ADMIN, receive FD, test TIOCSTI */ + close(sockpair[1]); + + TH_LOG("Parent: Waiting for TTY FD from unprivileged child..."); + + /* Verify we still have CAP_SYS_ADMIN */ + ASSERT_TRUE(has_cap_sys_admin()); + + /* Receive the TTY FD from unprivileged child */ + int received_fd = recv_fd_via_socket(sockpair[0]); + + ASSERT_GE(received_fd, 0) + TH_LOG("Parent: Received FD %d (opened by unprivileged process)", + received_fd); + + /* + * VULNERABILITY TEST: Try TIOCSTI with FD opened by unprivileged process + * This should FAIL even though parent has CAP_SYS_ADMIN + * because the FD was opened by unprivileged process + */ + char attack_char = 'V'; /* V for Vulnerability */ + int ret = ioctl(received_fd, TIOCSTI, &attack_char); + + TH_LOG("Parent: Testing TIOCSTI on FD from unprivileged process..."); + if (ret == 0) { + TH_LOG("*** VULNERABILITY DETECTED ***"); + TH_LOG("Privileged process can use TIOCSTI on unprivileged FD"); + } else { + TH_LOG("TIOCSTI failed on unprivileged FD: %s", + strerror(errno)); + EXPECT_EQ(errno, EPERM); + } + close(received_fd); + close(sockpair[0]); + + /* Wait for child */ + int status; + + ASSERT_EQ(waitpid(child_pid, &status, 0), child_pid); + EXPECT_EQ(WEXITSTATUS(status), 0); + ASSERT_NE(ret, 0); + } +} + +TEST_HARNESS_MAIN
--- base-commit: 40f92e79b0aabbf3575e371f9054657a421a3e79 change-id: 20250618-toicsti-bug-7822b8e94a32
Best regards,
On Sun, Jul 13, 2025 at 06:38:55PM -0600, Abhinav Saxena via B4 Relay wrote:
From: Abhinav Saxena xandfury@gmail.com
TIOCSTI is a TTY ioctl command that allows inserting characters into the terminal input queue, making it appear as if the user typed those characters.
Add a test suite with four tests to verify TIOCSTI behaviour in different scenarios when dev.tty.legacy_tiocsti is both enabled and disabled:
- Test TIOCSTI functionality when legacy support is enabled
- Test TIOCSTI rejection when legacy support is disabled
- Test capability requirements for TIOCSTI usage
- Test TIOCSTI security with file descriptor passing
The tests validate proper enforcement of the legacy_tiocsti sysctl introduced in commit 83efeeeb3d04 ("tty: Allow TIOCSTI to be disabled"). See tty_ioctl(4) for details on TIOCSTI behavior and security requirements.
Signed-off-by: Abhinav Saxena xandfury@gmail.com
This patch adds comprehensive selftests for the TIOCSTI ioctl to validate proper behaviour under different system configurations.
Much of the text under the --- line is useful details; I would merge it with the short commit log above to make a comprehensive commit message.
===============
The TIOCSTI ioctl allows inserting characters into the terminal input queue, making it appear as if the user typed those characters. This functionality has security implications and behaviour that varies based on system configuration.
Background
CONFIG_LEGACY_TIOCSTI controls the default value for the dev.tty.legacy_tiocsti sysctl, which remains runtime-configurable. The dev.tty.legacy_tiocsti sysctl was introduced in commit 83efeeeb3d04 ("tty: Allow TIOCSTI to be disabled") to provide administrators control over TIOCSTI usage.
When legacy_tiocsti is disabled, TIOCSTI requires CAP_SYS_ADMIN capability. However, the current implementation only checks the current process's credentials via capable(CAP_SYS_ADMIN), which doesn't validate against the file opener's credentials stored in file->f_cred.
This creates a potential security scenario where an unprivileged process can open a TTY fd and pass it to a privileged process via SCM_RIGHTS.
Testing
The test suite includes four comprehensive tests:
- Test TIOCSTI functionality when legacy support is enabled
- Test TIOCSTI rejection when legacy support is disabled
- Test capability requirements for TIOCSTI usage
- Test TIOCSTI security with file descriptor passing
All patches have been validated using:
- scripts/checkpatch.pl --strict (0 errors, 0 warnings)
- Functional testing on kernel v6.16-rc2
- File descriptor passing security test scenarios
The fd_passing_security test demonstrates the security concern. To verify, disable legacy TIOCSTI and run the test:
$ echo "0" | sudo tee /proc/sys/dev/tty/legacy_tiocsti $ sudo ./tools/testing/selftests/tty/tty_tiocsti_test -t fd_passing_security
Patch Overview
PATCH 1/1: selftests/tty: add TIOCSTI test suite Comprehensive test suite demonstrating the issue and fix validation
References
- tty_ioctl(4) - documents TIOCSTI ioctl and capability requirements
- commit 83efeeeb3d04 ("tty: Allow TIOCSTI to be disabled")
- Documentation/security/credentials.rst
- https://github.com/KSPP/linux/issues/156
- https://lore.kernel.org/linux-hardening/Y0m9l52AKmw6Yxi1@hostpad/
- drivers/tty/Kconfig
Configuration References: [1] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/driv... [2] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/driv... [3] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/driv...
Signed-off-by: Abhinav Saxena xandfury@gmail.com
Changes in v2:
- Focused series on selftests only
- Removed SELinux capability checking patch for separate submission
- Link to v1: https://lore.kernel.org/r/20250622-toicsti-bug-v1-0-f374373b04b2@gmail.com
tools/testing/selftests/tty/Makefile | 6 +- tools/testing/selftests/tty/config | 1 + tools/testing/selftests/tty/tty_tiocsti_test.c | 421 +++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-)
diff --git a/tools/testing/selftests/tty/Makefile b/tools/testing/selftests/tty/Makefile index 50d7027b2ae3..7f6fbe5a0cd5 100644 --- a/tools/testing/selftests/tty/Makefile +++ b/tools/testing/selftests/tty/Makefile @@ -1,5 +1,9 @@ # SPDX-License-Identifier: GPL-2.0 CFLAGS = -O2 -Wall -TEST_GEN_PROGS := tty_tstamp_update +TEST_GEN_PROGS := tty_tstamp_update tty_tiocsti_test +LDLIBS += -lcap include ../lib.mk
+# Add libcap for TIOCSTI test +$(OUTPUT)/tty_tiocsti_test: LDLIBS += -lcap diff --git a/tools/testing/selftests/tty/config b/tools/testing/selftests/tty/config new file mode 100644 index 000000000000..c6373aba6636 --- /dev/null +++ b/tools/testing/selftests/tty/config @@ -0,0 +1 @@ +CONFIG_LEGACY_TIOCSTI=y diff --git a/tools/testing/selftests/tty/tty_tiocsti_test.c b/tools/testing/selftests/tty/tty_tiocsti_test.c new file mode 100644 index 000000000000..6a4b497078b0 --- /dev/null +++ b/tools/testing/selftests/tty/tty_tiocsti_test.c @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: GPL-2.0 +/*
- TTY Tests - TIOCSTI
- Copyright © 2025 Abhinav Saxena xandfury@gmail.com
- */
+#include <stdio.h> +#include <stdlib.h> +#include <unistd.h> +#include <fcntl.h> +#include <sys/ioctl.h> +#include <errno.h> +#include <stdbool.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/wait.h> +#include <pwd.h> +#include <termios.h> +#include <grp.h> +#include <sys/capability.h> +#include <sys/prctl.h>
+#include "../kselftest_harness.h"
+/* Helper function to send FD via SCM_RIGHTS */ +static int send_fd_via_socket(int socket_fd, int fd_to_send) +{
- struct msghdr msg = { 0 };
- struct cmsghdr *cmsg;
- char cmsg_buf[CMSG_SPACE(sizeof(int))];
- char dummy_data = 'F';
- struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 };
- msg.msg_iov = &iov;
- msg.msg_iovlen = 1;
- msg.msg_control = cmsg_buf;
- msg.msg_controllen = sizeof(cmsg_buf);
- cmsg = CMSG_FIRSTHDR(&msg);
- cmsg->cmsg_level = SOL_SOCKET;
- cmsg->cmsg_type = SCM_RIGHTS;
- cmsg->cmsg_len = CMSG_LEN(sizeof(int));
- memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int));
- return sendmsg(socket_fd, &msg, 0) < 0 ? -1 : 0;
+}
+/* Helper function to receive FD via SCM_RIGHTS */ +static int recv_fd_via_socket(int socket_fd) +{
- struct msghdr msg = { 0 };
- struct cmsghdr *cmsg;
- char cmsg_buf[CMSG_SPACE(sizeof(int))];
- char dummy_data;
- struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 };
- int received_fd = -1;
- msg.msg_iov = &iov;
- msg.msg_iovlen = 1;
- msg.msg_control = cmsg_buf;
- msg.msg_controllen = sizeof(cmsg_buf);
- if (recvmsg(socket_fd, &msg, 0) < 0)
return -1;
- for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET &&
cmsg->cmsg_type == SCM_RIGHTS) {
memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int));
break;
}
- }
- return received_fd;
+}
+static inline bool has_cap_sys_admin(void) +{
- cap_t caps = cap_get_proc();
- if (!caps)
return false;
- cap_flag_value_t cap_val;
- bool has_cap = (cap_get_flag(caps, CAP_SYS_ADMIN, CAP_EFFECTIVE,
&cap_val) == 0) &&
(cap_val == CAP_SET);
- cap_free(caps);
- return has_cap;
+}
+/*
- Simple privilege drop that just changes uid/gid in current process
- and also capabilities like CAP_SYS_ADMIN
- */
+static inline bool drop_to_nobody(void) +{
- /* Drop supplementary groups */
- if (setgroups(0, NULL) != 0) {
printf("setgroups failed: %s", strerror(errno));
return false;
- }
- /* Change group to nobody */
- if (setgid(65534) != 0) {
printf("setgid failed: %s", strerror(errno));
return false;
- }
- /* Change user to nobody (this drops capabilities) */
- if (setuid(65534) != 0) {
printf("setuid failed: %s", strerror(errno));
return false;
- }
- /* Verify we no longer have CAP_SYS_ADMIN */
- if (has_cap_sys_admin()) {
printf("ERROR: Still have CAP_SYS_ADMIN after changing to nobody");
return false;
- }
- printf("Successfully changed to nobody (uid:%d gid:%d)\n", getuid(),
getgid());
- return true;
+}
All of the bare printfs above should use TH_LOG()
+static inline int get_legacy_tiocsti_setting(void) +{
- FILE *fp;
- int value = -1;
- fp = fopen("/proc/sys/dev/tty/legacy_tiocsti", "r");
- if (!fp) {
if (errno == ENOENT) {
printf("legacy_tiocsti sysctl not available (kernel < 6.2)\n");
} else {
printf("Cannot read legacy_tiocsti: %s\n",
strerror(errno));
}
return -1;
- }
- if (fscanf(fp, "%d", &value) == 1) {
printf("legacy_tiocsti setting=%d\n", value);
if (value < 0 || value > 1) {
printf("legacy_tiocsti unexpected value %d\n", value);
value = -1;
} else {
printf("legacy_tiocsti=%d (%s mode)\n", value,
value == 0 ? "restricted" : "permissive");
}
- } else {
printf("Failed to parse legacy_tiocsti value");
value = -1;
- }
- fclose(fp);
- return value;
+}
Same observation as above that reporting should go through the kselftest harness APIs.
+static inline int test_tiocsti_injection(int fd) +{
- int ret;
- char test_char = 'X';
- ret = ioctl(fd, TIOCSTI, &test_char);
- if (ret == 0) {
/* Clear the injected character */
printf("TIOCSTI injection succeeded\n");
- } else {
printf("TIOCSTI injection failed: %s (errno=%d)\n",
strerror(errno), errno);
- }
- return ret == 0 ? 0 : -1;
+}
Same. Note for each of these you'll need to pass in the test metadata (struct __test_metadata *_metadata). See helper examples that do this in tools/testing/selftests/seccomp/seccomp_bpf.c like tracer_poke(), get_syscall(), etc.
+FIXTURE(tty_tiocsti) +{
- int tty_fd;
- char *tty_name;
- bool has_tty;
- bool initial_cap_sys_admin;
- int legacy_tiocsti_setting;
+};
+FIXTURE_SETUP(tty_tiocsti) +{
- TH_LOG("Running as UID: %d with effective UID: %d", getuid(),
geteuid());
- self->tty_fd = open("/dev/tty", O_RDWR);
- self->has_tty = (self->tty_fd >= 0);
- if (self->tty_fd < 0)
TH_LOG("Cannot open /dev/tty: %s", strerror(errno));
I would expect this to be:
ASSERT_GE(self->tty_fd, 0) { TH_LOG("Cannot open /dev/tty: %s", strerror(errno)); }
- self->tty_name = ttyname(STDIN_FILENO);
- TH_LOG("Current TTY: %s", self->tty_name ? self->tty_name : "none");
- self->initial_cap_sys_admin = has_cap_sys_admin();
- TH_LOG("Initial CAP_SYS_ADMIN: %s",
self->initial_cap_sys_admin ? "yes" : "no");
- self->legacy_tiocsti_setting = get_legacy_tiocsti_setting();
+}
+FIXTURE_TEARDOWN(tty_tiocsti) +{
- if (self->has_tty && self->tty_fd >= 0)
close(self->tty_fd);
+}
+/* Test case 1: legacy_tiocsti != 0 (permissive mode) */ +TEST_F(tty_tiocsti, permissive_mode) +{
- // clang-format off
While there are instances in the tree of "//" please use /* */ for comments, even these format pragmas.
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
"legacy_tiocsti sysctl not available (kernel < 6.2)");
I notice this check is in every test. I think it'd be cleaner to put it into FIXTURE_SETUP() instead.
- if (self->legacy_tiocsti_setting == 0)
SKIP(return,
"Test requires permissive mode (legacy_tiocsti=1)");
What about changing this value during FIXTURE_SETUP() and restoring it in FIXTURE_TEARDOWN()?
- // clang-format on
- ASSERT_TRUE(self->has_tty);
Can opening a pty (i.e. openpty(3)) be used to always have a tty available?
- if (self->initial_cap_sys_admin) {
ASSERT_TRUE(drop_to_nobody());
ASSERT_FALSE(has_cap_sys_admin());
- }
- /* In permissive mode, TIOCSTI should work without CAP_SYS_ADMIN */
- EXPECT_EQ(test_tiocsti_injection(self->tty_fd), 0)
With openpty() I think it would also mean you can trivially throw away injected characters instead of having them actually end up on the real tty? But it's been a while since I did tty work, so maybe I'm confusing myself about what openpty() gets you.
- {
TH_LOG("TIOCSTI should succeed in permissive mode without CAP_SYS_ADMIN");
- }
+}
+/* Test case 2: legacy_tiocsti == 0, without CAP_SYS_ADMIN (should fail) */ +TEST_F(tty_tiocsti, restricted_mode_nopriv) +{
- // clang-format off
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
"legacy_tiocsti sysctl not available (kernel < 6.2)");
- if (self->legacy_tiocsti_setting != 0)
SKIP(return,
"Test requires restricted mode (legacy_tiocsti=0)");
I think it should be possible to _set_ the state you want (if running as root) and if not settable, then do the SKIP.
- // clang-format on
- ASSERT_TRUE(self->has_tty);
- if (self->initial_cap_sys_admin) {
ASSERT_TRUE(drop_to_nobody());
ASSERT_FALSE(has_cap_sys_admin());
- }
- /* In restricted mode, TIOCSTI should fail without CAP_SYS_ADMIN */
- EXPECT_EQ(test_tiocsti_injection(self->tty_fd), -1);
- /*
* it might fail with either EPERM or EIO
* EXPECT_TRUE(errno == EPERM || errno == EIO)
* {
* TH_LOG("Expected EPERM, got: %s", strerror(errno));
* }
*/
Why are these commented out?
+}
+/* Test case 3: legacy_tiocsti == 0, with CAP_SYS_ADMIN (should succeed) */ +TEST_F(tty_tiocsti, restricted_mode_priv) +{
- // clang-format off
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
"legacy_tiocsti sysctl not available (kernel < 6.2)");
- if (self->legacy_tiocsti_setting != 0)
SKIP(return,
"Test requires restricted mode (legacy_tiocsti=0)");
- // clang-format on
- /* Must have CAP_SYS_ADMIN for this test */
- if (!self->initial_cap_sys_admin)
SKIP(return, "Test requires CAP_SYS_ADMIN");
- ASSERT_TRUE(self->has_tty);
- ASSERT_TRUE(has_cap_sys_admin());
- /* In restricted mode, TIOCSTI should succeed with CAP_SYS_ADMIN */
- EXPECT_EQ(test_tiocsti_injection(self->tty_fd), 0)
- {
TH_LOG("TIOCSTI should succeed in restricted mode with CAP_SYS_ADMIN");
- }
+}
+/* Test TIOCSTI security with file descriptor passing */ +TEST_F(tty_tiocsti, fd_passing_security) +{
- // clang-format off
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
"legacy_tiocsti sysctl not available (kernel < 6.2)");
- if (self->legacy_tiocsti_setting != 0)
SKIP(return,
"Test requires restricted mode (legacy_tiocsti=0)");
- // clang-format on
- /* Must start with CAP_SYS_ADMIN */
- if (!self->initial_cap_sys_admin)
SKIP(return, "Test requires initial CAP_SYS_ADMIN");
- int sockpair[2];
- pid_t child_pid;
- ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sockpair), 0);
- child_pid = fork();
- ASSERT_GE(child_pid, 0)
- TH_LOG("Fork failed: %s", strerror(errno));
- if (child_pid == 0) {
/* Child process - become unprivileged, open TTY, send FD to parent */
close(sockpair[0]);
TH_LOG("Child: Dropping privileges...");
/* Drop to nobody user (loses all capabilities) */
drop_to_nobody();
/* Verify we no longer have CAP_SYS_ADMIN */
if (has_cap_sys_admin()) {
TH_LOG("Child: Failed to drop CAP_SYS_ADMIN");
_exit(1);
}
Please double check the behavior but I think you can use ASSERTs in the child and you don't need the "_exit(1)" stuff.
TH_LOG("Child: Opening TTY as unprivileged user...");
int unprivileged_tty_fd = open("/dev/tty", O_RDWR);
if (unprivileged_tty_fd < 0) {
TH_LOG("Child: Cannot open TTY: %s", strerror(errno));
_exit(1);
}
/* Test that we can't use TIOCSTI directly (should fail) */
char test_char = 'X';
if (ioctl(unprivileged_tty_fd, TIOCSTI, &test_char) == 0) {
TH_LOG("Child: ERROR - Direct TIOCSTI succeeded unexpectedly!");
close(unprivileged_tty_fd);
_exit(1);
}
TH_LOG("Child: Good - Direct TIOCSTI failed as expected: %s",
strerror(errno));
/* Send the TTY FD to privileged parent via SCM_RIGHTS */
TH_LOG("Child: Sending TTY FD to privileged parent...");
if (send_fd_via_socket(sockpair[1], unprivileged_tty_fd) != 0) {
TH_LOG("Child: Failed to send FD");
close(unprivileged_tty_fd);
_exit(1);
}
close(unprivileged_tty_fd);
close(sockpair[1]);
_exit(0); /* Child success */
- } else {
This doesn't need an else nor indenting: it is the parent no matter what due to the _exit above.
/* Parent process - keep CAP_SYS_ADMIN, receive FD, test TIOCSTI */
close(sockpair[1]);
TH_LOG("Parent: Waiting for TTY FD from unprivileged child...");
/* Verify we still have CAP_SYS_ADMIN */
ASSERT_TRUE(has_cap_sys_admin());
/* Receive the TTY FD from unprivileged child */
int received_fd = recv_fd_via_socket(sockpair[0]);
ASSERT_GE(received_fd, 0)
TH_LOG("Parent: Received FD %d (opened by unprivileged process)",
received_fd);
/*
* VULNERABILITY TEST: Try TIOCSTI with FD opened by unprivileged process
* This should FAIL even though parent has CAP_SYS_ADMIN
* because the FD was opened by unprivileged process
*/
char attack_char = 'V'; /* V for Vulnerability */
int ret = ioctl(received_fd, TIOCSTI, &attack_char);
Doesn't the child need to stay alive long enough to receive the character? i.e. is it a problem that the child immediately exits 0 after sending the fd to the parent?
TH_LOG("Parent: Testing TIOCSTI on FD from unprivileged process...");
if (ret == 0) {
TH_LOG("*** VULNERABILITY DETECTED ***");
TH_LOG("Privileged process can use TIOCSTI on unprivileged FD");
} else {
TH_LOG("TIOCSTI failed on unprivileged FD: %s",
strerror(errno));
EXPECT_EQ(errno, EPERM);
}
Shouldn't this be arranged with an expect on ret == 0 ?
close(received_fd);
close(sockpair[0]);
/* Wait for child */
int status;
ASSERT_EQ(waitpid(child_pid, &status, 0), child_pid);
EXPECT_EQ(WEXITSTATUS(status), 0);
ASSERT_NE(ret, 0);
- }
+}
+TEST_HARNESS_MAIN
Looks like you're on the right track!
-Kees
Hi!
Thank you so much for the detailed review! Based on the feedback, I have updated v3 [1]. In v3, we also add additional tests using the FIXTURE_ADD_VARIANT feature to test the matrix of possibilities.
The central idea is that FD passing tests would behave the same way as the regular tests if the file credentials were also checked.
Kees Cook kees@kernel.org writes:
On Sun, Jul 13, 2025 at 06:38:55PM -0600, Abhinav Saxena via B4 Relay wrote:
From: Abhinav Saxena xandfury@gmail.com
TIOCSTI is a TTY ioctl command that allows inserting characters into the terminal input queue, making it appear as if the user typed those characters.
Add a test suite with four tests to verify TIOCSTI behaviour in different scenarios when dev.tty.legacy_tiocsti is both enabled and disabled:
- Test TIOCSTI functionality when legacy support is enabled
- Test TIOCSTI rejection when legacy support is disabled
- Test capability requirements for TIOCSTI usage
- Test TIOCSTI security with file descriptor passing
The tests validate proper enforcement of the legacy_tiocsti sysctl introduced in commit 83efeeeb3d04 (“tty: Allow TIOCSTI to be disabled”). See tty_ioctl(4) for details on TIOCSTI behavior and security requirements.
Signed-off-by: Abhinav Saxena xandfury@gmail.com — This patch adds comprehensive selftests for the TIOCSTI ioctl to validate proper behaviour under different system configurations.
Much of the text under the — line is useful details; I would merge it with the short commit log above to make a comprehensive commit message.
Thanks! I added more details to the commit message in v3.
`============='
The TIOCSTI ioctl allows inserting characters into the terminal input queue, making it appear as if the user typed those characters. This functionality has security implications and behaviour that varies based on system configuration.
Background `========'
CONFIG_LEGACY_TIOCSTI controls the default value for the dev.tty.legacy_tiocsti sysctl, which remains runtime-configurable. The dev.tty.legacy_tiocsti sysctl was introduced in commit 83efeeeb3d04 (“tty: Allow TIOCSTI to be disabled”) to provide administrators control over TIOCSTI usage.
When legacy_tiocsti is disabled, TIOCSTI requires CAP_SYS_ADMIN capability. However, the current implementation only checks the current process’s credentials via capable(CAP_SYS_ADMIN), which doesn’t validate against the file opener’s credentials stored in file->f_cred.
This creates a potential security scenario where an unprivileged process can open a TTY fd and pass it to a privileged process via SCM_RIGHTS.
Testing `====='
The test suite includes four comprehensive tests:
- Test TIOCSTI functionality when legacy support is enabled
- Test TIOCSTI rejection when legacy support is disabled
- Test capability requirements for TIOCSTI usage
- Test TIOCSTI security with file descriptor passing
All patches have been validated using:
- scripts/checkpatch.pl –strict (0 errors, 0 warnings)
- Functional testing on kernel v6.16-rc2
- File descriptor passing security test scenarios
The fd_passing_security test demonstrates the security concern. To verify, disable legacy TIOCSTI and run the test:
$ echo “0” | sudo tee /proc/sys/dev/tty/legacy_tiocsti $ sudo ./tools/testing/selftests/tty/tty_tiocsti_test -t fd_passing_security
Patch Overview `============'
PATCH 1/1: selftests/tty: add TIOCSTI test suite Comprehensive test suite demonstrating the issue and fix validation
References `========'
- tty_ioctl(4) - documents TIOCSTI ioctl and capability requirements
- commit 83efeeeb3d04 (“tty: Allow TIOCSTI to be disabled”)
- Documentation/security/credentials.rst
- https://github.com/KSPP/linux/issues/156
- https://lore.kernel.org/linux-hardening/Y0m9l52AKmw6Yxi1@hostpad/
- drivers/tty/Kconfig
Configuration References: [1] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/tty/Kconfig#n149 [2] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/tty/Kconfig#n162 [3] - https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/tty/Kconfig#n188
Signed-off-by: Abhinav Saxena xandfury@gmail.com
Changes in v2:
- Focused series on selftests only
- Removed SELinux capability checking patch for separate submission
- Link to v1: https://lore.kernel.org/r/20250622-toicsti-bug-v1-0-f374373b04b2@gmail.com
— tools/testing/selftests/tty/Makefile | 6 +- tools/testing/selftests/tty/config | 1 + tools/testing/selftests/tty/tty_tiocsti_test.c | 421 +++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-)
diff –git a/tools/testing/selftests/tty/Makefile b/tools/testing/selftests/tty/Makefile index 50d7027b2ae3..7f6fbe5a0cd5 100644 — a/tools/testing/selftests/tty/Makefile +++ b/tools/testing/selftests/tty/Makefile @@ -1,5 +1,9 @@ # SPDX-License-Identifier: GPL-2.0 CFLAGS = -O2 -Wall -TEST_GEN_PROGS := tty_tstamp_update +TEST_GEN_PROGS := tty_tstamp_update tty_tiocsti_test +LDLIBS += -lcap
include ../lib.mk
+# Add libcap for TIOCSTI test +$(OUTPUT)/tty_tiocsti_test: LDLIBS += -lcap diff –git a/tools/testing/selftests/tty/config b/tools/testing/selftests/tty/config new file mode 100644 index 000000000000..c6373aba6636 — /dev/null +++ b/tools/testing/selftests/tty/config @@ -0,0 +1 @@ +CONFIG_LEGACY_TIOCSTI=y diff –git a/tools/testing/selftests/tty/tty_tiocsti_test.c b/tools/testing/selftests/tty/tty_tiocsti_test.c new file mode 100644 index 000000000000..6a4b497078b0 — /dev/null +++ b/tools/testing/selftests/tty/tty_tiocsti_test.c @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: GPL-2.0 +/*
- TTY Tests - TIOCSTI
- Copyright © 2025 Abhinav Saxena xandfury@gmail.com
- */
+#include <stdio.h> +#include <stdlib.h> +#include <unistd.h> +#include <fcntl.h> +#include <sys/ioctl.h> +#include <errno.h> +#include <stdbool.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/wait.h> +#include <pwd.h> +#include <termios.h> +#include <grp.h> +#include <sys/capability.h> +#include <sys/prctl.h>
+#include “../kselftest_harness.h”
+/* Helper function to send FD via SCM_RIGHTS */ +static int send_fd_via_socket(int socket_fd, int fd_to_send) +{
- struct msghdr msg = { 0 };
- struct cmsghdr *cmsg;
- char cmsg_buf[CMSG_SPACE(sizeof(int))];
- char dummy_data = ’F’;
- struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 };
- msg.msg_iov = &iov;
- msg.msg_iovlen = 1;
- msg.msg_control = cmsg_buf;
- msg.msg_controllen = sizeof(cmsg_buf);
- cmsg = CMSG_FIRSTHDR(&msg);
- cmsg->cmsg_level = SOL_SOCKET;
- cmsg->cmsg_type = SCM_RIGHTS;
- cmsg->cmsg_len = CMSG_LEN(sizeof(int));
- memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int));
- return sendmsg(socket_fd, &msg, 0) < 0 ? -1 : 0;
+}
+/* Helper function to receive FD via SCM_RIGHTS */ +static int recv_fd_via_socket(int socket_fd) +{
- struct msghdr msg = { 0 };
- struct cmsghdr *cmsg;
- char cmsg_buf[CMSG_SPACE(sizeof(int))];
- char dummy_data;
- struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 };
- int received_fd = -1;
- msg.msg_iov = &iov;
- msg.msg_iovlen = 1;
- msg.msg_control = cmsg_buf;
- msg.msg_controllen = sizeof(cmsg_buf);
- if (recvmsg(socket_fd, &msg, 0) < 0)
return -1;
- for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level `= SOL_SOCKET &&
cmsg->cmsg_type =' SCM_RIGHTS) {
memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int));
break;
}
- }
- return received_fd;
+}
+static inline bool has_cap_sys_admin(void) +{
- cap_t caps = cap_get_proc();
- if (!caps)
return false;
- cap_flag_value_t cap_val;
- bool has_cap = (cap_get_flag(caps, CAP_SYS_ADMIN, CAP_EFFECTIVE,
&cap_val) `= 0) &&
(cap_val =' CAP_SET);
- cap_free(caps);
- return has_cap;
+}
+/*
- Simple privilege drop that just changes uid/gid in current process
- and also capabilities like CAP_SYS_ADMIN
- */
+static inline bool drop_to_nobody(void) +{
- /* Drop supplementary groups */
- if (setgroups(0, NULL) != 0) {
printf(“setgroups failed: %s”, strerror(errno));
return false;
- }
- /* Change group to nobody */
- if (setgid(65534) != 0) {
printf(“setgid failed: %s”, strerror(errno));
return false;
- }
- /* Change user to nobody (this drops capabilities) */
- if (setuid(65534) != 0) {
printf(“setuid failed: %s”, strerror(errno));
return false;
- }
- /* Verify we no longer have CAP_SYS_ADMIN */
- if (has_cap_sys_admin()) {
printf(“ERROR: Still have CAP_SYS_ADMIN after changing to nobody”);
return false;
- }
- printf(“Successfully changed to nobody (uid:%d gid:%d)\n”, getuid(),
getgid());
- return true;
+}
All of the bare printfs above should use TH_LOG()
Done! I’ve updated all instances to use TH_LOG().
+static inline int get_legacy_tiocsti_setting(void) +{
- FILE *fp;
- int value = -1;
- fp = fopen(“/proc/sys/dev/tty/legacy_tiocsti”, “r”);
- if (!fp) {
if (errno `= ENOENT) {
printf("legacy_tiocsti sysctl not available (kernel < 6.2)\n");
} else {
printf("Cannot read legacy_tiocsti: %s\n",
strerror(errno));
}
return -1;
- }
- if (fscanf(fp, "%d", &value) =' 1) {
printf(“legacy_tiocsti setting=%d\n”, value);
if (value < 0 || value > 1) {
printf(“legacy_tiocsti unexpected value %d\n”, value);
value = -1;
} else {
printf(“legacy_tiocsti=%d (%s mode)\n”, value,
value == 0 ? “restricted” : “permissive”);
}
- } else {
printf(“Failed to parse legacy_tiocsti value”);
value = -1;
- }
- fclose(fp);
- return value;
+}
Same observation as above that reporting should go through the kselftest harness APIs.
Fixed.
+static inline int test_tiocsti_injection(int fd) +{
- int ret;
- char test_char = ’X’;
- ret = ioctl(fd, TIOCSTI, &test_char);
- if (ret `= 0) {
/* Clear the injected character */
printf("TIOCSTI injection succeeded\n");
- } else {
printf("TIOCSTI injection failed: %s (errno=%d)\n",
strerror(errno), errno);
- }
- return ret =' 0 ? 0 : -1;
+}
Same. Note for each of these you’ll need to pass in the test metadata (struct __test_metadata *_metadata). See helper examples that do this in tools/testing/selftests/seccomp/seccomp_bpf.c like tracer_poke(), get_syscall(), etc.
Yep. Done! I added metadata as an argument to helper functions in v3.
+FIXTURE(tty_tiocsti) +{
- int tty_fd;
- char *tty_name;
- bool has_tty;
- bool initial_cap_sys_admin;
- int legacy_tiocsti_setting;
+};
+FIXTURE_SETUP(tty_tiocsti) +{
- TH_LOG(“Running as UID: %d with effective UID: %d”, getuid(),
geteuid());
- self->tty_fd = open(“/dev/tty”, O_RDWR);
- self->has_tty = (self->tty_fd >= 0);
- if (self->tty_fd < 0)
TH_LOG(“Cannot open /dev/tty: %s”, strerror(errno));
I would expect this to be:
ASSERT_GE(self->tty_fd, 0) { TH_LOG(“Cannot open /dev/tty: %s”, strerror(errno)); }
You are right. I fixed this everywhere.
- self->tty_name = ttyname(STDIN_FILENO);
- TH_LOG(“Current TTY: %s”, self->tty_name ? self->tty_name : “none”);
- self->initial_cap_sys_admin = has_cap_sys_admin();
- TH_LOG(“Initial CAP_SYS_ADMIN: %s”,
self->initial_cap_sys_admin ? “yes” : “no”);
- self->legacy_tiocsti_setting = get_legacy_tiocsti_setting();
+}
+FIXTURE_TEARDOWN(tty_tiocsti) +{
- if (self->has_tty && self->tty_fd >= 0)
close(self->tty_fd);
+}
+/* Test case 1: legacy_tiocsti != 0 (permissive mode) */ +TEST_F(tty_tiocsti, permissive_mode) +{
- // clang-format off
While there are instances in the tree of "//" please use /* */ for comments, even these format pragmas.
I fixed "//" comments to use /* */ everywhere.
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
“legacy_tiocsti sysctl not available (kernel < 6.2)”);
I notice this check is in every test. I think it’d be cleaner to put it into FIXTURE_SETUP() instead.
Yes, I added a system-wide skip for cases such as when the legacy_tiocsti option is not available, which I placed in FIXTURE_SETUP.
- if (self->legacy_tiocsti_setting == 0)
SKIP(return,
“Test requires permissive mode (legacy_tiocsti=1)”);
What about changing this value during FIXTURE_SETUP() and restoring it in FIXTURE_TEARDOWN()?
Yes, that makes more sense. In v3 we change values during setup and teardown based on the requirements of the test.
- // clang-format on
- ASSERT_TRUE(self->has_tty);
Can opening a pty (i.e. openpty(3)) be used to always have a tty available?
Potentially, yes. Initially, I ran the tests on both pty and tty. But running on pty makes more sense for the reason you highlighted below.
- if (self->initial_cap_sys_admin) {
ASSERT_TRUE(drop_to_nobody());
ASSERT_FALSE(has_cap_sys_admin());
- }
- /* In permissive mode, TIOCSTI should work without CAP_SYS_ADMIN */
- EXPECT_EQ(test_tiocsti_injection(self->tty_fd), 0)
With openpty() I think it would also mean you can trivially throw away injected characters instead of having them actually end up on the real tty? But it’s been a while since I did tty work, so maybe I’m confusing myself about what openpty() gets you.
That’s true. I use openpty(3) in v3, and then in a child process and then run the TIOCSCTTY ioctl call to set it as the controlling terminal of the child. This allowed us to write even more comprehensive tests without having injected characters on the real tty.
- {
TH_LOG(“TIOCSTI should succeed in permissive mode without CAP_SYS_ADMIN”);
- }
+}
+/* Test case 2: legacy_tiocsti `= 0, without CAP_SYS_ADMIN (should fail) */ +TEST_F(tty_tiocsti, restricted_mode_nopriv) +{
- // clang-format off
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
"legacy_tiocsti sysctl not available (kernel < 6.2)");
- if (self->legacy_tiocsti_setting !' 0)
SKIP(return,
“Test requires restricted mode (legacy_tiocsti=0)”);
I think it should be possible to _set_ the state you want (if running as root) and if not settable, then do the SKIP.
Yes, we do this in v3.
- // clang-format on
- ASSERT_TRUE(self->has_tty);
- if (self->initial_cap_sys_admin) {
ASSERT_TRUE(drop_to_nobody());
ASSERT_FALSE(has_cap_sys_admin());
- }
- /* In restricted mode, TIOCSTI should fail without CAP_SYS_ADMIN */
- EXPECT_EQ(test_tiocsti_injection(self->tty_fd), -1);
- /*
* it might fail with either EPERM or EIO
* EXPECT_TRUE(errno `= EPERM || errno =' EIO)
* {
* TH_LOG(“Expected EPERM, got: %s”, strerror(errno));
* }
*/
Why are these commented out?
I think this is from v1 where I also had added SELinux-related return as a potential fix. Since that is no longer the case, v3 verifies the error codes correctly.
+}
+/* Test case 3: legacy_tiocsti `= 0, with CAP_SYS_ADMIN (should succeed) */ +TEST_F(tty_tiocsti, restricted_mode_priv) +{
- // clang-format off
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
"legacy_tiocsti sysctl not available (kernel < 6.2)");
- if (self->legacy_tiocsti_setting !' 0)
SKIP(return,
“Test requires restricted mode (legacy_tiocsti=0)”);
- // clang-format on
- /* Must have CAP_SYS_ADMIN for this test */
- if (!self->initial_cap_sys_admin)
SKIP(return, “Test requires CAP_SYS_ADMIN”);
- ASSERT_TRUE(self->has_tty);
- ASSERT_TRUE(has_cap_sys_admin());
- /* In restricted mode, TIOCSTI should succeed with CAP_SYS_ADMIN */
- EXPECT_EQ(test_tiocsti_injection(self->tty_fd), 0)
- {
TH_LOG(“TIOCSTI should succeed in restricted mode with CAP_SYS_ADMIN”);
- }
+}
+/* Test TIOCSTI security with file descriptor passing */ +TEST_F(tty_tiocsti, fd_passing_security) +{
- // clang-format off
- if (self->legacy_tiocsti_setting < 0)
SKIP(return,
“legacy_tiocsti sysctl not available (kernel < 6.2)”);
- if (self->legacy_tiocsti_setting != 0)
SKIP(return,
“Test requires restricted mode (legacy_tiocsti=0)”);
- // clang-format on
- /* Must start with CAP_SYS_ADMIN */
- if (!self->initial_cap_sys_admin)
SKIP(return, “Test requires initial CAP_SYS_ADMIN”);
- int sockpair[2];
- pid_t child_pid;
- ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sockpair), 0);
- child_pid = fork();
- ASSERT_GE(child_pid, 0)
- TH_LOG(“Fork failed: %s”, strerror(errno));
- if (child_pid == 0) {
/* Child process - become unprivileged, open TTY, send FD to parent */
close(sockpair[0]);
TH_LOG(“Child: Dropping privileges…”);
/* Drop to nobody user (loses all capabilities) */
drop_to_nobody();
/* Verify we no longer have CAP_SYS_ADMIN */
if (has_cap_sys_admin()) {
TH_LOG(“Child: Failed to drop CAP_SYS_ADMIN”);
_exit(1);
}
Please double check the behavior but I think you can use ASSERTs in the child and you don’t need the “_exit(1)” stuff.
TH_LOG(“Child: Opening TTY as unprivileged user…”);
int unprivileged_tty_fd = open(“/dev/tty”, O_RDWR);
if (unprivileged_tty_fd < 0) {
TH_LOG(“Child: Cannot open TTY: %s”, strerror(errno));
_exit(1);
}
/* Test that we can’t use TIOCSTI directly (should fail) */
char test_char = ’X’;
if (ioctl(unprivileged_tty_fd, TIOCSTI, &test_char) == 0) {
TH_LOG(“Child: ERROR - Direct TIOCSTI succeeded unexpectedly!”);
close(unprivileged_tty_fd);
_exit(1);
}
TH_LOG(“Child: Good - Direct TIOCSTI failed as expected: %s”,
strerror(errno));
/* Send the TTY FD to privileged parent via SCM_RIGHTS */
TH_LOG(“Child: Sending TTY FD to privileged parent…”);
if (send_fd_via_socket(sockpair[1], unprivileged_tty_fd) != 0) {
TH_LOG(“Child: Failed to send FD”);
close(unprivileged_tty_fd);
_exit(1);
}
close(unprivileged_tty_fd);
close(sockpair[1]);
_exit(0); /* Child success */
- } else {
This doesn’t need an else nor indenting: it is the parent no matter what due to the _exit above.
/* Parent process - keep CAP_SYS_ADMIN, receive FD, test TIOCSTI */
close(sockpair[1]);
TH_LOG(“Parent: Waiting for TTY FD from unprivileged child…”);
/* Verify we still have CAP_SYS_ADMIN */
ASSERT_TRUE(has_cap_sys_admin());
/* Receive the TTY FD from unprivileged child */
int received_fd = recv_fd_via_socket(sockpair[0]);
ASSERT_GE(received_fd, 0)
TH_LOG(“Parent: Received FD %d (opened by unprivileged process)”,
received_fd);
/*
* VULNERABILITY TEST: Try TIOCSTI with FD opened by unprivileged process
* This should FAIL even though parent has CAP_SYS_ADMIN
* because the FD was opened by unprivileged process
*/
char attack_char = ’V’; /* V for Vulnerability */
int ret = ioctl(received_fd, TIOCSTI, &attack_char);
Doesn’t the child need to stay alive long enough to receive the character? i.e. is it a problem that the child immediately exits 0 after sending the fd to the parent?
Yep. In v3, we have the child wait for a character to be received from the parent confirming that the fd has been received.
TH_LOG(“Parent: Testing TIOCSTI on FD from unprivileged process…”);
if (ret `= 0) {
TH_LOG("*** VULNERABILITY DETECTED ***");
TH_LOG("Privileged process can use TIOCSTI on unprivileged FD");
} else {
TH_LOG("TIOCSTI failed on unprivileged FD: %s",
strerror(errno));
EXPECT_EQ(errno, EPERM);
}
Shouldn't this be arranged with an expect on ret =' 0 ?
close(received_fd);
close(sockpair[0]);
/* Wait for child */
int status;
ASSERT_EQ(waitpid(child_pid, &status, 0), child_pid);
EXPECT_EQ(WEXITSTATUS(status), 0);
ASSERT_NE(ret, 0);
- }
+}
+TEST_HARNESS_MAIN
Looks like you’re on the right track!
-Kees
Thanks!
I look forward to your comments and feedback on v3.
Best, Abhinav
• Link to v3: https://lore.kernel.org/all/20250730-toicsti-bug-v3-1-dd2dac97f27a@gmail.com/
linux-kselftest-mirror@lists.linaro.org