前言
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 \
--privileged -h sudodebug \
--name sudodebug \
--cap-add=SYS_PTRACE \
-v .:/data \
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) |
之后又添加了溢出的检测,从而修复了漏洞。