0. TL;DR

Heap bugs are still the bread and butter of real-world pwn. Many practical crashes—especially those found through fuzzing—stem from heap-related issues. The key challenge is identifying how to pivot from a crash to a reliable exploit.

This writeup is a field guide—a step-by-step dissection of how we take a crash in sudo and shape it into a privilege escalation exploit. Our lens: the infamous Baron Samedit (CVE-2021-3156), a heap overflow bug that shook the Linux ecosystem, revisited with a new twist.

We'll fold in the freshy publicly released primitive CVE-2025-4802 (but we pwners have have been weaponized with it for years), a setlocale()-triggered heap-feng-shui technique that manipulates NSS (Name Service Switch) internals. Think of this as the prologue to CVE-2025-32463, another NSS-abuse story (to be covered in Part II).

Objectives:

  • Fuzz sudo with AFL++ to trigger heap corruption.
  • Review and dynamically debug the Baron Samedit overflow (CVE-2021-3156).
  • Leverage setlocale() heap feng shui (CVE-2025-4802) to align chunks and poison NSS flows.
  • Escalate privileges by hijacking NSS lookups inside sudo.
  • Reconstruct the full chain: from fuzzing crash → code review → binary tracing → heap exploit techniques → privilege escalation PoC.

Prereqs for readers:

  • Comfort with Linux heap exploitation.
  • Familiarity with fuzzing workflows, especially AFL++.
  • A hacker's patience for debugging in GDB until your eyes bleed.

1. Victim

1.1. Target Version

Before fuzzing a binary, the first step is reconnaissance: study its lineage of vulnerabilities.

sudo has historically been a prime attack surface on Linux, because of its sensitive usage purpose, suffice to say. Some recent war stories in its CVE history:

CVE IDTypeAffected VersionsFixed In
CVE-2019-14287UID bypass< 1.8.281.8.28
CVE-2021-3156Heap buffer overflow1.8.2 – 1.8.31p2, 1.9.0 – 1.9.5p11.9.5p2
CVE-2023-22809Arbitrary file read/write1.8.0 – 1.9.12p11.9.12p2

And our focus is fuzzing and exploiting heap-based issues, the most relevant and impactful vulnerability among them is:

CVE-2021-3156 (Baron Samedit)

A heap-based buffer overflow in sudoedit, present in:

  • sudo 1.8.21.8.31p2
  • sudo 1.9.01.9.5p1

First unearthed by Qualys: advisory

For this case study, we select sudo-1.9.5p1 as our fuzzing target. The rationale:

  • It's the last vulnerable release before the patch dropped in 1.9.5p2.
  • It preserves the exploitable heap overflow, but with a slightly fresher codebase than older PoCs — giving us a new attack surface.
  • It sets the stage for Part II, where we pivot to CVE-2025-4802 (the setlocale() heap-feng-shui bug in NSS).

In short: we're loading sudo-1.9.5p1 into the fuzzing pit because it's the perfect bridge between the legendary Baron Samedit and the new heap-trick arsenal.

1.2. Challenges

Now that we've locked in our victim (sudo 1.9.5p1), the next question is: how the hell do we fuzz it?

Unlike average command-line binaries, sudo is a fortress: layered execution logic, password prompts, NSS hooks, and mode switches. A dumb stdin fuzz won't even tickle it. To make the fuzzer bite, we need strategy.

1.2.1. Password Prompt

By default, sudo halts at the password wall. In a fuzzing loop, that's game over — we'll just hang forever at a prompt.

Two hacks around this:

  • Patch out the auth logic (our choice).
  • Or run with a NOPASSWD sudoers config in our lab.

1.2.2. Parameter Constraints

The first argument to sudo (e.g., -l, ls, /bin/bash) determines the entire code path. Fuzzing with garbage values will just short-circuit before hitting juicy code.

Inside parse_args.c, the logic funnels argv[0] through initprogname(), enforcing an allowlist of valid program names. Bad input = wasted fuzz cycles.

In the very early stage of a running sudo process, the parse_args() function funnels argv[0] through initprogname():

C
#define ARG_PROGNAME 12
 { "progname" },
... 

int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
 			struct sudo_settings **settingsp, char ***env_addp)
{
  ...
  const char *progname;

  /* Pass progname to plugin so it can call initprogname() */
  progname = getprogname();
  ...
}

The called initprogname() is a wrapper for initprogname2() defined in progname.c:

