Baron Samedit

CVE-2021-3156 Caught my eye this week! It's a heap-based buffer overflow affecting versions prior to 1.9.5p2, allowing local privilege escalation to root via sudoedit -s and a command that ends with a single backslash character. I want to take the time to understand this one in depth.

Comprehension

As a starting point, I looked up the CVE Disclosure. And then the currently most linked to article about the issue Qualys Blog. I'm essentially just re-writing that last blog post there so I can put it in my own words and fully understand.

Source

If sudo is executed to run a command in "shell" mode, or through the -i option,

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

at the beginning of Sudo’s main(), parse_args() rewrites argv...

    av[0] = (char *)user_details.shell; /* plugin may override shell */ 
    if (cmnd != NULL) { 
        av[1] = "-c"; 
        av[2] = cmnd; 
    } 
    av[ac] = NULL; 
    argv = av; 
    argc = ac; 
} 

...by concatenating all command-line arguments, escaping all meta-characters with backslashes.

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++ = ' '; 
} 

Later, in sudoers_policy_main(), set_cmnd() concatenates the command-line arguments into a heap-based buffer “user_args” and unescapes the meta-characters "for sudoers matching and logging purposes."

for (to = user_args, av = NewArgv + 1; (from = *av); av++) { 
    while (*from) { 
        if (from[0] == '\\' && !isspace((unsigned char)from[1])) 
            from++; 
        *to++ = *from++; 
    } 
    *to++ = ' '; 
}

Here's the buffer allocation logic:

for (size = 0, av = NewArgv + 1; *av; av++) 
    size += strlen(*av) + 1; 
if (size == 0 || (user_args = malloc(size)) == NULL) { 

The Vulnerability

Let's take a closer look at the line that un-escapes the arguments.
If one of the command-line arguments end's with a backslash, then from[0] is the backslash character and from[1] is the argument's null terminator:
if (from[0] == '\\' && !isspace((unsigned char)from[1])) Then, from is incremented and now points to the null character:
from++; On the next line, the null terminator is copied to the user_args buffer, and from is incremented again and now points to the first character after the null terminator (from pointer is out of bounds now):
*to++ = *from++; Finally, the while loop reads and copies out-of-bounds characters to the user_args buffer, completing the heap-based overflow vulnerability.

The Exploit Vector

In theory this vulnerable code is not a concern. If MODE_SHELL or MODE_LOGIN_SHELL is set (required for reaching the vulnerable piece of code), parse_args() executes and all command-line arguments should have already been escaped with a single bashslash character:
dst++ = '\\'; In reality, there is a slightly different set of conditions between parse_args (the shell escape code) and set_cmnd (the vulnerable code):

/* set_cmnd() */
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { 
... 
    if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
/* Versus parse_args() */
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {

So, we want to set MODE_SHELL and either MODE_EDIT or MODE_CHECK, but not MODE_RUN. This isn't normally possible. If we set MODE_EDIT or MODE_CHECK, then parse_args removes MODE_SHELL from "valid_flags" and exits with an error. However, Qualys found a loophole! If you execute sudo as sudoedit instead of sudo, then parse_args automatically sets MODE_EDIT, but does not reset "valid_flags" which includes "MODE_SHELL" by default:

127 #define DEFAULT_VALID_FLAGS     (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL) 
... 
     int valid_flags = DEFAULT_VALID_FLAGS; 
... 
     proglen = strlen(progname); 
     if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) { 
         progname = "sudoedit"; 
         mode = MODE_EDIT; 
         sudo_settings[ARG_SUDOEDIT].value = "true"; 
     }

Therefore, executing sudoedit -s allows us to escape the escape code and reach the vulnerable code, overflowing the heap-based buffer "user_args":

$ sudoedit -s '\' `perl -e 'print "A" x 65536'` 
malloc(): corrupted top size 
Aborted (core dumped)