【CVE分析】CVE-2021-3156 sudo权限提升漏洞
Sudo 权限提升漏洞
漏洞详情
https://blog.csdn.net/qq_43332010/article/details/113560664
https://zhuanlan.zhihu.com/p/352124097
https://www.cnblogs.com/yujin2020/p/14377503.html
2021 年 1 月 26 日,Linux 安全工具 sudo 被发现严重的基于堆缓冲区溢出漏洞。利用这一漏洞,攻击者无需知道用户密码,一样可以获得 root 权限,并且是在默认配置下。此漏洞已分配为 CVE-2021-3156,危险等级评分为7分。
当 sudo 通过 -s 或 -i 命令行选项在 shell 模式下运行命令时,它将在命令参数中使用反斜杠转义特殊字符。但使用 -s 或 -i 标志运行 sudoedit 时,实际上并未进行转义,从而可能导致缓冲区溢出。因此只要存在 sudoers 文件(通常是 /etc/sudoers),攻击者就可以使用本地普通用户利用sudo获得系统root权限。研究人员利用该漏洞在多个 Linux 发行版上成功获得了完整的root权限,包括 Ubuntu 20.04(sudo 1.8.31)、Debian 10(sudo 1.8.27)和 Fedora 33(sudo 1.9.2),并且 sudo 支持的其他操作系统和Linux发行版也很容易受到攻击。
漏洞复现
我的复现环境是 ubuntu 18.04.1,sudo 版本是 1.8.21p2
1 | Linux ubuntu 5.0.0-23-generic #24~18.04.1-Ubuntu SMP Mon Jul 29 16:12:28 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux |
POC 代码位于 https://github.com/blasty/CVE-2021-3156。
1 | $ git clone https://github.com/blasty/CVE-2021-3156 |
接下来源码编译 sudo 1.8.21p2,编译时添加符号表信息。
1 | cd sudo-SUDO_1_8_21p2 |
接下来把符号表信息单独导出为 sudo.dbg 。
1 | objcopy --only-keep-debug sudo sudo.dbg |
漏洞分析
环境与版本
sudo 源码于 https://github.com/sudo-project/sudo/tree/SUDO_1_8_21p2 下载,下面的分析也基于此版本。
漏洞分析原文在 https://www.qualys.com/2021/01/26/cve-2021-3156/baron-samedit-heap-based-overflow-sudo.txt
漏洞细节
src/sudo.c
中定义了入口点 main()
函数,其中第 193 行调用 parse_args()
函数来解析命令行参数。
1 | // src/sudo.c in main() line 192-194 |
如果在 shell 模式使用 sudo 运行一条命令:
- 要么使用
-s
参数,能够设置 sudo 的MODE_SHELL
标志位 - 要么使用
-i
参数,设置 sudo 的MODE_SHELL
和MODE_LOGIN_SHELL
标志位。然后在 sudo 的main()
函数的开始,parse_args()
覆盖了命令行参数(566-574 行),然后用反斜线来转义所有的元字符(547-548 行)。
在 parse_args()
函数中使用反斜线来转义特殊字符:
1 | // src/parse_args.c in parse_args() line 544-552 |
然后,在 plugins/sudoers/sudoers.c
的 sudoers_policy_main()
函数中,于 293 行调用了 set_cmnd()
函数:
1 | // plugins/sudoers/sudoers.c in sudoers_policy_main() line 292-295 |
set_cmnd()
函数会将命令行参数处理后,合并到位于堆的缓冲区 user_args
上,并取消对元字符的转义(861-862 行),用于 sudoers 的匹配和日志记录。
1 | // plugins/sudoers/sudoers.c in set_cmnd() line 853-868 |
然而,如果一个命令行参数以反斜线 \
结尾,那么:
- 在 861 行,
from[0]
是反斜线\
,那么from[1]
就是字符串的终结符\x00
,显然不是一个空白符 - 因此,在 862 行,
from
指针 +1,指向了终结符\x00
- 在 863 行,终结符将被拷贝到
to
指针指向的user_args
缓冲区处,然后from
指针自增,因此会指向终结符\x00
后的第一个字符 - 860-864 行的
while()
循环将会继续读取并复制超出了from
边界的字符,并拷贝到user_args
缓冲区。
总之,set_cmnd()
存在一个堆缓冲区溢出漏洞。
漏洞触发条件
首先讨论如何才能触发这个漏洞。plugins/sudoers/sudoers.c
in set_cmnd()
的第 853 行要求:
1 | if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) { |
即必须设置 MODE_SHELL
或者 MODE_LOGIN_SHELL
才能向下运行。而在 src/parse_args.c
的 parse_args()
函数中,第 528 行要求了 MODE_SHELL
被设置时,将会对所有非字母和数字进行转义(使用反斜线),因此也会对反斜线 \
进行转义从而得到 \\
。
1 | // src/parse_args.c in parse_args() line 525-536 |
但实际上,set_cmnd()
函数中漏洞代码的触发条件,和 parse_args()
中参数被转义的条件有细微的不同:
1 | // set_cmnd() 触发条件 |
1 | // parse_args() 转义条件 |
我们的目标是,触发 set_cmnd()
的漏洞代码,但不触发 parse_args()
的转义条件。因此我们需要设置 MODE_SHELL
,并设置 MODE_EDIT
或 MODE_CHECK
其中一项,并且不能设置 MODE_RUN
。所以现在的问题是,是否有这样的一种设置方式呢?
答案是否定的。如果我们设置 MODE_EDIT
(使用 -e
参数,337 行)或者设置 MODE_CHECK
(使用 -l
参数,389 行和 476 行)时,valid_flags
位将会被重新设置(342 行和 397 行),显然 MODE_SHELL
标志位并不存在于 valid_flags
中。因此在 489-490 行的检查无法通过,程序会退出。因此 MODE_EDIT
或 MODE_CHECK
无法与 MODE_SHELL
标志位共存。
1 | // src/parse_args.c in parse_args() line 337-342 |
但是我们发现了一个漏洞:如果我们执行 sudoedit
而不是 sudo
命令,那么 parse_args()
函数将会自动设置好 MODE_EDIT
标志位,且不会重设 valid_flags
( 261 行,如下所示)。
1 | // src/parse_args.c in parse_args() line 257-263 |
因此,当我们执行 sudoedit -s
时,我们就能同时设置 MODE_EDIT
和 MODE_SHELL
,且不设置 MODE_RUN
。此时,我们就能跳过字符的转义,从而触发位于 set_cmnd()
函数的漏洞代码,用以 \
结尾的命令行参数溢出缓冲区 user_args
。
从攻击者的角度总结一下,这个缓冲区溢出漏洞非常理想,主要有以下几个原因:
攻击者可以控制溢出
user_args
缓冲区的大小(在 847-848 行,可以看到,user_args
的大小就是所有参数所占字节数 + 1)1
2
3
4// plugins/sudoers/sudoers.c in set_cmnd() line 847-849
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
if (size == 0 || (user_args = malloc(size)) == NULL) {攻击者可以独立控制溢出的大小和溢出的内容。(our last command-line argument is conveniently followed by our first environment variables, which are not included in the size calculation at lines 847-848)
由于在 Linux 下,命令行参数后面的内存空间保存的是环境变量,因此可以构造包含
{'\', '\x00'}
这样的环境变量,实现内存任意溢出。
- 攻击者甚至可以向缓冲区中写入空字符
\x00
(每个以单个反斜线作为结尾的命令行参数或者环境变量都会向user_args
中写入一个空字符,在set_cmnd()
861-862 行)
漏洞利用
提权原理
sudo 程序具备 SUID 权限,且 sudo 的所有者是 root,因此普通用户执行 sudo 程序时,是以 root 身份执行的。sudo 程序正常执行时,会在执行用户命令之前鉴定执行者的权限。CVE-2021-3156 漏洞发生的函数在 set_cnmd()
,处于鉴定权限之前,所以会造成任意用户提权的风险。
利用思路
最初,作者希望使用 halfdog 团队的技术,从而将堆溢出转化为一个格式化字符串利用。因此作者写了一个爆破(没看懂,挖坑),虽然没有成功,但是意外地发现了有趣的 crash。
1. struct sudo_hook_entry
覆盖
1 | Program received signal SIGSEGV, Segmentation fault. |
难以置信, sudo 的一个函数 process_hooks_getenv()
崩溃了,因为我们直接覆盖了一个函数指针 getenv_fn
(101 行),它是一个位于堆的结构体 struct sudo_hook_entry
的一个成员。
1 | // src/hooks.c in process_hooks_getenv() line 92-104 |
可以将 getenv_fn
指针替换为 execve()
,并控制 name
字段即可。然而这样需要爆破 ASLR。
2. struct service_user
覆盖
1 | Program received signal SIGSEGV, Segmentation fault. |
glibc 的库函数 nss_load_library()
崩溃了,原因是覆盖了 library
指针,它是位于堆的结构体 struct service_user
的一个成员。 nss_load_library()
函数的部分定义如下所示。可以看到,当控制了 ni
指针,就可以调用 __libc_dlopen()
打开一个指定的动态链接库。
1 | ------------------------------------------------------------------------ |
调试分析——溢出分析
显然,第二个方法比较靠谱。我决定调试一下。
1 | sudo gdb ./sudoedit |
可以看到,to
地址处被设置为 0x787878787878(对应字符 ‘x’),最后一个反斜线被处理为 \x00
,后续跟随下一个命令行参数 01234567(对应 0x30)。这里足以说明后续的 01234567 可以溢出到下一个 chunk 。
调试分析——利用分析
对 /usr/bin/sudo
进行调试分析。
先关闭 ASLR:
1 | echo 0 > /proc/sys/kernel/randomize_va_space |
用到的指令如下:
set breakpoint pending on
:允许在模块还未加载时就设置断点,当模块加载后自动添加断点catch exec
:当新进程启动时断住,这样就可以通过gdb sudo-hax-me-a-sandwich
来启动进程了。
/usr/bin/sudo
没有符号信息。但是 exp 向堆中写入了字符串 "X/P0P_SH3LLZ_"
作为打开动态库的文件名。根据前面的信息,是在调用了函数 nss_load_library
函数时加载了动态链接库。通过 b nss_load_library
配合 set breakpoint pending on
,在 nss_load_library()
函数调用时中断。通过调试发现,nss_load_library()
调用了 __libc_dlopen_mode()
函数:
如果后续有时间,再研究把这个 exp 扩展到其他版本的系统上。