0%

CVE-2021-3156sudo提权漏洞分析

从这个漏洞入手,一是为了更加熟悉linux,扩宽自己linux的知识面,二是想把学习的pwn知识实践操作一下。

摘要

该漏洞成因主要是由于sudo在处理单个反斜杠结尾的命令,会出现逻辑上的错误,从而导致堆溢出。当sudo通过-s或-i命令行选项在shell模式下运行命令时,它将在命令参数中使用反斜杠转义特殊字符。但使用-s或 -i标志运行sudoedit时,实际上并未进行转义,从而可能导致缓冲区溢出。(后面分析原源代码会提到),只要存在sudoers文件(通常是 /etc/sudoers),攻击者就可以使用本地普通用户利用sudo获得系统root权限。

漏洞检测

在非root权限下,运行sudoedit -s /,如果出现下图这样,说明系统受到该漏洞影响。

image-20220907220817214

代码逻辑分析

命令行模式下运行sudo,加上-s选项会设置MODE_SHELL flag;加上-i选项会设置MODE_SHELL flag 和 MODE_LOGIN_SHELL flag。sudo的main() 函数开头调用了parse_args()parse_args() 会连接所有命令行参数。并给元字符加反斜杠来重写 argv

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
32
//parse_args.c   parse_args()
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 */
}

suduers.c中的sudoers_policy_main()中调用了 set_cmnd()函数,在 set_cmnd()函数中,首先根据参数使用 strlen()函数计算了参数的 size,再调用 malloc()函数分配了 size大小的堆空间 user_args 。随后判断是否开启了 MODE_SHELL,如果开启了将会 连接命令行参数并存入堆空间 user_args

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// sudoers.c set_cmnd()    
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
if (size == 0 || (user_args = malloc(size)) == NULL) {
...
}
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
...
for (to = user_args, av = NewArgv + 1; (from = *av); av++) { // 把命令行参数放入from里面
while (*from) {
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++; // 将输入的命令行参数拷贝到堆空间 user_args
}
*to++ = ' ';
}
...
}
...
}

小总结一下,首先大概就是有两个流程,根据上面的分析,我们需要触发漏洞需要启用MODE_SHELL,但是如果启用了该标志,在第一个逻辑parge_args()中就会对所有参数进行转义,触发漏洞的 \,将会被转义为 \\,这样就无法触发漏洞了。

1
2
3
4
5
6
// parse_args() 换码代码
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
// set_cmnd() 漏洞代码
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

所以要触发漏洞就需要设置 MODE_SHELLMODE_EDIT/MODE_CHECK ,但不设置 MODE_RUN,但是在parge_args()代码中,设置了MODE_EDIT/MODE_CHECKparse_args()就会从valid_flags移除MODE_SHELL,如果此时还设置了MODE_SHELL就会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
                 case 'e': 
...
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
...
case 'l':
...
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
break;
...
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
...
if ((flags & valid_flags) != flags)
usage(1);

所以选择使用 sudoedit。因为 sudoedit还是会被软链接到使用 sudo命令,但是在 parse_args()函数中会自动设置 MODE_EDIT和不会重置 valid_flags,则 MODE_SHELL仍然在 valid_flags中 ,而且不会设置 MODE_RUN,这样就能跳过 parse_args()函数中转义参数的部分,同时满足 set_cmnd()函数中漏洞触发的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//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";
}
...
case 's':
sudo_settings[ARG_USER_SHELL].value = "true";
SET(flags, MODE_SHELL);
break;

所以,只要执行sudoedit -s \,就能同时设置MODE_EDITMODE_SHELL,但不设置MODE_RUN。跳过parse_args()中的换码代码,直接执行漏洞代码set_cmnd(),溢出user_args堆缓冲区。

漏洞成因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 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) {

if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

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';
}

还是上面的set_cmnd函数,如果输入指令是

1
sudoedit -s '\' 112233445566

那么进入该函数时,NewArgv的结构如下

1
2
3
NewArgv[0]: sudoedit
NewArgv[1]: \
NewArgv[2]: 112233445566

首先会计算 NewArgv [1]、[2] 两个参数的长度 2 + 13 = 15 .

因此user_args分配的内存大小为 15 字节

然后会把 NewArgv[1]、[2] 的数据拷贝到user_args里面。

拷贝过程中如果 from[0]\,且 from[1] 不是空格就会from++。所以在处理NewArgv[1]时,from[0] 就是 \from[1]\x00 ,会通过这个判断让 from++ ,然后后面会再次from++.

之后from就指向了NewArgv[1]字符串\x00后面一个字符的位置,后面紧跟着的是NewArgv[2],所以此时 from 执行的就是 NewArgv[2] 的开头

从而会再次进入while循环把NewArgv[2]拷贝到user_args

然后处理NewArgv[2]再次把NewArgv[2]拷贝到user_args

因此最终结果就是 NewArgv[2] 被拷贝了两次,实际的写入数据长度为26字节

漏洞利用

  1. user_args堆缓冲区的size可控(根据前面set_cmnd()就是命令行参数合并后的长度);
  2. 能分别控制size和溢出的内容(第一段命令行参数后紧跟第二段命令行参数,第二段命令行参数不包含在size中);
  3. 可以写null字节到user_args(每个以单反斜杠结尾的命令行参数或环境变量,都能往user_args写1个null字节)

最后的提权是通过堆溢出覆盖nss_load_library函数加载so的时候需要用到的结构体service_user,覆盖此结构体中的so名字符串,这样就可以让程序加载我们指定的so文件,从而完成任意代码执行。

目标

溢出后覆盖service_user结构。该结构出现在libc的nss_load_library()函数中,用于加载动态链接库。如果能覆盖service_user->name,就能指定加载我们伪造的库,利用root权限运行非root权限的库。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
// 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, ni->name);// (1)设置 ni->library

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

根据上面的代码,我们知道需要将 ni->library覆盖为 null,将 ni->name覆盖我们自己伪造的库名字,且伪造的库文件名必须是 libnss_xxx.so

实战分析

借助gdb工具对其进行调试,首先输入指令gdb sudoedit,然后设置参数set args -s '\' AAAAAAAAAAAAAAAA,在代码switch部分下断点。

image-20230329200144522

详细步入下面policy_check函数查看

image-20230329200239086

紧跟着会进入到sudoers_policy_check------>sudoers_policy_main

在该函数中会将argcargv传递给NewArgcNewArgv

image-20230329202430790

image-20230329202955337

然后进入set_cmnd()函数,也就是前面说的溢出点,首先在计算参数长度这里,输出size为19(反斜杠长度1+1+16*A+1)

image-20230329202511177

输入heap查看堆内存,可以看到原本size为19,所以申请了一个0x20的chunk(0x556d7f991e70),但是拷贝了0x22个字节进去,所以把下一个chunk的size给覆盖掉了,所以才导致后一个堆块大小为0x4141414141414141

image-20230329203631646

(tips:由于我用的是docker镜像,所以调试运行会出现错误warning: Error disabling address space randomization: Operation not permitted。这是由于linux 内核为了安全起见,采用了Seccomp(secure computing)的沙箱机制来保证系统不被破坏。它能使一个进程进入到一种“安全”运行模式,该模式下的进程只能调用4种系统调用,即read(), write(), exit()和sigreturn(),否则进程便会被终止。

解决:docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined 你的容器才能利用GDB调试)