CVE-2023-22809: Sudoedit Bypass - Analysis

13 minute read

Introduction

A vulnerability was discovered by Synacktive in the sudo program and was published on January 18, 2023, known as CVE-2023-22809. This vulnerability leads to a security bypass in the sudoedit feature, allowing unauthorized users to escalate their privileges by editing files. This vulnerability affects versions of sudo from 1.8.0 through 1.9.12p1.

 

Testing Lab

For the testing lab, I will be using my normal Kali machine with an affected version of Sudo. The details are as follows

┌──(kali㉿kali)-[~]
└─$ sudo --version
Sudo version 1.9.10
Sudoers policy plugin version 1.9.10
Sudoers file grammar version 48
Sudoers I/O plugin version 1.9.10
Sudoers audit plugin version 1.9.10

 

What is sudo ?

Sudo is a short for Superuser do, and it is a program used by Linux that allows a user with the proper permissions to execute commands on the actions or behavior of another user, typically root. Therefore, it can be used to give users permission to run a specific program or command without giving them high-level permissions, such as root.

 

How sudo Works ?

When a user tries to execute the sudo command, It first checks and verifies the user’s permissions at /etc/sudoers, which is the configuration file that contains a list of users and their corresponding permissions. This is used to determine which users are authorized to run specific commands as certain users, depending on the configurations. If the user is authorized to run the command using sudo, they will be asked to enter their password to confirm their identity before the command is executed with elevated privileges. Another way is if the user is listed in the sudoers file with the NOPASSWD option. This allows the user to run either one command or all commands as root without confirming a password.

 

The Analysis

As we explained briefly how sudo works, let’s take an in-depth look at what happens when it is run. According to synacktive, the sudoers plugin first calls the sudoers_policy_main() function, which is responsible for handling the lookup and validation of the policy using the sudoers_lookup() function. Now, let’s take a look at the sudoers_policy_main() function.

 

Note: The function is over 500 lines of code, therefore we will give an overview of it.

 

This function starts by taking the following arguments:

int sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
    bool verbose, void *closure)
  • argc: Number of command-line arguments passed to the function.

  • argv: Array of command-line arguments passed to the function.

  • pwflag: Indicating whether the user’s password has been verified or no or the type of authentication.

  • env_add: Array of environment variables to add to the user’s environment when running the command.

  • verbose: Indicating whether verbose output should be generated.

  • closure: A pointer to any additional data passed to the plugin.

     

After taking all of these arguments The function first checks the user’s permissions by referring to the sudoers file, which contains a list of users and their respective permissions. If the user does not have permission to run the command, the function exits and sends an error message to the user. On the other hand, if the user is permitted to run the command, the function continues to execute it as the relevant user or group, using the setuid and setgid bit to temporarily acquire the user’s privileges. By reviewing the lines of the function, we can see the following line:

validated = sudoers_lookup(snl, sudo_user.pw, &cmnd_status, pwflag);

The sudoers_lookup() is where the lookup happens to check the user’s permissions by taking the following arguments:

  • snl: Object that holds information about the sudoers file sources.

  • sudo_user.pw: User’s password from the struct sudo_user.

  • &cmnd_status: Pointer to a variable that can be used to store information about the command or the authorization status.

  • pwflag: Indicating whether the user’s password has been verified or no or the type of authentication.

     

Then, the function will compare the user and the command they want to run with the defined rules in the sudoers file to check if the user is authorized and has the permissions to run the command or not. Inside the plugins/sudoers/parse.c file, we can see the sudoers_lookup() function code where all of that happens:

int sudoers_lookup(struct sudo_nss_list *snl, struct passwd *pw, int *cmnd_status,
    int pwflag)
{
    struct defaults_list *defs = NULL;
    struct sudoers_parse_tree *parse_tree = NULL;
    struct cmndspec *cs = NULL;
    struct sudo_nss *nss;
    struct cmnd_info info;
    int validated = FLAG_NO_USER | FLAG_NO_HOST;
    int m, match = UNSPEC;
    time_t now;
    debug_decl(sudoers_lookup, SUDOERS_DEBUG_PARSER);

    /*
     * Special case checking the "validate", "list" and "kill" pseudo-commands.
     */
    if (pwflag)
    debug_return_int(sudoers_lookup_pseudo(snl, pw, validated, pwflag));

    /* Need to be runas user while stat'ing things. */
    if (!set_perms(PERM_RUNAS))
    debug_return_int(validated);

    /* Query each sudoers source and check the user. */
    time(&now);
    TAILQ_FOREACH(nss, snl, entries) {
    if (nss->query(nss, pw) == -1) {
        /* The query function should have printed an error message. */
        SET(validated, VALIDATE_ERROR);
        break;
    }

    m = sudoers_lookup_check(nss, pw, &validated, &info, &cs, &defs, now);
    if (m != UNSPEC) {
        match = m;
        parse_tree = nss->parse_tree;
    }

    if (!sudo_nss_can_continue(nss, m))
        break;
    }
    if (match != UNSPEC) {
    if (info.cmnd_path != NULL) {
        /* Update user_cmnd, user_stat, cmnd_status from matching entry. */
        free(user_cmnd);
        user_cmnd = info.cmnd_path;
        if (user_stat != NULL)
        *user_stat = info.cmnd_stat;
        *cmnd_status = info.status;
    }
    if (defs != NULL)
        (void)update_defaults(parse_tree, defs, SETDEF_GENERIC, false);
    if (!apply_cmndspec(cs))
        SET(validated, VALIDATE_ERROR);
    else if (match == ALLOW)
        SET(validated, VALIDATE_SUCCESS);
    else
        SET(validated, VALIDATE_FAILURE);
    }
    if (!restore_perms())
    SET(validated, VALIDATE_ERROR);
    debug_return_int(validated);
}

 

So, what the function does behind the scenes is it checks for the special case of the validate, list, and kill pseudo-commands. Each one of them has a different usage, as follows:

  • validate: It’s used to check if a user has valid permissions to run a command. The function checks the sudoers file and verifies that the user is allowed to run the command.

  • list: It’s used to display a list of the privileges that a user has, Then the function use it to check the sudoers file and lists the commands that the user is allowed to run.

  • kill: It’s used to revoke privileges from a user, Then the function use it to check the sudoers file and revokes the privileges associated with the user.

     

And this part occurs within the sudoers_lookup_pseudo() function. At the end, when all checks have been done successfully, the user is cleared to proceed and can complete the command. Now, it is time to discuss sudoedit, which is a component of sudo and is primarily used for the same functionality as sudo. However, it is specifically used for securely editing files that require high levels of permissions, primarily those of the root user.

if (ISSET(sudo_mode, MODE_EDIT))

 

In the code above, which is a part of the sudoers_policy_main() function, the line checks if the sudo_mode is equal to MODE_EDIT. This indicates that the user wants to run an editor. The following line then:

safe_cmnd = find_editor(NewArgc - 1, NewArgv + 1, &edit_argc, &edit_argv, NULL,
&env_editor, false);

 

It saves the value of the find_editor() function in safe_cmnd to use later. As you can guess from the function name, it searches for an editor (by default, it's vi). If we go through the following part of the function code:

*env_editor = NULL;
    ev[0] = "SUDO_EDITOR";
    ev[1] = "VISUAL";
    ev[2] = "EDITOR";
    for (i = 0; i < nitems(ev); i++) {
    char *editor = getenv(ev[i]);

    if (editor != NULL && *editor != '\0') {
        *env_editor = editor;
        editor_path = resolve_editor(editor, strlen(editor),
        nfiles, files, argc_out, argv_out, allowlist);
        if (editor_path != NULL)
        break;
        if (errno != ENOENT)
        debug_return_str(NULL);
    }
    }

 

The above code looks for the editor in the environment variables SUDO_EDITOR, VISUAL, and EDITOR. Then, for each variable found, it will pass it to the resolve_editor() function, along with the number of files, etc.

if (nfiles != 0) {
nargv[nargc++] = "--";
while (nfiles--)
nargv[nargc++] = *files++;
}

 

Anyway, When the resolve_editor() function resolves the editor’s path, it accepts extra arguments to be passed and separates them from the files in the original command-line using the -- as a separator. After that, it calls the sudo_edit() function and each argument is considered a filename due to the -- separator, as you can see in the following code:

 /*
     * The user's editor must be separated from the files to be
     * edited by a "--" option.
     */
    for (ap = command_details->argv; *ap != NULL; ap++) {
    if (files)
        nfiles++;
    else if (strcmp(*ap, "--") == 0)
        files = ap + 1;
    else
        editor_argc++;

 

Exploitation

Now, In order to exploit this vulnerability, I have logged in as the user kali' on the machine. Let’s add the following rule to the /etc/sudoers file:

kali    ALL=(root) NOPASSWD: sudoedit /etc/services

 

This rule will allow the user kali to edit the /etc/services file without being prompted for a password. Moving on to the next step, let’s add the EDITOR variable to our environment variables and assign it the value vi -- /etc/shadow:

 

 

  • Add Editor variable:
export EDITOR="vi -- /etc/shadow"

Let’s run sudo -l and you will be able to see that our rule has been applied.

 

By running sudoedit /etc/services it will execute vi on /etc/shadow file also.

 

 

  • command-line output:

 

So, As a Re-Cap what happened is:

1- User has permissions to edit /etc/services

2- User defined a crafted editor environment-variable vi -- /etc/shadow

3- When sudo executed and matched MODE_EDIT it will lookup for the editor in the 3 known environment-variables SUDO_EDITOR, VISUAL, and EDITOR.

4- As the resolve_editor() function resolves the editor’s path, It accepts extra arguments to be passed and separates them from the files in the original command-line using the -- as a separator. After that, it calls the sudo_edit() function and each argument is considered a filename due to the -- separator, Therefore, It will take the extra argument which is /etc/shadow file to be edited.

 

Mitigation

To prevent this vulnerability, we will add environment variables to the denial list using the env_delete in /etc/sudoers as follows:

Defaults!SUDOEDIT       env_delete+="SUDO_EDITOR VISUAL EDITOR"
Cmnd_Alias SUDOEDIT = sudoedit /etc/services
kali    ALL=(root) NOPASSWD: SUDOEDIT

 

Now, if we run sudo -l we can see the following and our new rule applied:

 

  • Trying after mitigation:

     

 

And, Finally you can also update to the last version of sudo, Which is patched. Just by running apt install sudo.

 

  • Trying after update:

Patch Diffing

Now, Coming to the patch part, We can see here the commit made to the code on github https://github.com/sudo-project/sudo/commit/0274a4f3b403162a37a10f199c989f3727ed3ad4. There are 3 files got changed which are editor.c, sudoers.c and visudo.c. Let’s explain the changes in each file:

 

editor.c

change no.1

  • Before change
        if (find_path(editor, &editor_path, &user_editor_sb, getenv("PATH"), NULL,
    0, allowlist) != FOUND) {
sudoers_gc_remove(GC_PTR, editor);
free(editor);
errno = ENOENT;

 

The code is a condation if the editor not found it will call sudoers_gc_remove() function and then use the free() function to free up any memory associated with the editor variable. Finally, set errno to ENOENT.

 

  • After change
if (find_path(editor, &editor_path, &user_editor_sb, getenv("PATH"), NULL,
    0, allowlist) != FOUND) {
    goto bad;
    }

 

You can see in the after change code it makes the code jump to bad which defined before in the code. As we can see from the commit that it doesn’t want the errno be equal toENOENT which means No such file or directory.

 

Change no.2

In this change the following code added:

if (strcmp(nargv[nargc], "--") == 0) {
        sudo_warnx(U_("ignoring editor: %.*s"), (int)edlen, ed);   
        sudo_warnx("%s", U_("editor arguments may not contain \"--\""));
        errno = EINVAL;
        goto bad;
    }

Basically, the code checks for the -- in the editor variables, If it’s exist it will not work (And we can see it in the mitigation above).

 

sudoers.c

Change

  • Before change
if (errno != ENOENT)
audit_failure(NewArgv, N_("%s: command not found"),
        env_editor ? env_editor : def_editor);
        sudo_warnx(U_("%s: command not found"),
        env_editor ? env_editor : def_editor);
        goto bad;

 

The statment here checks if the error is not euqal to ENOENT. Then it will call the audit_failure() function and then sudo_warnx() function to log the error and display a warning message. After that jump to bad which will handle the error.

 

  • After change
switch (errno) {
        case ENOENT:
        audit_failure(NewArgv, N_("%s: command not found"),
            env_editor ? env_editor : def_editor);
        sudo_warnx(U_("%s: command not found"),
            env_editor ? env_editor : def_editor);
        goto bad;
        case EINVAL:
        if (def_env_editor && env_editor != NULL) {
            /* User tried to do something funny with the editor. */
            log_warningx(SLOG_NO_STDERR|SLOG_AUDIT|SLOG_SEND_MAIL,
            "invalid user-specified editor: %s", env_editor);
            goto bad;
        }
        FALLTHROUGH;
        default:
        goto done;

 

In this switch we have 2 cases:

  • First: If errno is set to ENOENT it will call the audit_failure() function and then sudo_warnx() function to log the error and display a warning message.

  • Second: If errno is set to EINVAL and the user has attempted to do something with the editor, Then it will call the audit_failure() function and then sudo_warnx() function to log the error and display a warning message.
    Finally, If there is no case of the 2 cases occures it will jump to done, Which defined before in the code and indicating to success of the process.

 

visudo.c

Change

  • Before Change
if (editor_path == NULL) {
    if (def_env_editor && env_editor != NULL) {
        sudo_fatalx(U_("specified editor (%s) doesn't exist"), env_editor);
  • After change
if (errno == ENOENT) {
        sudo_warnx(U_("specified editor (%s) doesn't exist"),
            env_editor);
        }
        exit(EXIT_FAILURE);

Here we can see that the old code replaced with more secure error handling way for $EDITOR and exit safely.

 

Conclusion

My final words are to do your upgrade better cause as there is a vulnerable version it’s possiable to bypass the rules in the sudoers file. Therefore, keep all of your softwares updated. And also add the above suders rules also to your /etc/sudoers file as another layer of security. In summary, the root cause is the presence of the -- argument to determine the list of files to edit can be included in environment variables without any validation.