【CVE.0x01】CVE-2021-3156 sudo 堆溢出漏洞复现及简要分析

本文最后更新于:2023年4月19日 晚上

食我三明治啦!.jpg

0x00.一切开始之前

CVE-2021-3156 大概是今年年初的时候爆出来的基于堆溢出的一个洞,该漏洞存在于 Linux 安全工具 sudo 当中,据悉其影响十分广泛,甚至能够在 Ubuntu20.04 LTS 上完成利用

这个漏洞主要出现在 sudoedit 对于传入参数的解析方式中的逻辑错误,由于 sudo 为 SUID root 程序,攻击者无需知道用户密码便可以通过该漏洞直接提权到 root

当时笔者刚放寒假,本来也想复现一波,但是👴摸了,不知不觉就拖到了今天才想起来这件事情

影响范围

受影响版本

  • sudo 1.8.2 – 1.8.31p2

  • sudo 1.9.0 – 1.9.5p1

对于受到该漏洞影响的版本,在输入 sudoedit -s / 时出现如下提示

image.png

不受影响版本

  • sudo =>1.9.5p2

不受该漏洞影响的版本则会出现如下提示

image.png

0x01.漏洞分析

从最简单的 POC 入手

根据安全通告中称,漏洞点主要出现在 sudoedit 对于传入参数的解析方式中的错误解析导致堆溢出

最简单的能反映该漏洞的 poc 如下:

1
$ sudoedit -s '\' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

简单跑一下,果然炸了,按照报错信息来看的话我们至少溢出到了 unsorted bin 的 size 域,从而在下一次 malloc 时 abort

image.png

源码分析

这个漏洞从逻辑上而言并不算复杂,我们来简单分析一下存在漏洞的代码的逻辑

在 sudoedit 中若是我们设定了参数 -s ,则会通过函数 set_cmnd() 来对从命令行传入的参数进行解析,其中会对以反斜杠 \ 开头的字符进行单独解析,其中比较关键的代码如下:

