前言

CVE-2021-3156sudo 的一个堆溢出漏洞,可以用来进行本地提权。

漏洞影响版本为 1.8.2-1.8.31sp12, 1.9.0-1.9.5sp1sudo >=1.9.5sp2 的版本则不受影响。

环境搭建

有人制作好了 Docker 镜像,可以直接拿来用:https://hub.docker.com/r/chenaotian/cve-2021-3156

docker pull chenaotian/cve-2021-3156
docker run -d -ti --rm -h sudodebug --name sudodebug --cap-add=SYS_PTRACE chenaotian/cve-2021-3156:latest /bin/bash
docker exec -it sudodebug  /bin/bash

执行命令

sudoedit -s /

出现

sudoedit: /: not a regular file

代表存在漏洞

出现

usage: sudoedit [-AknS] [-r role] [-t type] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u
                user] file ...

代表漏洞已经被修复。

漏洞细节

取消转义

set_cmnd 函数中,如果同时满足下面的三个条件,则会取消参数中的转义:

/*
 * Fill in user_cmnd, user_args, user_base and user_stat variables
 * and apply any command-specific defaults entries.
 */
static int
set_cmnd(void)
{

条件一:sudo_mode 设置了 MODE_RUN | MODE_EDIT | MODE_CHECK

    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK))
    {
        // ...

条件二:待执行程序参数个数大于 1:

        /* 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;
            if (size == 0 || (user_args = malloc(size)) == NULL) {...}
            // ...

条件三:sudo_mode 设置了 MODE_SHELL | MODE_LOGIN_SHELL

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

那么假设我们传进来的字符串是 XXXXX\,那么理论上就会跳过最后的反斜杠,实现越界写的效果。

Tldr

取消转义的条件

  1. sudo_mode 设置了 MODE_RUN | MODE_EDIT | MODE_CHECK
  2. 待执行程序参数个数大于 1;
  3. sudo_mode 设置了 MODE_SHELL | MODE_LOGIN_SHELL

添加转义

那么该怎样调用该函数呢?在 main 函数中,它的调用链如下:

int main(int argc, char *argv[], char *envp[])
  +-> int parse_args(...)
  +-> static int policy_check(...)
    +-> int sudoers_policy_main(...)
      +-> static int set_cmnd(void)

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 *nargc, char ***nargv,
               struct sudo_settings **settingsp, char ***env_addp)
{
    // ...
    /*
     * For shell mode we need to rewrite argv
     */

条件:如果 mode 设置了 MODE_RUNflags 设置了 MODE_SHELL

    if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL))
    {
        char **av, *cmnd = NULL;
        int ac = 1;

如果 argc 不为 0,则构造 shell -c <command> 命令

        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++)
                {

如果传入的字符中包含 _-$,则在新构造的 <command> 字符串中加上 \

                    /* quote potential meta characters */
                    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;
    }
    // ...
}

Tldr

如果程序设置了 MODE_RUNMODE_SHELL 标志,sudo 就会构造 shell -c <command> 指令,并处理转义字符,对相应的字符添加反斜杠。

绕过检查

整理添加转义与取消转义的要求如下:

函数条件
parse_argsMODE_RUN && MODE_SHELL
set_cmnd(MODE_RUN | MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL)

调用 set_cmnd 的条件是调用 policy_check 函数,要求设置 MODE_EDIT | MODE_RUN

int
main(int argc, char *argv[], char *envp[])
{
    // ...
    case MODE_EDIT:
    case MODE_RUN:
        ok = policy_check(&policy_plugin, nargc, nargv, env_add,
        &command_info, &argv_out, &user_env_out);
    // ...
}

如果想要触发漏洞,就需要在不执行 parse_args 添加转义的情况下执行 set_cmnd。那么我们检查一下什么时候参会设置 MODE_RUN && MODE_SHELL 参数。

如果执行 sudo 的时候设置了 -s-i 参数,则在 parse_args 函数中会设置 MODE_SHELL 标志:

int parse_args(int argc, char **argv, 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";
                SET(flags, MODE_LOGIN_SHELL);
                break;
            // ...
            case 's':
                sudo_settings[ARG_USER_SHELL].value = "true";
                SET(flags, MODE_SHELL);
                break;
            // ...
        }
        // ...
    }
    // ...

如果此时还没有设置 mode,则默认设置 modeMODE_RUN

    if (!mode)
    {
        /* Defer -k mode setting until we know whether it is a flag or not */
        if (sudo_settings[ARG_IGNORE_TICKET].value != NULL)
        {
            // 参数为 0 且 没设置 `MODE_SHELL|MODE_LOGIN_SHELL` 才进入
            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;
            }
        }
        if (!mode)
            mode = MODE_RUN; /* running a command */
    }
    // ...

只要设置了 MODE_LOGIN_SHELL 就会设置 MODE_SHELL

    if (ISSET(flags, MODE_LOGIN_SHELL))
    {
        // ...
        SET(flags, MODE_SHELL);
    }
    // ...
}
 

可以看出,只要 flag 设置了 MODE_SHELL | MODE_LOGIN_SHELL,就一定会设置 MODE_RUN。那么有什么办法可以不设置 MODE_RUN 又满足后续的 check 吗?考虑到上面的检查要求,我们其实只需要满足 (MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL) 即可。

继续审计,如果要设置 MODE_EDITMODE_CHECK,可以通过传入以下参数实现:

