Broken input validation in SUDO – From sandwiches to death spirits

Developers
C
C++
Linux
Case study

A newly discovered critical vulnerability in sudo allowed anyone to get root. The cause? Poor input validation.

On 26th of January 2021, security researchers from Qualys released an advisory about a critical vulnerability (also in CVE as CVE-2021-3156) allowing arbitrary code execution and privilege escalation on most Linux distributions including Ubuntu, Debian and Fedora. Basically, an arbitrary local user could invoke sudo with some malicious parameters to gain full root privileges due to an input validation problem. As expected in the post-Heartbleed era, the vulnerability also needed a cool name: ‘Baron Samedit’. But what made this vulnerability possible?

Let’s start with the target application itself, sudo. It evolved from the basic ‘substitute user’ (su) Unix command, and became one of the core system tools in Linux (and *nix in general). On a basic level, it executes a command line as another user, typically the root superuser. To the general public, sudo is best known from a certain xkcd comic (incidentally, sudo’s logo is a smiling/laughing sandwich):

sudo, input validation, sandwich, baron samedit

The sudo tool is rather old – it was originally created in 1980, but its developers kept adding new features over time, such as an entire plugin framework as well as several other tools to manage sudo policies (sudoedit, visudo, sudoreplay). Through the years, an important feature of sudo over su was its support for automation – the user doesn’t necessarily have to authenticate to invoke a command via sudo as long as they have the right permission in the /etc/sudoers file.

As for the name: Baron Samedi is a loa (spirit) from the Haitian Vodou pantheon – specifically the loa of the dead – and the slight ‘Samedit’ variation of his name makes sense considering that sudo was exploited through the sudoedit tool.

When input validation goes bad

The vulnerability was introduced in 2011, when sudo 1.8.2 added some input validation (technically sanitization) to its command line arguments. According to the official changelog, the change was “Spaces in command line arguments for sudo -s and sudo -i are now escaped with a backslash when checking the security policy.” This indeed sounds like input validation to protect against, say, command injection – good! So, let’s take a look at what the code actually did through a code snippet taken from the Qualys advisory.

The following code fragment is from set_cmnd() within the sudoers plugin (plugins/sudoers/sudoers.c).

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

Let’s look at the heart of this fragment: the for loop where we copy elements from a string we got by concatenating all the arguments (av) into the user_args buffer (allocated on the heap). We are going through the command line arguments one by one and unescaping all characters in them: that is, if we find a backslash character followed by a non-space character, we skip over the backslash by incrementing from, then put the escaped character into the user_args buffer (which itself is on the heap).

But what happens if the last character in a command line argument is a backslash character? In that case, the character after them is going to be the null terminator (or the 0x00 character). We’ll ‘skip over’ the backslash, copy the next character (null terminator) into the user_args buffer, then increment the from pointer again, so it now points to whatever is after the command line argument in memory. At this point, we have reached the end of the buffer, but we’ll keep reading more bytes and copying them into user_args until we actually reach another 0x00 character on the input side after that – a classic example of a heap-based buffer overflow enabled by an input validation weakness!

Hacking with slashes

But so far, we were only looking at the code from a theoretical standpoint. How can this be exploited in practice? The obvious answer is “just send in a command line that ends with a backslash!”. But it’s not as easy as that. There’s another piece of the code we haven’t looked at yet: the parse_args() function in parse_args.c that is invoked by sudo’s main() function. This function is responsible for escaping the characters in the first place. The relevant code fragment is as follows (from Qualys’ analysis, as before):

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

Now we can see that if both MODE_SHELL and MODE_RUN are set, meta characters such as the backslash will be escaped, and so any backslash character in the string will be prepended with another backslash. So far so good, there cannot be a single backslash at the end of the string… right? That’s how the code should work, but there is a discrepancy between these two code fragments. Note that if MODE_RUN is not set but MODE_EDIT is, escaping will not occur, but unescaping will, and this way the vulnerability can be exploited!

Incidentally, if we invoke sudoedit instead of sudo, it will set MODE_EDIT alongside the DEFAULT_VALID_FLAGS, as can be seen from this code snippet (also from parse_args()):

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

There is one more piece of the puzzle: sudoedit (which is normally a tool for editing security policy files) was not restricted in its command line arguments. In particular, it could be invoked with the -s argument – in the context of sudo, it passed the arguments to the $SHELL (likely /bin/bash), but this same argument made no sense for sudoedit. Still, it was allowed – or it may better to say that, due to lack of proper input validation, it was not disallowed.

This means that if the attacker runs env -i ‘<envvar>=<envvalue>’ sudoedit -s ‘\’ it will set MODE_EDIT and MODE_SHELL, but not MODE_RUN – thus backslashes will not be escaped, but set_cmnd() will still try to unescape them! Since sudoedit’s command line argument list ends with a backslash, it will trigger the bug, and overflow the buffer with the contents of the environment variables in the shell (these are passed as a parameter to the shell instead of sudoedit, and thus not processed by the escaping code) until it reaches a 0x00 character. At this point, the attacker is in full control of the heap overflow – it is even possible to write 0x00 characters into memory by exploiting the same backslash bug once again (e.g. if an <envvar> ends with a backslash, it is transformed by the unescaping code into 0x00)

In general, heap overflow vulnerabilities are notoriously tricky to exploit. The attacker needs to overwrite the right parts of a heap header structure with the right values at the right time – and often they need to bring the heap into the correct state beforehand, too.

