前言
CVE-2021-3156
是 sudo
的一个堆溢出漏洞,可以用来进行本地提权。
漏洞影响版本为 1.8.2-1.8.31sp12, 1.9.0-1.9.5sp1
,sudo >=1.9.5sp2
的版本则不受影响。
环境搭建
有人制作好了 Docker 镜像,可以直接拿来用:https://hub.docker.com/r/chenaotian/cve-2021-3156
docker pull chenaotian/cve-2021-3156
docker run -d -ti --rm -h sudodebug --name sudodebug --cap-add=SYS_PTRACE chenaotian/cve-2021-3156:latest /bin/bash
docker exec -it sudodebug /bin/bash
执行命令
sudoedit -s /
出现
sudoedit: /: not a regular file
代表存在漏洞
出现
usage: sudoedit [-AknS] [-r role] [-t type] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u
user] file ...
代表漏洞已经被修复。
漏洞细节
取消转义
在 set_cmnd
函数中,如果同时满足下面的三个条件,则会取消参数中的转义:
/*
* Fill in user_cmnd, user_args, user_base and user_stat variables
* and apply any command-specific defaults entries.
*/
static int
set_cmnd(void)
{
条件一:sudo_mode
设置了 MODE_RUN | MODE_EDIT | MODE_CHECK
:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK))
{
// ...
条件二:待执行程序参数个数大于 1:
/* set user_args */
if (NewArgc > 1)
{
char *to, *from, **av;
size_t size, n;
/* Alloc and build up user_args. */
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
if (size == 0 || (user_args = malloc(size)) == NULL) {...}
// ...
条件三:sudo_mode
设置了 MODE_SHELL | MODE_LOGIN_SHELL
:
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.
*/
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';
}
// ...
}
}
// ...
}
那么假设我们传进来的字符串是 XXXXX\
,那么理论上就会跳过最后的反斜杠,实现越界写的效果。
Tldr
取消转义的条件
sudo_mode
设置了MODE_RUN | MODE_EDIT | MODE_CHECK
;- 待执行程序参数个数大于 1;
sudo_mode
设置了MODE_SHELL | MODE_LOGIN_SHELL
。
添加转义
那么该怎样调用该函数呢?在 main 函数中,它的调用链如下:
int main(int argc, char *argv[], char *envp[])
+-> int parse_args(...)
+-> static int policy_check(...)
+-> int sudoers_policy_main(...)
+-> static int set_cmnd(void)
在 parse_args
函数中,处理转义字符的逻辑如下:
/*
* Command line argument parsing.
* Sets nargc and nargv which corresponds to the argc/argv we'll use
* for the command to be run (if we are running one).
*/
int parse_args(int argc, char **argv, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
// ...
/*
* For shell mode we need to rewrite argv
*/
条件:如果 mode
设置了 MODE_RUN
且 flags
设置了 MODE_SHELL
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL))
{
char **av, *cmnd = NULL;
int ac = 1;
如果 argc
不为 0,则构造 shell -c <command>
命令
if (argc != 0)
{
/* shell -c "command" */
char *src, *dst;
size_t cmnd_size = (size_t)(argv[argc - 1] - argv[0]) +
strlen(argv[argc - 1]) + 1;
cmnd = dst = reallocarray(NULL, cmnd_size, 2);
// ...
开始处理传入的参数:
for (av = argv; *av != NULL; av++)
{
for (src = *av; *src != '\0'; src++)
{
如果传入的字符中包含 _-$
,则在新构造的 <command>
字符串中加上 \
:
/* quote potential meta characters */
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 */
}
av = reallocarray(NULL, ac + 1, sizeof(char *));
// ...
av[0] = (char *)user_details.shell; /* plugin may override shell */
if (cmnd != NULL)
{
av[1] = "-c";
av[2] = cmnd;
}
av[ac] = NULL;
argv = av;
argc = ac;
}
// ...
}
Tldr
如果程序设置了
MODE_RUN
和MODE_SHELL
标志,sudo 就会构造shell -c <command>
指令,并处理转义字符,对相应的字符添加反斜杠。
绕过检查
整理添加转义与取消转义的要求如下:
函数 | 条件 |
---|---|
parse_args | MODE_RUN && MODE_SHELL |
set_cmnd | (MODE_RUN | MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL) |
调用 set_cmnd
的条件是调用 policy_check
函数,要求设置 MODE_EDIT | MODE_RUN
:
int
main(int argc, char *argv[], char *envp[])
{
// ...
case MODE_EDIT:
case MODE_RUN:
ok = policy_check(&policy_plugin, nargc, nargv, env_add,
&command_info, &argv_out, &user_env_out);
// ...
}
如果想要触发漏洞,就需要在不执行 parse_args
添加转义的情况下执行 set_cmnd
。那么我们检查一下什么时候参会设置 MODE_RUN && MODE_SHELL
参数。
如果执行 sudo 的时候设置了 -s
或 -i
参数,则在 parse_args
函数中会设置 MODE_SHELL
标志:
int parse_args(int argc, char **argv, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
// ...
/* XXX - should fill in settings at the end to avoid dupes */
for (;;)
{
/*
* Some trickiness is required to allow environment variables
* to be interspersed with command line options.
*/
if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1)
{
switch (ch)
{
// ...
case 'i':
sudo_settings[ARG_LOGIN_SHELL].value = "true";
SET(flags, MODE_LOGIN_SHELL);
break;
// ...
case 's':
sudo_settings[ARG_USER_SHELL].value = "true";
SET(flags, MODE_SHELL);
break;
// ...
}
// ...
}
// ...
如果此时还没有设置 mode
,则默认设置 mode
为 MODE_RUN
:
if (!mode)
{
/* Defer -k mode setting until we know whether it is a flag or not */
if (sudo_settings[ARG_IGNORE_TICKET].value != NULL)
{
// 参数为 0 且 没设置 `MODE_SHELL|MODE_LOGIN_SHELL` 才进入
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;
}
}
if (!mode)
mode = MODE_RUN; /* running a command */
}
// ...
只要设置了 MODE_LOGIN_SHELL
就会设置 MODE_SHELL
:
if (ISSET(flags, MODE_LOGIN_SHELL))
{
// ...
SET(flags, MODE_SHELL);
}
// ...
}
可以看出,只要 flag
设置了 MODE_SHELL | MODE_LOGIN_SHELL
,就一定会设置 MODE_RUN
。那么有什么办法可以不设置 MODE_RUN
又满足后续的 check 吗?考虑到上面的检查要求,我们其实只需要满足 (MODE_EDIT | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL)
即可。
继续审计,如果要设置 MODE_EDIT
和 MODE_CHECK
,可以通过传入以下参数实现:
int parse_args(int argc, char **argv, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
/* XXX - should fill in settings at the end to avoid dupes */
for (;;)
{
if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1)
{
switch (ch)
{
// ...
如果传入 -e
参数,则会设置 MODE_EDIT
:
case 'e':
if (mode && mode != MODE_EDIT)
usage_excl(1);
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
// ...
如果传入 -l
参数,则会设置 MODE_LIST
:
case 'l':
if (mode)
{
if (mode == MODE_LIST)
SET(flags, MODE_LONG_LIST);
else
usage_excl(1);
}
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE | MODE_LONG_LIST;
break;
// ...
}
}
// ...
}
// ...
如果上面设置了 MODE_LIST
参数,这里会设置 MODE_CHECK
参数
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
// ...
注意到无论是 -e
还是 -l
都设置的是 valid_flags
而不是 flags
,不满足下面的特殊判断条件:
if ((flags & valid_flags) != flags)
usage(1);
// ...
}
那么接下来的问题就是该怎样绕过该特殊判断条件。
全局搜索 MODE_CHECK/MODE_EDIT
,可以发现如果是用 sudoedit 启动的 sudo,就可以在不修改 valid_flags
的前提下设置 mode
为 MODE_EDIT
:
int parse_args(int argc, char **argv, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
// ...
int valid_flags = DEFAULT_VALID_FLAGS;
// ...
/* Pass progname to plugin so it can call initprogname() */
progname = getprogname();
sudo_settings[ARG_PROGNAME].value = progname;
/* 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";
}
// ...
}
对于另一个判断条件 (MODE_SHELL | MODE_LOGIN_SHELL)
,我们可以传入 -s
参数,由于已经设置了 mode = MODE_EDIT
,就不会进入设置 mode = MODE_RUN
的逻辑了:
// 如果是 sudoedit,不会进入这个逻辑
if (!mode)
{
// ...
if (!mode)
mode = MODE_RUN; /* running a command */
}
// ...
这样,我们就可以绕过 parse_args
添加转义的操作,并进入 set_cmnd
取消转义,实现堆溢出了。
触发漏洞
Qualys 团队给出了一段紧凑的 PoC:
sudoedit -s '\' `perl -e 'print "A" x 65536'`
进入 main 函数时内存布局如下:
在执行到 plugins/sudoers/sudoers.c:set_cmnd
的为 Args 申请内存时
/* Alloc and build up user_args. */
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
很容易发现这类的 *av
对应的就是传入参数的地址,显然下面就会发生堆溢出了:
这个漏洞非常友好,我们可以通过控制命令行参数来控制申请堆块的大小、溢出的内容和溢出的长度,还可以通过以反斜杠结尾的方式实现向目标地址写 0 的操作。
补丁分析
patch 链接为:https://www.sudo.ws/repos/sudo/rev/049ad90590be,主要修改了 plugins/sudoers/sudoers.c
这里判断 sudo_mode
需要设置 MODE_RUN|MODE_EDIT
,由于 #define ISSET(t, f) ((t) & (f))
,因此可以认为没什么变化。
--- a/plugins/sudoers/sudoers.c Sat Jan 23 08:43:59 2021 -0700
+++ b/plugins/sudoers/sudoers.c Sat Jan 23 08:43:59 2021 -0700
@@ -547,7 +547,7 @@
/* If run as root with SUDO_USER set, set sudo_user.pw to that user. */
/* XXX - causes confusion when root is not listed in sudoers */
- if (sudo_mode & (MODE_RUN | MODE_EDIT) && prev_user != NULL) {
+ if (ISSET(sudo_mode, MODE_RUN|MODE_EDIT) && prev_user != NULL) {
if (user_uid == 0 && strcmp(prev_user, "root") != 0) {
struct passwd *pw;
这里要求 sudo_mode
不能为 MODE_EDIT
。
@@ -932,8 +932,8 @@
if (user_cmnd == NULL)
user_cmnd = NewArgv[0];
- if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
- if (ISSET(sudo_mode, MODE_RUN | MODE_CHECK)) {
+ if (ISSET(sudo_mode, MODE_RUN|MODE_EDIT|MODE_CHECK)) {
+ if (!ISSET(sudo_mode, MODE_EDIT)) {
const char *runchroot = user_runchroot;
if (runchroot == NULL && def_runchroot != NULL &&
strcmp(def_runchroot, "*") != 0)
这里检查 sudo_mode
必须还有 MODE_RUN
。
@@ -961,7 +961,8 @@
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
debug_return_int(NOT_FOUND_ERROR);
}
- if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
+ if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL) &&
+ ISSET(sudo_mode, MODE_RUN)) {
/*
* When running a command via a shell, the sudo front-end
* escapes potential meta chars. We unescape non-spaces
这里添加了新的检查,反斜杠后面的字符后添加了不能为 \0
的判断。
@@ -969,10 +970,22 @@
*/
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
while (*from) {
- if (from[0] == '\\' && !isspace((unsigned char)from[1]))
+ if (from[0] == '\\' && from[1] != '\0' &&
+ !isspace((unsigned char)from[1])) {
from++;
+ }
+ if (size - (to - user_args) < 1) {
+ sudo_warnx(U_("internal error, %s overflow"),
+ __func__);
+ debug_return_int(NOT_FOUND_ERROR);
+ }
*to++ = *from++;
}
+ if (size - (to - user_args) < 1) {
+ sudo_warnx(U_("internal error, %s overflow"),
+ __func__);
+ debug_return_int(NOT_FOUND_ERROR);
+ }
*to++ = ' ';
}
*--to = '\0';
总的来说,修复后的转义条件为:
函数 | 条件 |
---|---|
parse_args | MODE_RUN && MODE_SHELL |
set_cmnd | (MODE_RUN | MODE_CHECK) && (MODE_SHELL | MODE_LOGIN_SHELL) |
之后又添加了溢出的检测,从而修复了漏洞。