int parse_args(int argc, char **argv, int *nargc, char ***nargv,
               struct sudo_settings **settingsp, char ***env_addp)
{
    /* XXX - should fill in settings at the end to avoid dupes */
    for (;;)
    {
        if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1)
        {
            switch (ch)
            {
            // ...

如果传入 -e 参数,则会设置 MODE_EDIT

            case 'e':
                if (mode && mode != MODE_EDIT)
                    usage_excl(1);
                mode = MODE_EDIT;
                sudo_settings[ARG_SUDOEDIT].value = "true";
                valid_flags = MODE_NONINTERACTIVE;
                break;
            // ...

如果传入 -l 参数,则会设置 MODE_LIST

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

如果上面设置了 MODE_LIST 参数,这里会设置 MODE_CHECK 参数

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

注意到无论是 -e 还是 -l 都设置的是 valid_flags 而不是 flags,不满足下面的特殊判断条件:

    if ((flags & valid_flags) != flags)
        usage(1);
    // ...
}
 

那么接下来的问题就是该怎样绕过该特殊判断条件。

全局搜索 MODE_CHECK/MODE_EDIT,可以发现如果是用 sudoedit 启动的 sudo,就可以在不修改 valid_flags 的前提下设置 modeMODE_EDIT

int parse_args(int argc, char **argv, int *nargc, char ***nargv,
               struct sudo_settings **settingsp, char ***env_addp)
{
    // ...
    int valid_flags = DEFAULT_VALID_FLAGS;
    // ...
    /* Pass progname to plugin so it can call initprogname() */
    progname = getprogname();
    sudo_settings[ARG_PROGNAME].value = progname;
 
    /* 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";
    }
    // ...
}

对于另一个判断条件 (MODE_SHELL | MODE_LOGIN_SHELL),我们可以传入 -s 参数,由于已经设置了 mode = MODE_EDIT,就不会进入设置 mode = MODE_RUN 的逻辑了:

    // 如果是 sudoedit,不会进入这个逻辑
    if (!mode)
    {
        // ...
        if (!mode)
            mode = MODE_RUN; /* running a command */
    }
    // ...

这样,我们就可以绕过 parse_args 添加转义的操作,并进入 set_cmnd 取消转义,实现堆溢出了。

触发漏洞

Qualys 团队给出了一段紧凑的 PoC:

sudoedit -s '\' `perl -e 'print "A" x 65536'`

进入 main 函数时内存布局如下:

在执行到 plugins/sudoers/sudoers.c:set_cmnd 的为 Args 申请内存时

    /* Alloc and build up user_args. */
    for (size = 0, av = NewArgv + 1; *av; av++)
    size += strlen(*av) + 1;

很容易发现这类的 *av 对应的就是传入参数的地址,显然下面就会发生堆溢出了:

这个漏洞非常友好,我们可以通过控制命令行参数来控制申请堆块的大小、溢出的内容和溢出的长度,还可以通过以反斜杠结尾的方式实现向目标地址写 0 的操作。

补丁分析

patch 链接为:https://www.sudo.ws/repos/sudo/rev/049ad90590be,主要修改了 plugins/sudoers/sudoers.c

这里判断 sudo_mode 需要设置 MODE_RUN|MODE_EDIT,由于 #define ISSET(t, f)     ((t) & (f)),因此可以认为没什么变化。

--- a/plugins/sudoers/sudoers.c    Sat Jan 23 08:43:59 2021 -0700
+++ b/plugins/sudoers/sudoers.c    Sat Jan 23 08:43:59 2021 -0700
@@ -547,7 +547,7 @@
 
     /* If run as root with SUDO_USER set, set sudo_user.pw to that user. */
     /* XXX - causes confusion when root is not listed in sudoers */
-    if (sudo_mode & (MODE_RUN | MODE_EDIT) && prev_user != NULL) {
+    if (ISSET(sudo_mode, MODE_RUN|MODE_EDIT) && prev_user != NULL) {
     if (user_uid == 0 && strcmp(prev_user, "root") != 0) {
         struct passwd *pw;

这里要求 sudo_mode 不能为 MODE_EDIT

@@ -932,8 +932,8 @@
     if (user_cmnd == NULL)
     user_cmnd = NewArgv[0];
 
-    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
-    if (ISSET(sudo_mode, MODE_RUN | MODE_CHECK)) {
+    if (ISSET(sudo_mode, MODE_RUN|MODE_EDIT|MODE_CHECK)) {
+    if (!ISSET(sudo_mode, MODE_EDIT)) {
         const char *runchroot = user_runchroot;
         if (runchroot == NULL && def_runchroot != NULL &&
             strcmp(def_runchroot, "*") != 0)

这里检查 sudo_mode 必须还有 MODE_RUN

@@ -961,7 +961,8 @@
         sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
         debug_return_int(NOT_FOUND_ERROR);
         }
-        if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
+        if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL) &&
+            ISSET(sudo_mode, MODE_RUN)) {
         /*
          * When running a command via a shell, the sudo front-end
          * escapes potential meta chars.  We unescape non-spaces

这里添加了新的检查,反斜杠后面的字符后添加了不能为 \0 的判断。

@@ -969,10 +970,22 @@
          */
         for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
             while (*from) {
-            if (from[0] == '\\' && !isspace((unsigned char)from[1]))
+            if (from[0] == '\\' && from[1] != '\0' &&
+                !isspace((unsigned char)from[1])) {
                 from++;
+            }
+            if (size - (to - user_args) < 1) {
+                sudo_warnx(U_("internal error, %s overflow"),
+                __func__);
+                debug_return_int(NOT_FOUND_ERROR);
+            }
             *to++ = *from++;
             }
+            if (size - (to - user_args) < 1) {
+            sudo_warnx(U_("internal error, %s overflow"),
+                __func__);
+            debug_return_int(NOT_FOUND_ERROR);
+            }
             *to++ = ' ';
         }
         *--to = '\0';

总的来说,修复后的转义条件为:

函数条件
parse_argsMODE_RUN && MODE_SHELL
set_cmnd(MODE_RUN | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL)

之后又添加了溢出的检测,从而修复了漏洞。

参考资料