2

CVE-2021-3156分析

 2 years ago
source link: https://kiprey.github.io/2021/01/CVE-2021-3156/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
  • 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 的源代码:

    sudo apt-get source sudo

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

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

    # 注意此时的工作目录必须是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的报错如下:

    ./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函数以处理传入的参数。其中有一个处理转义字符的代码片段:

/*
* 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 标志:

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函数:

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 函数,这是整条调用链上唯一的条件判断。

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。

具体代码见如下:

/*
* 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,使得可以绕过添加转义操作,并成功执行取消转义操作?

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

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

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

/*
* 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 的参数长度:

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

    img

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

b. POC

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

# 执行指令
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 程序的权限:

ls /bin/sudo -al

输出如下:

-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:

// 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;
}

执行以下命令:

# 当前为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 程序。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK