Heap-Based Buffer Overflow in Sudo

First Post:

Last Update:

Word Count:
2.3k

Read Time:
14 min

CVE-2021-3156: Heap-Based Buffer Overflow in Sudo

Intro

This CVE almost impact on all distributions of linux, every common user can use this vulnerability escaped permission as root. Disclosured at 2021-01-13. We have a ctf match (hws) at 2021-02, there is a pwn challenge can use this vulnerability to escape permission as root, but I don’t use this CVE to realize it. Just use CVE-2019-14287 easily to get root. At that time, I knew this CVE, but I didn’t want explore the background technologes. Now, this paper will analyse this vulnerability and let’s to exploit it.

What versions are vulnerable?

The following versions of sudo are vulnerable:

  • All legacy versions from 1.8.2 to 1.8.31p2
  • All stable versions from 1.9.0 to 1.9.5p1

sudo official website: https://www.sudo.ws/

Check if you are affected

Login as non-root user

Method 1

Run a command as follow to check.

1
sudoedit -s 'aaaaaaaaaaaaaaaaa\'

If you receive a usage or error message, sudo is not vulnerable. If the result is a Segmentation fault, sudo is vulnerable. As you can see below:

1
2
malloc(): memory corruption
Aborted (core dumped)

Method 2

Coming from official judge method (Not recommend!)

Run command sudoedit -s /

If the system is vulnerable, it will response with an error that starts with “sudoedit”

If the system is not vulnerable, it will response with an error that starts with “usage”

1
2
3
[i0gan@arch ~]$ sudoedit -s / 
usage: sudoedit [-AknS] [-C num] [-D directory] [-g group] [-h host] [-p prompt] [-R directory] [-T timeout] [-u user]
file ...

But I try it, this dosen’t work, I cannot use this method to judge if my system is vulnerable. becuase I test on my latest sudo, this version has been patched, I use this method to test,the result is correct. when I test on my vulnerable system. it dosen’t show “sudoedit”, instead let me input password. So I recommend tester to use method 1 to test your linux system.

Prepare ENV

My sudo version and operation system version information as follows.

1
2
3
4
5
6
7
8
sudo --version
Sudo version 1.8.21p2
Sudoers policy plugin version 1.8.21p2
Sudoers file grammar version 46
Sudoers I/O plugin version 1.8.21p2

cat /proc/version
Linux version 5.3.0-28-generic (buildd@lcy01-amd64-009) (gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1)) #30~18.04.1-Ubuntu SMP Fri Jan 17 06:14:09 UTC 2020

My vulnerable system is ubuntu 18.04.1 and operation system is ubuntu~18.04.1

I test version is 1.8.21p2. We should download source code to analyse this vulnerability. You can download source code of 1.8.21p2 version here.

Download

If we downloaded source code, we can open NEWS file learn historic vulnerabilities : ). There are so many bug we can learn. Nice!!!

Now we must to dynamic debugging sudo program, some tools we will used.

gdb + pwndbg plugin — Debugging sudo program

gcc — Compile our C exploit script to ELF binary

If your prepared tools above, now starting our journey. Last set your linux system sudo program version as 1.8.21p2.

install step

1
2
3
./configure
make
sudo make install

Hole Technical Analysis

This vulnerability is discovered by fuzz, So we have to explore how is it crashed. sudo allows users to run programs with the security previleges of another user. sudoedit is a built-in command of sudo that allows users to securely edit files. In this new fulnerability, Qualys researchers discovered that when running sudoedit whth flags -s or -i, the command will not result exit with an error, and the sudoers policy plugin will not remove the escape characters, resulting instead in reading beyond the last of a string. if it ends with an un-escaped backslash character. May allow attackers to exploit this vulnerability in order to run arbitrary code execution with root privilege without authentication.

Test what arguments in running programs

1
2
3
4
5
#include<stdio.h>
int main(int argc, char* argv[]) {
printf("%s\n", argv[1]);
return 0;
}

outputs:

1
2
./a.out 'AAAAAAAAA\'
AAAAAAAAA\
1
2
3
4
#! /bin/bash
args=`echo -e 'AAAAAAAAA\'`
echo "sudoedit -s $args"
sudoedit -s $args

outputs:

1
2
3
4
./t
sudoedit -s AAAAAAAAA\
malloc(): memory corruption
./t: line 4: 8832 Aborted (core dumped) sudoedit -s $args

So arguments is AAAAAAAAA\

This version, if sudo is executed to run a command in shell mode . through the -s option

or through -i option sets sudo’s MODE_SHELL and MODE_LOGIN_SHELL flags.

parse_args function used for parse program arguments

src/sudo.c: 193 in main() function

1
2
3
/* 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);

if we set flags as MODE_SHELL, we can enter shell mode, that can rewrite argv.

By concatenating all command-line arguments and by escaping all meta-characters with backslashes

src/parse_args.c : 528 in parse_args() 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
/*  
* For shell mode we need to rewrite argv
*/
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;
// command size
size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) +
strlen(argv[argc - 1]) + 1;

