CVE-2021-3156分析

一、前言

  • sudo是Linux中一个非常重要的管理权限的软件,它允许用户使用 root 权限来运行程序。而CVE-2021-3156是sudo中存在一个堆溢出漏洞。通过该漏洞,任何没有特权的用户均可使用默认的sudo配置获取root权限。

  • 该漏洞可以影响从1.8.2~1.8.31p2下的所有旧版本sudo,以及1.9.0~1.9.5p1的所有稳定版sudo。

    Qualys漏洞团队于2021-01-13联系 sudo 团队、2021-01-26正式披露。

  • 由于这个漏洞原理较为简单,同时又涉及到提权这种高危操作,并且其影响广泛(笔者一台虚拟机、一个WSL以及一台阿里云服务器均可被攻击),相当有趣。所以我们接下来就来简单分析一下这个漏洞。

二、环境搭建

  • 首先通过以下命令获取 sudo 的源代码:

    1
    sudo apt-get source sudo

    由于获取源代码时,apt-get 提示可直接 git clone 该程序的仓库,因此我们就直接 clone 其仓库:

    1
    git clone https://salsa.debian.org/debian/sudo.git
  • 切换分支并编译 sudo,注意不要 install 。

    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
    # 注意此时的工作目录必须是git仓库的根目录
    # 以笔者为例,此时笔者的git仓库根目录为 /usr/class/myPoc/CVE-2021-3156/sudo
    # 此时笔者所使用的终端处于非root权限
    # 切换分支。笔者切换到了最后一个漏洞版本
    git reset --hard 36955b3ef399efeea25824d32e6cfbaa444e9f07 # v1.9.5p1

    # 编译, 这里设置了sudo查找sudo.conf、sudoers以及sodoers.so的路径。
    # 原指令为 ./configure --sysconfdir=<repo>/examples --with-plugindir=<repo>/plugins/sudoers/.libs && make
    ./configure --sysconfdir=/usr/class/myPoc/CVE-2021-3156/sudo/examples --with-plugindir=/usr/class/myPoc/CVE-2021-3156/sudo/plugins/sudoers/.libs/ && make

    # 需要注意的是,sudo.conf、sodoers.so以及sudoers这三个文件的owner必须是root,否则会执行失败
    sudo chown root:root examples/sudo.conf
    sudo chown root:root examples/sudoers
    sudo chown root:root plugins/sudoers/.libs/sudoers.so

    # 切换工作路径至sudo的二进制文件路径
    cd src/.libs

    # 手动建立一个 sudoedit 链接
    sudo ln -s sudo sudoedit

    # 设置环境变量,原指令为:export LD_LIBRARY_PATH="<repo>/lib/util/.libs"
    export LD_LIBRARY_PATH=/usr/class/myPoc/CVE-2021-3156/sudo/lib/util/.libs

    # 设置sudo权限
    # sudo的权限设置比较特殊,按如下操作:
    sudo chown root:root ./sudo
    sudo chmod 4755 ./sudo

    # 在root权限下执行sudo以及sudoedit
    ./sudo
    ./sudoedit

    环境配置到最后,root权限下已经可以执行编译出的sudo了。但无论有没有设置 LD_LIBRARY_PATH,普通用户仍然执行不了编译出的sudo。普通用户执行编译出的sudo的报错如下:

    1
    ./sudo: error while loading shared libraries: libsudo_util.so.0: cannot open shared object file: No such file or directory

    既然普通用户执行不了sudo,那就先暂时用root权限调试。

三、漏洞细节

1. parse_args 添加转义

在main函数中,程序会调用parse_args函数以处理传入的参数。其中有一个处理转义字符的代码片段:

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
/*
* Command line argument parsing.
* Sets nargc and nargv which corresponds to the argc/argv we'll use
* for the command to be run (if we are running one).
*/
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
// ...
/*
* For shell mode we need to rewrite argv
*/
// 条件:当 mode 设置了 MODE_RUN,并且 flags 设置了 MODE_SHELL
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL))
{
// 开始构造 "shell -c <command>"指令
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;

cmnd = dst = reallocarray(NULL, cmnd_size, 2);
// ...
// 开始处理传入的参数
for (av = argv; *av != NULL; av++)
{
for (src = *av; *src != '\0'; src++)
{
/* quote potential meta characters */
// 将一些字符转义,即如果发现 _-$ 字符,则在新构造出的<command>中加上 `\`
if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\';
*dst++ = *src;
}
*dst++ = ' ';
}
if (cmnd != dst)
dst--; /* replace last space with a NUL */
*dst = '\0';

ac += 2; /* -c cmnd */
}