C
void
initprogname2(const char *name, const char * const * allowed)
{
const char *progname;
  int i;
  ...
  /* Check allow list if present (first element is the default). */
  if (allowed != NULL) {
    for (i = 0; ; i++) {
    if (allowed[i] == NULL) {
       name = allowed[0];
       break;
     }
     if (strcmp(allowed[i], name) == 0)
       break;
    }
}
...

It enforces an allowlist of valid program names. Bad input = wasted fuzz cycles.

So:

  • Keep the first arg legit, mutate later ones.
  • Structure matters more than entropy.

1.2.3. Symlink Aliases

Classic Unix trick: sudoedit is just a symlink to sudo, but its progname flips the binary into MODE_EDIT. Same file, different persona:

$ ls -l /usr/bin/sudoedit
lrwxrwxrwx 1 root root 4 Jul 31 02:41 /usr/bin/sudoedit -> sudo

As displayed, /usr/bin/sudoedit is a symlink to /usr/bin/sudo — is central to how sudo internally differentiates its modes.

And we have seen similar implementation in the sudo help page:

$ sudo -h
usage: sudo -e [-AknS] [-C num] [-D directory] [-g group] [-h host] [-p prompt] [-R directory] [-T timeout] [-u user] file ...
  -e, --edit                    edit files instead of running a command
...

They may look similar, but the implementation logic is different.

Continue the argument parsing logic in parse_args.c, we can they both set mode = MODE_EDIT, but with different flag configuration:

C
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
			struct sudo_settings **settingsp, char ***env_addp)
{
...

/* First, check to see if we were invoked as "sudoedit". */
proglen = strlen(progname);
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) 
   {
     progname = "sudoedit";
     mode = MODE_EDIT;
     sudo_settings[ARG_SUDOEDIT].value = "true";
   }

 ...

  for (;;) {
      /*
       * Some trickiness is required to allow environment variables
       * to be interspersed with command line options.
       */
         if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1) {
           switch (ch) {
           ...
          case 'e':
          if (mode && mode != MODE_EDIT)
            usage_excl();
          mode = MODE_EDIT;
            sudo_settings[ARG_SUDOEDIT].value = "true";
            valid_flags = MODE_NONINTERACTIVE;		// [!] Mind this configuration 
          break;                
      ...

For fuzzing, this means if we poof argv[0] as sudoedit, it brings us into a different logic path.

1.2.4. Argument Fuzzing

Unlike most fuzz targets that slurp stdin or files, sudo lives and dies by argv[]. The parser (parse_args()) handles flags (-h, -e), end markers (--), and even inline env vars (VAR=value).

More argument parsing logic in parse_args.c:

C
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
 			struct sudo_settings **settingsp, char ***env_addp)
{
  ...
  /* Returns true if the last option string was "-h" */
#define got_host_flag	(optind > 1 && argv[optind - 1][0] == '-' && \
	    argv[optind - 1][1] == 'h' && argv[optind - 1][2] == '\0')

  /* Returns true if the last option string was "--" */
#define got_end_of_args	(optind > 1 && argv[optind - 1][0] == '-' && \
	    argv[optind - 1][1] == '-' && argv[optind - 1][2] == '\0')

  /* Returns true if next option is an environment variable */
#define is_envar (optind < argc && argv[optind][0] != '/' && \
	    strchr(argv[optind], '=') != NULL)   

  /* Space for environment variables is lazy allocated. */
   memset(&extra_env, 0, sizeof(extra_env));

  /* XXX - should fill in settings at the end to avoid dupes */
  for (;;) {
  /*
	 * Some trickiness is required to allow environment variables
	 * to be interspersed with command line options.
	 */
    if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1) {
	    switch (ch) {
		case 'A':
		    ...
		case 'a':
        	...
		default:
		    usage();
    }
}

This highlights that:

  • -h, --, and VAR=value inputs are treated with special logic.
  • Environment variables can be interspersed with options, creating complex parsing paths.
  • Some options (like -e, -a, etc.) parses user provided argc and argv via getopt_long(), or they cause immediate termination via usage().

Special quirks:

  • Env vars can be interleaved with options, creating weird parsing flows.
  • Some flags (-e, -a) hit deep code paths; others (-?) just yeet us out with usage().

So the fuzz harness must:

  • Inject payloads directly into argv[].
  • Respect just enough structure to get past the parser.

Fuzzing sudo isn't “throw bytes at stdin and pray.” It's a chess match. We line up our argv[] like pawns, use symlink tricks to flip modes, and patch out the password lock. Only then does the fuzzer start walking the dangerous paths where heap bugs hide.

But hold on, before fuzzing , our first move will be setting up a proper workstation for our task.