cmnd = dst = reallocarray(NULL, cmnd_size, 2); // malloc mem to store command
if (cmnd == NULL)
sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
if (!gc_add(GC_PTR, cmnd))
exit(1);
// By concatenating all command-line arguments
for (av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) { // get one commands
/* quote potential meta characters */
if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\'; // Escaping all meta-characters with backslashes, that can make a easy heap overflow
*dst++ = *src;
}
*dst++ = ' ';
}
if (cmnd != dst)
dst--; /* replace last space with a NUL */
*dst = '\0';

ac += 2; /* -c cmnd */
}

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

Now we debugging this sudoedit program, copy this file to current directory from /usr/bin/sudoedit, use IDA or Cutter to analyse it.

I use IDA 7.5 to decompile it, we should find parse_args function. because this program haven’t parse_args function symbol, I have to find this parse_args function by self according main argc arguments.

1
2
3
4
5
6
7
8
9
10
if ( (unsigned __int8)sudo_conf_disable_coredump_v1() )
sub_14E00(0LL);
dword_2250C0 = sub_11760((int)v4, a2, (int *)&v171, &v172, &v177, &v173);
sudo_debug_printf2_v1(
"main",
"../../src/sudo.c",
194LL,
456LL,
"sudo_mode %d",
(unsigned int)dword_2250C0);

Because v2 is main function argc argument, so I easily found sub_11760, it is parse_args function.