av = reallocarray(NULL, ac + 1, sizeof(char *));
// ...

av[0] = (char *)user_details.shell; /* plugin may override shell */
if (cmnd != NULL)
{
av[1] = "-c";
av[2] = cmnd;
}
av[ac] = NULL;

argv = av;
argc = ac;
}
// ...
}

当程序设置了 MODE_RUN 和 MODE_SHELL 标志后,控制流就会进入内部代码,构造 shell -c <command>指令,并在其中处理<command>中的一些转义字符,在这些转义字符前添加反斜杠。

若执行 sudo 时设置了 -s-i参数,则在parse_args函数中将会同时设置 MODE_RUN 和 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
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
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
/* XXX - should fill in settings at the end to avoid dupes */
for (;;)
{
/*
* Some trickiness is required to allow environment variables
* to be interspersed with command line options.
*/
if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1)
{
switch (ch)
{
// ...
case 'i':
sudo_settings[ARG_LOGIN_SHELL].value = "true";
// 设置 MODE_LOGIN_SHELL
SET(flags, MODE_LOGIN_SHELL);
break;
// ...
case 's':
sudo_settings[ARG_USER_SHELL].value = "true";
// 设置 flags 为 MODE_SHELL.
SET(flags, MODE_SHELL);
break;
// ...
}
}
// ...
}
// ...
if (!mode)
{
/* Defer -k mode setting until we know whether it is a flag or not */
if (sudo_settings[ARG_IGNORE_TICKET].value != NULL)
{
if (argc == 0 && !(flags & (MODE_SHELL | MODE_LOGIN_SHELL)))
{
mode = MODE_INVALIDATE; /* -k by itself */
sudo_settings[ARG_IGNORE_TICKET].value = NULL;
valid_flags = 0;
}
}
// 如果 mode 运行到现在都还没有设置,则默认设置为 MODE_RUN
if (!mode)
mode = MODE_RUN; /* running a command */
}
// ...
// 如果设置了 MODE_LOGIN_SHELL
if (ISSET(flags, MODE_LOGIN_SHELL))
{
// ...
// 则继续设置 MODE_SHELL
SET(flags, MODE_SHELL);
}
// ...
}

这样就可以成功进入处理转义字符的代码片段。

2. set_cmnd 取消转义

当程序执行完parse_args后,沿以下调用链最终调用到set_cmnd函数:

1
2
3
4
5
int main(int argc, char *argv[], char *envp[])
static int policy_check(...)
static int sudoers_policy_check(...)
int sudoers_policy_main(...)
static int set_cmnd(void)

需要注意的是,只有在 parse_args 函数返回的 sudo_mode 设置了 MODE_RUN,才会调用 policy_check 函数,这是整条调用链上唯一的条件判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int
main(int argc, char *argv[], char *envp[])
{
// ...
/* Parse command line arguments. */
sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv,
&settings, &env_add);
// ...
switch (sudo_mode & MODE_MASK)
{
// ...
case MODE_RUN:
policy_check(nargc, nargv, env_add, &command_info, &argv_out,
&user_env_out);
// ...
}
// ...
}