位于源码目录下的 plugins/sudoers/sudoers.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
28
29
30
31
/*
* Fill in user_cmnd, user_args, user_base and user_stat variables
* and apply any command-specific defaults entries.
*/
static int
set_cmnd(void)
{

...
/* 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_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)) {
/*
* 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';
}

在这里有两个变量 fromto,其中 from 为我们输入的参数在程序中的原地址,主要是从 字符串数组 NewArgv 中取; to 为存放解析后的参数的地址,我们来看一下这段代码大致的一个流程:

  • 获取当前待解析参数 NewArgv[n] 的长度,通过 malloc() 动态分配一个 chunk 来储存解析后的所有参数,以\0 作为分隔
  • 逐字节遍历原待解析参数,若是遇到字符 \ 则会再判断下一个字符是否为空格字符,若否,则 from 指针移向下一个字符,即字符 \ 的下一个字符
  • 将 from 所指向字符拷贝到 to 所指向内存块上,from 与 to 都 ++,此时若 from 所指向字符不为 \0 则循环继续

讲到这里想必大家已经看出漏洞所在了,若是该参数仅有一个反斜杠 \ 字符,from 指针的自增操作会导致循环判断不到该字符串末尾的 \0 字符,接下来会一直向后读取拷贝直到遇到下一个 \0 字符,导致其下一个参数被拷贝两次,从而造成溢出

举个例子,我们来考虑如下传参情况:

args[n] args[n+1]
\ \0 a r t t n b a 3 \0

一开始时 from 指向 args[n][0]:

args[n] args[n+1]
\ \0 a r t t n b a 3 \0
from ↑

循环条件判断,此时 *from\,进入到循环中;if 条件判断,通过,from++

args[n] args[n+1]
\ \0 a r t t n b a 3 \0
from ↑

此时进行拷贝工作,之后 from++,本次循环结束,进入新一轮循环,此时的 from 指向下一个待解析参数开头的第一个字节,不为 \0,该循环还会接着将后面直到 \0 的内容全部拷贝,而接下来的常规流程还会将args[n+1]再拷贝一次

args[n] args[n+1]
\ \0 a r t t n b a 3 \0
from ↑

由于我们用以存储解析后参数的内存块是通过 malloc() 分配的一个 chunk,因此只要我们在 \ 之后传入的下一个参数足够长我们便能完成堆溢出

gdb调试

我们用 gdb 简单调一调可能更加直观一些

1
$ sudo gdb --args sudoedit -s '\' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

在源码目录下载入源码,看着方便些

1
pwndbg> directory ./plugins/sudoers/

将断点下在 set_cmnd() 函数,然后👴直接快进到有洞的地方,从 gdb 上我们可以看到 from 此时指向我们传入的 \

image.png

看一下 to 变量,刚好挨着 unsorted bin

image.png

判断到了 \ ,from++

img.png

然后重新进入循环,此时 from 已经指向下一个参数,于是接下来会直接将下一个参数拷贝一份

img.png

我们不难看到,当真正进入到正常的拷贝下一个参数的流程时,to 已经指向了下一个 chunk 的 prev_size 域,虽然说当一个 chunk 的 PREV_INUSE 位被设置时其 prev_size 域无效,但毫无疑问的是8字节不到的空间并不能够装下即将拷贝的 42 字节的下一个参数

PUURO44BPBVW@JC___V3HAO.png

最终的结果便是溢出

img.png

img.png

多次重写入

由于每一个 \ 字符串都会导致一次越界读,那么我们可以使用多个\字符串组合以完成大于两倍参数总长度的溢出,由此我们便可以通过控制参数的长度在一定范围内的情况下利用空闲 chunk 在堆上可能的特定位置进行足够长度的溢出,而不会过度地破坏当前的堆上下文

考虑如下参数:
image.png

sudoedit 计算出来的需求空间长度为 0x4f,大于 0x48,我们刚好得到一个 0x60 的 chunk

image.png

简单看一下,我们写入了**将近 0x250 **的数据,远远超出 0x4f,可以看出我们完成了多次的重复写入导致溢出长度远大于我们的输入长度

image.png

溢出前堆布局

在进行分配前除了 Top Chunk 以外我们大概有如下空闲 chunk 可用:

(溢出到 top chunk 几乎没有意义,就一次溢出机会难道你还想整个 House of 🍊或者 House of Force?

0x02.漏洞利用

很神奇的是,网上找的 POC 只能在刚刚安装好的 Ubuntu 上运行,放置一会就无效了…推测是 sudo 在后台自动把这个洞修了…?

就笔者个人的感受而言,这个洞虽然刚出的时候沸沸扬扬的(估计是因为 sudo 比较有名?),但其实并不能够做到较为稳定的提权(比如说脏牛一个 exp 通杀所有版本,但这个感觉还得根据不同的小版本的堆布局进行 exp 的微调)

虽然说毫无疑问存在堆溢出漏洞,但是似乎并不容易利用的样子,因为我们仅有一次能够溢出的机会,且我们的输入不能含有 \x00 字符,那么我们说实话并不能够很好地伪造还原当前的堆上下文,无论是分配较小的 chunk 溢出到 unsorted bin 还是直接分配一个大 chunk 往 Top Chunk 溢出都并不容易让我们能够控制程序执行流,但是我们仍要尝试仅通过一次溢出便绕过所有防御并成功控制程序执行流

Qualys 团队大概给出了三种利用的思路:

I.劫持 service_user 结构体

算是比较稳定化的一个利用思路?可以参考 GitHub 上大神给出的 exp

阅读源码我们不难观察到在

1

我们最终的一个调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
main()
sudoers_policy_check()
sudoers_policy_main()
...
set_cmnd() // we will make a heap buffer overflow there
...
sudo_getgrgid()
getgrgid() // root shell there by loading libc file with evil init
getgrgid_r()
__nss_group_lookup2()
__nss_lookup()
__nss_lookup_function()
nss_load_library()

这似乎也是 Qualys 团队在视频中所演示的利用方法,但令笔者所不解的是这种利用手段应当是能够稳定获取 root shell 的,而在 Qualys 团队的视频中却似乎爆破了多次?

image.png

II.劫持 sudo_hook_entry 结构体

大概是在 sudo 中定义了这样的一个结构体,位于源码中的 hooks.c 中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
/* Singly linked hook list. */
struct sudo_hook_entry {
SLIST_ENTRY(sudo_hook_entry) entries;
union {
sudo_hook_fn_t generic_fn;
sudo_hook_fn_setenv_t setenv_fn;
sudo_hook_fn_unsetenv_t unsetenv_fn;
sudo_hook_fn_getenv_t getenv_fn;
sudo_hook_fn_putenv_t putenv_fn;
} u;
void *closure;
};

其中有着一个联合体 u,其存放的是函数指针

hooks.c 中的 register_hook_internal() 函数中通过 calloc 动态申请了一个位于堆上的 sudo_hook_entry 结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Hook registration internals. */
static int
register_hook_internal(struct sudo_hook_list *head,
int (*hook_fn)(), void *closure)
{
struct sudo_hook_entry *hook;
debug_decl(register_hook_internal, SUDO_DEBUG_HOOKS)

if ((hook = calloc(1, sizeof(*hook))) == NULL) {
sudo_debug_printf(SUDO_DEBUG_ERROR|SUDO_DEBUG_LINENO,
"unable to allocate memory");
debug_return_int(-1);
}
hook->u.generic_fn = hook_fn;
hook->closure = closure;
SLIST_INSERT_HEAD(head, hook, entries);

debug_return_int(0);
}

我们不难想到,只要我们能够劫持对应的的函数指针为后门函数地址(例如 one_gadget ?),在后面该 hook 被执行时我们便可得到 root shell

在这里给出来自 Pax0s 师傅的思路:

通过堆溢出,劫持函数指针getenv_fn,在存在aslr的情况下进行部分覆写(低两字节为 0x8a00),然后爆破execv函数的地址,最后通过execv以root来执行我们自己的文件。(比如我们的文件叫:”SYSTEMD_BYPASS_USERDB”,这是正常执行getenv_fn中的第一个参数)

不过这种劫持方法在笔者看来在实际应用中是比较难以完成的一种利用方式

III.劫持 def_timestampdir

比较独特的一个利用思路,可以参考 Github 上大神给出的 exp

sudo有这样一种行为,大致就是会在我们的工作目录下创建一些属于root用户的目录。每个这样的目录下都有且仅有一个文件:Sudo’s timestamp file。 如果我们尝试将def_timestampdir覆盖为一个不存在的目录。然后我们可以与sudo的ts_mkdirs()竞争,创建一个指向任意文件的符号链接。并且尝试打开这个文件,向其中写入一个struct timestamp_entry。我们可以符号链接将其指向/etc/passwd,然后以root打开他,然后实现任意用户的注入从而root

0xFF.Reference

安全客 - CVE-2021-3156调试分析

看雪论坛 - 最近很火的一个cve

GitHub - lockedbyte = CVE-2021-3156

CVE-2021-3156 sudo 提权漏洞复现与分析 | Ama2in9


【CVE.0x01】CVE-2021-3156 sudo 堆溢出漏洞复现及简要分析
https://arttnba3.github.io/2021/04/24/CVE-0X01-CVE-2021-3156/
作者
arttnba3
发布于
2021年4月24日
许可协议