Found above source’s pseudo code as follow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LABEL_100:
if ( (v122 & 1) == 0 || (v123 & 0x20000) == 0 ) // flags
goto LABEL_128;
if ( v38 )
{
v41 = v36[v38 - 1];
v42 = strlen(v41);
v43 = reallocarray(0LL, v42 + v41 - *v36 + 1, 2LL);
v44 = (_BYTE *)v43;
if ( !v43 )
{
sudo_warn_gettext_v1(0LL, "unable to allocate memory");
v106 = (const char *)sudo_warn_gettext_v1(0LL, "%s: %s", v104, v105);
sudo_debug_printf2_v1("parse_args", "../../src/parse_args.c", 540LL, 98LL, v106);
goto LABEL_153;
}
...

We just to know address of this part for debugging.

show asm

1
2
3
4
5
6
7
8
9
10
11
12
13
.text:0000000000011F4A                 test    [rsp+0E8h+var_D8], 20000h
.text:0000000000011F52 jz loc_121DB
.text:0000000000011F58 test ebx, ebx
.text:0000000000011F5A jz loc_1217B
.text:0000000000011F60 movsxd rbx, ebx
.text:0000000000011F63 mov rbx, [r14+rbx*8-8]
.text:0000000000011F68 mov rdi, rbx ; s
.text:0000000000011F6B call _strlen
.text:0000000000011F70 sub rbx, [r14]
.text:0000000000011F73 xor edi, edi
.text:0000000000011F75 mov edx, 2
.text:0000000000011F7A lea rsi, [rax+rbx+1]
.text:0000000000011F7F call _reallocarray

The parse_args function offset is 0x11760

0x11760-> 0x118B0 -> 0x5674

Now we can set breakpoint at ELF_BASE_ADDRESS + 0x11760 ( parse_args ) to debugging sudoedit program.

When I debugging, I don’t know why this program haven’t enter above code.

Continue to read blog @_@ …

The author say: In sudoders_policy_main(), set_cmnd() concatenates the command-line arguements into a heap-based buffer. user_args and unescapes the meta-characters (for sudoers matching and logging purposes). These functions are at plugins/sudoers/sudoers.c.

plugins/sudoers/sudoers.c in set_cmnd() 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
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
/*
* Fill in user_cmnd, user_args, user_base and user_stat variables
* and apply any command-specific defaults entries.
*/
static int
set_cmnd(void) // vulnerable function
{
int ret = FOUND;
char *path = user_path;
debug_decl(set_cmnd, SUDOERS_DEBUG_PLUGIN)

/* Allocate user_stat for find_path() and match functions. */
user_stat = calloc(1, sizeof(struct stat));
if (user_stat == NULL) {
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
debug_return_int(NOT_FOUND_ERROR);
}

/* Default value for cmnd, overridden below. */
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 (def_secure_path && !user_is_exempt())
path = def_secure_path;
if (!set_perms(PERM_RUNAS))
debug_return_int(-1);
ret = find_path(NewArgv[0], &user_cmnd, user_stat, path,
def_ignore_dot, NULL);
if (!restore_perms())
debug_return_int(-1);
if (ret == NOT_FOUND) {
/* Failed as root, try as invoking user. */
if (!set_perms(PERM_USER))
debug_return_int(-1);
ret = find_path(NewArgv[0], &user_cmnd, user_stat, path,
def_ignore_dot, NULL);
if (!restore_perms())
debug_return_int(-1);
}
if (ret == NOT_FOUND_ERROR) {
if (errno == ENAMETOOLONG)
audit_failure(NewArgc, NewArgv, N_("command too long"));
log_warning(0, "%s", NewArgv[0]);
debug_return_int(ret);
}
}

/* 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)) {
/*
* 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) { // vulnerability: if command-line == '\'
// form [0] == '\\' && form[1] == '\x00'
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++; // copy data to user_args, form pointer add again, out of range
}
*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';
}
}
}

if ((user_base = strrchr(user_cmnd, '/')) != NULL)
user_base++;
else
user_base = user_cmnd;

if (!update_defaults(SETDEF_CMND, false)) {
log_warningx(SLOG_SEND_MAIL|SLOG_NO_STDERR,
N_("problem with defaults entries"));
}

debug_return_int(ret);
}

If a command-line argument ends with a signle backslash character, then form[0] is a backslash character, and form[1] is ‘\x00’ (is not space character)

form is incremented and points to the null terminator (‘\x00’);

The null terminator is copied to the user_args buffer, the form is incremented again and points to the first character after the null terminator. (out of arguments’ bounds). The whill loop reads and copies out of bounds characters to the user_args buffer.

So, In the words, set_cmnd is a vulnerable function. There is a heap-based buffer overflow vulnerability.

That copy out of bounds data to the user_args buffer, and not sure how much copied it is. when we exploit, we can use ‘\00’ to terminate coping.

Now how we trigger this vulnerability?

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

we should set sudo_mode as MODE_SHELL and MODE_LOGIN_SHELL

It is necessary condition for reaching the vulnerable code. parse_args function can do that, but this function already escaped all meta-characters (i.e., it escaped every single backslash with a second backslash). so including more backslashes, that cannot trigger vuln.

Found a loophole:

if we execute sudo as sudoedit instead of sudo, then parse_args() automatically sets sudo_mode as MODE_EDIT, but does not reset valid_flags, and the valid_flags include MODE_SHELL by default.

If we execute sudoedit -s, then we set both MODE_EDIT and MODE_SHELL (but not MODE_RUN), we avoid the escape code, reach the vulnerable code, and overflow the heap-based buffer user_args through a command-line argument that ends with a single backslash character.

Testing heap overflow, we use gdb to test it.

How to exploit?

gdb.sh

1
2
3
#! /bin/bash
sudo gdb /usr/bin/sudoedit \
-ex "set args -s 'AAAAAAAAAAAABBBBBBB\'"

Run gdb.sh

1
./gdb.sh

Input run, heap in gdb program

1
2
3
4
5
6
7
8
9
10
11
12
0x55791f9b40a0 FASTBIN {
mchunk_prev_size = 0,
mchunk_size = 33,
fd = 0x4141414141414141,
bk = 0x4242424241414141,
fd_nextsize = 0x435f534c00424242,
bk_nextsize = 0x73723d53524f4c4f
}
Exception occurred: malloc_chunk: Cannot access memory at address 0x55791f900000 (<class 'gdb.MemoryError'>)
For more info invoke `set exception-verbose on` and rerun the command
or debug it by yourself with `set exception-debugger on`
'heap': Prints out chunks starting from the address specified by `addr`.

Show memory.

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> x /40gx 0x55791f9b40a0
0x55791f9b40a0: 0x0000000000000000 0x0000000000000021
0x55791f9b40b0: 0x4141414141414141 0x4242424241414141
0x55791f9b40c0: 0x435f534c00424242 0x73723d53524f4c4f
0x55791f9b40d0: 0x31303d69643a303d 0x303d6e6c3a34333b
0x55791f9b40e0: 0x3d686d3a36333b31 0x30343d69703a3030
0x55791f9b40f0: 0x303d6f733a33333b 0x3d6f643a35333b31
0x55791f9b4100: 0x64623a35333b3130 0x303b33333b30343d
0x55791f9b4110: 0x3b30343d64633a31 0x726f3a31303b3333
0x55791f9b4120: 0x303b31333b30343d 0x3a30303d696d3a31
0x55791f9b4130: 0x31343b37333d7573 0x343b30333d67733a
0x55791f9b4140: 0x3b30333d61633a33 0x30333d77743a3134

It is obviously, this is a heap overflow at top chunk, has overlaped the top chunk.

How can we get shell?

Updating…

ref: https://blog.qualys.com/vulnerabilities-research/2021/01/26/cve-2021-3156-heap-based-buffer-overflow-in-sudo-baron-samedit

ref: https://github.com/blasty/CVE-2021-3156

ref: https://github.com/reverse-ex/CVE-2021-3156/blob/main/info

打赏点小钱
支付宝 | Alipay
微信 | WeChat