在 set_cmnd 函数中,如果同时满足以下三个条件,则程序将会取消参数中的转义

  • sudo_mode 设置了 MODE_RUN | MODE_EDIT | MODE_CHECK。
  • NewArgc > 1,即待执行程序的参数个数。
  • sudo_mode 还设置了 MODE_SHELL | MODE_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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/*
* Fill in user_cmnd, user_args, user_base and user_stat variables
* and apply any command-specific defaults entries.
*/
static int
set_cmnd(void)
{
// ...
// MODE 条件1
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK))
{
// ...
/* 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;
// ...
// MODE 条件2
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)
{
// 如果识别出了反斜杠,则跳过第一个反斜杠,只复制二个反斜杠
// 例如 \$ 只复制 $
// 注意!该代码默认假设原先传入sudo的参数已经被转义。
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++;
}
*to++ = ' ';
}
*--to = '\0';
}
// ...
}
}
// ...
}

3. 漏洞触发

a. 具体细节

由于 set_cmnd 函数的执行会基于原先传入sudo的参数已经在 parse_args 中被转义的前提下,因此如果传入的参数是以单个反斜杠结尾,则在取消转义的循环中,将会产生以下影响:

  • from[0] 为反斜杠,但from[1] 是传入参数的 NULL byte。
  • 由于满足from[0] == '\\' && !isspace((unsigned char)from[1]),因此from指针向下移动 1 byte,指向参数的NULL byte。
  • 执行 *to++ = *from++,将 NULL byte 复制到 user_args 堆数组中,同时 from 指针继续向下移动,指向 NULL byte的下一个字节位置(注意此时已经超出了参数的范围)。
  • 如果此时 from 指向的不是NULL byte,那就继续循环越界写入数据至 user_args 堆数组中。

但通常我们是没有办法传入一个单反斜杠进入 set_cmnd 函数中,因为在 parse_args 函数中,若 MODE_SHELL 或 MODE_LOGIN_SHELL 标志被设置,那么所有的转义字符将在 parse_args 函数中被转义,包括反斜杠。 (MODE_RUN 默认已经设置)。

但实际上,set_cmnd 中取消转义的条件判断与 parse_args 函数中添加转义的条件判断有所不同。

Functions Mode Comditions
parse_args MODE_RUN && MODE_SHELL
set_cmnd (MODE_RUN | MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL)

那么我们能否绕过 parse_args 的添加转义操作,并到达 set_cmnd 的取消转义操作呢?即,能否在设置 MODE_SHELL 标志的前提下,取消 MODE_RUN 标志,但又设置了 MODE_EDIT 或 MODE_CHECK,使得可以绕过添加转义操作,并成功执行取消转义操作?

上面说的条件有点绕,总结一下就是这样

1
MODE_SHELL && !MODE_RUN  && (MODE_EDIT || MODE_CHECK)

答案似乎是否定的,因为如果我们直接给 sudo 传入-l-e参数,则 valid_flags 标志将会设置为 MODE_NONINTERACTIVE 或 MODE_LONG_LIST。

而此时的 flags 标志为 MODE_SHELL 或 MODE_LOGIN_SHELL,因此使得我们无法绕过一个特殊的判断条件:flags & valid_flags) != flags

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
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
// ...
for (;;)
{
if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1)
{
switch (ch)
{
// ...
case 'e':
if (mode && mode != MODE_EDIT)
usage_excl();
// 设置 mode 为 MODE_EDIT
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
// ...
case 'l':
if (mode)
{
if (mode == MODE_LIST)
SET(flags, MODE_LONG_LIST);
else
usage_excl();
}
// 设置 mode 为 MODE_LIST
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE | MODE_LONG_LIST;
break;
// ...
}
}
// ...
}
// ...
// 在此处将 MODE_LIST 更新为 MODE_CHECK
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
// ...
// 必须绕过的特殊判断条件
if ((flags & valid_flags) != flags)
usage();
// ...
}

但天无绝人之路,如果 sudo 是以 sudoedit 启动的(注意 sudoedit 是一个符号链接,直接指向 /bin/sudo),那么就可以在不修改 valid_flags 的前提下,设置 mode 为 MODE_EDIT

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
/*
* Default flags allowed when running a command.
*/
#define DEFAULT_VALID_FLAGS (MODE_BACKGROUND | MODE_PRESERVE_ENV | MODE_RESET_HOME | MODE_LOGIN_SHELL | MODE_NONINTERACTIVE | MODE_SHELL)

int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
// ...
int valid_flags = DEFAULT_VALID_FLAGS;
// ...

/* First, check to see if we were invoked as "sudoedit". */
// 如果以 sudoedit 打开
proglen = strlen(progname);
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0)
{
progname = "sudoedit";
// 则设置 mode 为 MODE_EDIT
mode = MODE_EDIT;
// 注意之后就没有再设置 valid_flags了
sudo_settings[ARG_SUDOEDIT].value = "true";
}

// ...

// 必须绕过的特殊判断条件
if ((flags & valid_flags) != flags)
usage();
// ...
}

而 valid_flags 的默认值中设置了 MODE_SHELL 以及 MODE_LOGIN_SHELL ,因此可以通过该判断条件。

所以最后,我们可以:

  • 绕过 parse_args 的添加转义操作。
  • 进入 set_cmnd 的取消转义操作。

并最终越界写入数据至堆数组 user_args。