The security researchers used a brute-forcer to find possible paths forward, and – long story short – managed to find three (!) different viable exploitation techniques. Going into detail about them would be quite lengthy, so let’s just look at a high-level summary:

  • They could partially overwrite a sudo_hook_entry struct and replace a function pointer to point at execve() instead. At this point, they could get the program to call the function pointer and thus run execve() on any binary they wanted – and since it was done in the context of the sudo process, the binary was executed as root. They could also defeat address space layout randomization (ASLR) by only partially overwriting the function pointer.
  • They could overwrite the library pointer in the service_user struct, allowing them to load any shared library file that they wanted, then run its _init() constructor. As before, this executed arbitrary code as root.
  • They observed that whenever a crash happened, sudo created a new directory that only contained a sudo timestamp file. This seemed to be a potentially exploitable race condition. As it turned out, as part of the heap overflow exploitation they’ve accidentally overwritten def_timestampdir, which defined which directory the timestamp files should be saved to. Since these files were owned by root, they could not edit them directly – however, by exploiting a race condition in sudo’s timestamp_lock() function, they could replace the timestamp file with a symbolic link to /etc/passwd, and then inject arbitrary data in there by exploiting the heap overflow in the right way. This way, the attacker could inject a new user with root privileges, thus giving them root privileges indirectly.

Is bigger always better?

The ‘Baron Samedit’ vulnerability could have been prevented by proper input validation, but it also highlights some systemic problems that have been seen before in other vulnerabilities – and we will likely keep seeing them for some time.

First of all, increased complexity is always going to increase the likelihood of security vulnerabilities in the code, as underlined by the Economy of Mechanism, the first principle of secure software design.

The sudo program has evolved a lot (and perhaps has been refactored a few times) over 40 years. It has grown to be a much more complex beast, with greatly expanded functionality compared to its original purpose. This isn’t a problem by itself, but it definitely makes finding and fixing vulnerabilities harder. In this particular case, the vulnerability spanned two different code units (parse_args.c and sudoers.c) that each represented different aspects of the program: a basic command line parser vs. a policy file handler plugin. And finally, the command line to trigger the vulnerability invoked sudoedit instead of sudo – while using a command line argument that only made sense for sudo! This is part of the reason why this vulnerability has been hiding in the source code for almost a decade without the good guys noticing it (and we hope the bad guys haven’t secretly been exploiting it all this time).

A related issue – not specific to sudo! – is undertesting. Many open-source tools, especially old ones like sudo, tend to go light on unit tests (if they have them at all) and are rarely tested for vulnerabilities in the first place. Google’s OSS-Fuzz initiative is trying to help here by providing the infrastructure for large-scale fuzz testing of open source software, but there is still a fair amount of manual work involved – someone still needs to create a test harness to connect the fuzzer to the program under test, after all.

An easy parallel for Baron Samedit is the famous Shellshock (CVE-2014-6271) vulnerability from 2014. In Shellshock – again, due to missing input validation -, it was possible to exploit an obscure vulnerability in how bash handled function definitions inside environment variables, which itself was a very rarely used functionality. The bug was introduced in 1989, and it took 25 years for someone to find it despite bash being the default shell for many *nix systems for decades!

We can also draw some parallels to another cyber security incident, the WannaCry ransomware and EternalBlue exploit kit – they were ultimately enabled by a simple type mismatch bug (CVE-2017-0143) in what was essentially input validation code to verify the length of an SMB message.

Input validation – check yourself before you wreck yourself

Looking back, “Baron Samedit” is rooted (😊) in input validation issues. Specifically:

  • not checking whether a backslash was followed by a non-NUL character during unescaping – or whether the write into the user_args buffer was still within the bounds of that buffer,
  • incorrectly checking whether the av argument string should be unescaped, and
  • not checking whether the command line options used when invoking sudoedit were valid.

The first two issues were quickly fixed by adding some extra validation to plugins/sudoers/sudoers.c. Originally the unescaping code was not vulnerable by itself, but it placed too much trust in its inputs, and had too many assumptions that pushed things in the attacker’s favor. The developer correctly addressed these issues with the fix: the code now only unescapes the text if it was actually escaped in the first place. The escaping process was also made more robust by checking for a terminating NULL character immediately after a backslash. This is a secure coding technique called defensive programming – adding input validation to code and doing sanity checks to improve robustness and prevent exploitation of vulnerabilities even in the presence of other bugs.

There was also another fix that added input validation to plugins/sudoers/policy.c, and restricted possible command line arguments for sudoedit to the ones that actually make sense:

sudoers_policy_deserialize_info(void *v)
{
    const int edit_mask = MODE_EDIT|MODE_IGNORE_TICKET|MODE_NONINTERACTIVE;
    // …
    /* Sudo front-end should restrict mode flags for sudoedit. */
    if (ISSET(flags, MODE_EDIT) && (flags & edit_mask) != flags) {
	sudo_warnx(U_("invalid mode flags from sudo front end: 0x%x"), flags);
	goto bad;
    }
    // …
}

Also, while this vulnerability is serious, thankfully it doesn’t affect all Unix-like systems in the world. OpenBSD, for instance, has abandoned sudo in 2016 in favor of a simpler and more secure tool (doas) – and this is despite sudo itself originally being a product of OpenBSD developers!

Improper input validation is the first of the Seven Pernicious Kingdoms, and with good reason: many real-world vulnerabilities are a consequence of incorrectly implemented – or missing – validation. The escaping mechanism that introduced the vulnerability was well-intentioned (to prevent potential command injection vulnerabilities), but even a tiny oversight could eventually spiral out of control and result in an exploitable heap overflow vulnerability.

As always.

At Cydrill, our mission is for secure coding to become second nature for developers. By combining practical skills with an understanding of the hacker mindset, we teach the best practices that prevent such problems in your code before they cause too much damage in form of a cyber security incident. Courses such as Secure coding in C and C++ or its ARM version can help you establish code hygiene, and prevent mistakes that can lead to the next Baron Samedit.