Hi, On Wed, Jul 30, 2025 at 06:14:43PM -0600, Abhinav Saxena wrote:
<snip> > --- > tools/testing/selftests/tty/Makefile | 6 +- > tools/testing/selftests/tty/config | 1 + > tools/testing/selftests/tty/tty_tiocsti_test.c | 650 > +++++++++++++++++++++++++ > 3 files changed, 656 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 Is it necessary to append -lcap to LDLIBS twice? Once globally and once for that TU? > 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..1eafef6e36fa > --- /dev/null > +++ b/tools/testing/selftests/tty/tty_tiocsti_test.c > @@ -0,0 +1,650 @@ > +// SPDX-License-Identifier: GPL-2.0 > +/* > + * TTY Tests - TIOCSTI > + * > + * Copyright © 2025 Abhinav Saxena <[email protected]> > + */ > + > +#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 <pty.h> > +#include <utmp.h> > + > +#include "../kselftest_harness.h" > + > +enum test_type { > + TEST_PTY_TIOCSTI_BASIC, > + TEST_PTY_TIOCSTI_FD_PASSING, > + /* other tests cases such as serial may be added. */ > +}; > + > +/* > + * Test Strategy: > + * - Basic tests: Use PTY with/without TIOCSCTTY (controlling terminal for > + * current process) > + * - FD passing tests: Child creates PTY, parent receives FD (demonstrates > + * security issue) > + * > + * SECURITY VULNERABILITY DEMONSTRATION: > + * FD passing tests show that TIOCSTI uses CURRENT process credentials, not > + * opener credentials. This means privileged processes can be given FDs from > + * unprivileged processes and successfully perform TIOCSTI operations that > the > + * unprivileged process couldn't do directly. > + * > + * Attack scenario: > + * 1. Unprivileged process opens TTY (direct TIOCSTI fails due to lack of > + * privileges) > + * 2. Unprivileged process passes FD to privileged process via SCM_RIGHTS > + * 3. Privileged process can use TIOCSTI on the FD (succeeds due to its > + * privileges) > + * 4. Result: Effective privilege escalation via file descriptor passing > + * > + * This matches the kernel logic in tiocsti(): > + * 1. if (!tty_legacy_tiocsti && !capable(CAP_SYS_ADMIN)) return -EIO; > + * 2. if ((current->signal->tty != tty) && !capable(CAP_SYS_ADMIN)) > + * return -EPERM; > + * Note: Both checks use capable() on CURRENT process, not FD opener! > + * > + * If the file credentials were also checked along with the capable() checks > + * then the results for FD pass tests would be consistent with the basic > tests. > + */ > + > +FIXTURE(tiocsti) > +{ > + int pty_master_fd; /* PTY - for basic tests */ > + int pty_slave_fd; > + bool has_pty; > + bool initial_cap_sys_admin; > + int original_legacy_tiocsti_setting; > + bool can_modify_sysctl; > +}; > + > +FIXTURE_VARIANT(tiocsti) > +{ > + const enum test_type test_type; > + const bool controlling_tty; /* true=current->signal->tty == tty */ > + const int legacy_tiocsti; /* 0=restricted, 1=permissive */ > + const bool requires_cap; /* true=with CAP_SYS_ADMIN, false=without */ > + const int expected_success; /* 0=success, -EIO/-EPERM=specific error */ > +}; > + > +/* > + * Tests Controlling Terminal Variants (current->signal->tty == tty) > + * > + * TIOCSTI Test Matrix: > + * > + * | legacy_tiocsti | CAP_SYS_ADMIN | Expected Result | Error | > + * |----------------|---------------|-----------------|-------| > + * | 1 (permissive) | true | SUCCESS | - | > + * | 1 (permissive) | false | SUCCESS | - | > + * | 0 (restricted) | true | SUCCESS | - | > + * | 0 (restricted) | false | FAILURE | -EIO | > + */ > + > +/* clang-format off */ > +FIXTURE_VARIANT_ADD(tiocsti, basic_pty_permissive_withcap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = true, > + .legacy_tiocsti = 1, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, basic_pty_permissive_nocap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = true, > + .legacy_tiocsti = 1, > + .requires_cap = false, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, basic_pty_restricted_withcap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = true, > + .legacy_tiocsti = 0, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, basic_pty_restricted_nocap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = true, > + .legacy_tiocsti = 0, > + .requires_cap = false, > + .expected_success = -EIO, /* FAILURE: legacy restriction */ > +}; /* clang-format on */ > + > +/* > + * Note for FD Passing Test Variants > + * Since we're testing the scenario where an unprivileged process pass an FD > + * to a privileged one, .requires_cap here means the caps of the child > process. > + * Not the parent; parent would always be privileged. > + */ > + > +/* clang-format off */ > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_permissive_withcap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = true, > + .legacy_tiocsti = 1, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_permissive_nocap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = true, > + .legacy_tiocsti = 1, > + .requires_cap = false, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_restricted_withcap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = true, > + .legacy_tiocsti = 0, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_restricted_nocap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = true, > + .legacy_tiocsti = 0, > + .requires_cap = false, > + .expected_success = -EIO, > +}; /* clang-format on */ > + > +/* > + * Non-Controlling Terminal Variants (current->signal->tty != tty) > + * > + * TIOCSTI Test Matrix: > + * > + * | legacy_tiocsti | CAP_SYS_ADMIN | Expected Result | Error | > + * |----------------|---------------|-----------------|-------| > + * | 1 (permissive) | true | SUCCESS | - | > + * | 1 (permissive) | false | FAILURE | -EPERM| > + * | 0 (restricted) | true | SUCCESS | - | > + * | 0 (restricted) | false | FAILURE | -EIO | > + */ > + > +/* clang-format off */ > +FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_permissive_withcap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = false, > + .legacy_tiocsti = 1, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_permissive_nocap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = false, > + .legacy_tiocsti = 1, > + .requires_cap = false, > + .expected_success = -EPERM, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_restricted_withcap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = false, > + .legacy_tiocsti = 0, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_restricted_nocap) { > + .test_type = TEST_PTY_TIOCSTI_BASIC, > + .controlling_tty = false, > + .legacy_tiocsti = 0, > + .requires_cap = false, > + .expected_success = -EIO, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_permissive_withcap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = false, > + .legacy_tiocsti = 1, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_permissive_nocap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = false, > + .legacy_tiocsti = 1, > + .requires_cap = false, > + .expected_success = -EPERM, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_restricted_withcap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = false, > + .legacy_tiocsti = 0, > + .requires_cap = true, > + .expected_success = 0, > +}; > + > +FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_restricted_nocap) { > + .test_type = TEST_PTY_TIOCSTI_FD_PASSING, > + .controlling_tty = false, > + .legacy_tiocsti = 0, > + .requires_cap = false, > + .expected_success = -EIO, > +}; /* clang-format on */ > + > +/* 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; > +} > + > +/* > + * Drop to nobody user (uid/gid 65534) to lose all capabilities > + */ > +static inline bool drop_to_nobody(struct __test_metadata *_metadata) > +{ Maybe we can retrieve the uid/gid from getpwnam(3) with: const struct passwd *pw = getpwnam("nobody"); ... then use pw->pw_{uid,gid}. I suggest this because there might be portability issues with the hardcoded 65534 -- not 100% sure though. > + ASSERT_EQ(setgroups(0, NULL), 0); > + ASSERT_EQ(setgid(65534), 0); > + ASSERT_EQ(setuid(65534), 0); > + > + ASSERT_FALSE(has_cap_sys_admin()); > + return true; > +} > + > +static inline int get_legacy_tiocsti_setting(struct __test_metadata > *_metadata) > +{ > + FILE *fp; > + int value = -1; > + > + fp = fopen("/proc/sys/dev/tty/legacy_tiocsti", "r"); > + if (!fp) { > + /* legacy_tiocsti sysctl not available (kernel < 6.2) */ > + return -1; > + } > + > + if (fscanf(fp, "%d", &value) == 1) { > + if (value < 0 || value > 1) > + value = -1; /* Invalid value */ > + } else { > + value = -1; /* Failed to parse */ > + } > + > + fclose(fp); > + return value; > +} > + <snip> > --- > base-commit: 283564a43383d6f26a55546fe9ae345b5fa95e66 > change-id: 20250618-toicsti-bug-7822b8e94a32 > > Best regards, > -- > Abhinav Saxena <[email protected]> > > Justin