这个漏洞相当的理想,因为它可以使得:

  • user_args 堆内存长度可控。因为 user_args的长度取决于传入 sudoedit 的参数长度:

    1
    2
    3
    4
    // 该代码片段位于 set_cmnd 函数中
    /* Alloc and build up user_args. */
    for (size = 0, av = NewArgv + 1; *av; av++)
    size += strlen(*av) + 1;
  • 越界写入的数据可控。因为存放传入 sudoedit 参数的内存位置与环境变量紧紧相临,因此我们可以通过指定特定环境变量来控制越界写入的数据:

  • 可以用单个反斜杠来写入单个NULL byte,具体请阅读上面的触发过程。

b. POC

Qualys漏洞团队给出了一个非常精简的POC,该 POC 可以触发 malloc 的 corrupt。

1
2
3
4
5
# 执行指令
sudoedit -s '\' `perl -e 'print "A" x 65536'`
# 程序输出
malloc(): corrupted top size
[1] 411260 abort sudoedit -s '\' `perl -e 'print "A" x 65536'`

可以看到这个 POC 满足我们刚刚所分析的那样:

  • 使用 sudoedit 设置 MODE_EDIT 标志
  • 使用 -s 参数设置 MODE_SHELL 标志
  • 后面带的参数中,有个参数以单个反斜杠结尾

因此可以触发 crash。

根据 Qualys 漏洞团队披露出的 exploit 构造细节(详见第二条参考连接),最少有三种构造 exp 的方式。但笔者调试时发现这其中存在一些问题:

  • 如果以第一种方式来越界写入将近 0x1000 个字节的数据至对应堆内存上,来覆盖函数指针,则在越界写入内存使用函数指针的这个过程上,存在解引用被覆盖内存上的指针的操作,这将导致程序崩溃,且没有办法绕过。

  • 如果以第二种方式来试图越界写入内存至 service_user 结构。由于 user_args 堆数组的地址高于后分配的 service_user 结构,因此我们没有办法覆盖到该结构。

    这个问题大概率受到 glibc 版本的影响,笔者在自己非标准 glibc 上测试会出现该问题。

  • 第三种方法难度较大,原理较为复杂,暂时没有去研究。

至于为什么 Qualys 漏洞团队可以利用成功,可能是因为其 exploit 是 fuzz 出的,即可以使 sudo 恰好达到预期的目的(例如使用函数指针 / 欲覆盖对象在 user_args 堆数组的高地址处等等)。

四、小结

该漏洞实际上是低权限用户突破高权限程序的保护,从而获取高权限的情形。

我们可以执行以下命令,查看 sudo 程序的权限:

1
ls /bin/sudo -al

输出如下:

1
-rwsr-xr-x 1 root root 161512 Oct 29  2019 /bin/sudo

可以看到,sudo 的 owner 是 root权限是 rwsrwx我们都知道是 可读可写可执行,但 rws 又是什么呢?

实际上,s标志代表的是 setuid标志。一个可执行文件在执行时,一般该程序只拥有调用该程序的用户具有的权限,而 setuid标志可以让普通用户以 owner 权限运行只有 owner 帐号才能运行的程序或命令。

在 sudo 这个例子中,owner 是 root

因此,倘若含有 setuid 标志的软件存在漏洞,那我们就可以通过这些漏洞来获取更高权限

以下是一个简单的 test case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// test.c
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main()
{
if(setuid(0) == -1)
printf("setuid fail\n");
if(setgid(0) == -1)
printf("setuid fail\n");
system("/bin/sh");
return 0;
}

执行以下命令:

1
2
3
4
5
6
# 当前为user权限
g++ test.c -o test
sudo chown root:root ./test
sudo chmod 4755 ./test
./test
# 新开的 /bin/sh 为 root 权限

即,对于那些 owner 为 root 、执行权限为 rws的程序,若该程序内部执行了setuid(0)setgid(0),那么该程序就成功提权至 root。

这个样例同样适用于 sudo 程序。

五、参考

  1. CVE-2021-3156: Heap-Based Buffer Overflow in Sudo (Baron Samedit)

  2. Qualys Security Advisory - Baron Samedit: Heap-based buffer overflow in Sudo (CVE-2021-3156)

  3. CVE-2021-3156:Sudo 堆缓冲区溢出漏洞通告 - 安全客

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2021 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯奶茶吧~