3. Harness

With the workstation locked and loaded, we need a fuzzing harness. Throwing garbage at sudo blindly won't get us anywhere — it'll just hang at a password prompt or bail out at argument parsing. A good harness cuts through the noise, bypasses blockers, and forces the binary down dangerous paths.

For open-source targets like sudo, we have the luxury of source patches and controlled test scaffolds. This not only keeps fuzzing efficient, but also lets us zero in on logic flows where real bugs lurk.

3.1. Kill Password Auth

First roadblock: authentication. By default, sudo spawns a password prompt on tty. In a fuzz loop, that means hang city — no progress, no crashes.

Solution: neuter the auth check.

This means we can patch the password verification routine to always succeed (or fail immediately), avoiding the interactive prompt entirely.

Succeed or Fail?

In real-world exploitation, attackers often don't know valid creds. Imagine, a bug with password required is much worthless. Fuzzing unauthenticated paths always gives us more bounty.

Inside plugins/sudoers/auth/sudo_auth.c, the verify_user() routine controls login success. We patch it to short-circuit immediately:

fuzz_sudo_1-1

Always return false (0) to simulate failed login. By adding this very early false return, the rest code snippet is then cut off.

3.2. Arguments Fuzzing

Next hurdle: sudo doesn't slurp from stdin or files like a typical fuzz target. Its main input surface is command-line arguments (argv[]).

To fuzz this properly, we hook into AFL++'s argument fuzzing helper: argv-fuzz-inl.h. This little header turns AFL's mutated bytes into synthetic argv[] arrays for our binary.

3.2.1. AFL Implementation

The argv-fuzz-inl.h is a helper used to fuzz command-line arguments (argv[]) with AFL++, instead of fuzzing standard input (stdin) — the fuzzing payload becomes the simulated command-line arguments passed to main(int argc, char argv).

It provides several pre-defined macros and functions. The AFL_INIT_SET0 macro is commonly used for fuzzing programs that take command-line arguments, while keeping the program name (argv[0]) fixed and unmutated.

C
#define AFL_INIT_SET0(_p)        \
  do {                           \
                                 \
    argv = afl_init_argv(&argc); \
    argv[0] = (_p);              \
    if (!argc) argc = 1;         \
                                 \
  } while (0)

This does two things:

  1. Replace argv[] with fuzzed input parsed from stdin
  2. Preserves argv[0] as a fixed string (_p), e.g., "sudo" or "sudoedit"

On the other hand, AFL_INIT_ARGV() fuzzes the entire argv[] array, including argv[0] (i.e., the program name):

C
#define AFL_INIT_ARGV()         \
do {                            \
 argv = afl_init_argv(&argc);   \
} while (0)

Typically we use this one when we want to explore different execution modes of a binary that switches behavior based on different progname.

Under the hood, the macros call afl_init_argv():

C
static char **afl_init_argv(int *argc) {

    static char  in_buf[MAX_CMDLINE_LEN];
    static char *ret[MAX_CMDLINE_PAR];

    char *ptr = in_buf;
    int   rc = 0;

    ssize_t num = read(0, in_buf, MAX_CMDLINE_LEN - 2);
    if (num < 1) { _exit(1); }
    in_buf[num] = '\0';
    in_buf[num + 1] = '\0';

    while (*ptr && rc < MAX_CMDLINE_PAR) {

    ret[rc] = ptr;
    if (ret[rc][0] == 0x02 && !ret[rc][1]) ret[rc]++;
    rc++;

    while (*ptr)
      ptr++;
    ptr++;

    }

    *argc = rc;

    return ret;

}

Encoding quirks:

  • Arguments are NUL-delimited (\0).
  • End of argv is marked by double-NUL (\0\0).
  • Empty args encoded as 0x02 0x00.

This approach allows afl-fuzz to mutate command-line arguments just like it mutates files — enabling deep testing of argument parsing logic.

Example: fuzz input that mimics sudo -u root id would be:

73 75 64 6f 00 2d 75 00 72 6f 6f 74 00 69 64 00 00

Which maps to:

"sudo\0-u\0root\0id\0\0"

After calling AFL_INIT_SET0("sudo"), argv[] becomes:

argv[0] = "sudo";      // fixed manually
argv[1] = "-u";        // from fuzzed input
argv[2] = "root";
argv[3] = "id";
argv[4] = NULL;

The harness is our cheat code. By patching auth and wiring in argv[] fuzzing, we don't waste cycles on prompts or invalid entry points.

3.2.2. Hook Sudo Argv

To fuzz sudo's command-line arguments, we need to wire AFL++ into its main() by including the helper header:

#include "/home/pwn/fuzz/tools/AFLplusplus/utils/argv_fuzzing/argv-fuzz-inl.h"

AFLplusplus provides utils/argv_fuzzing/argv_fuzz_demo.c to illustrate the fundamental usage for these utilities.

Locate the main() function in src/sudo.c at line 150, and hook argv[] as follow:

fuzz_sudo_1-2

This way fuzz the first argument argv[0] (progname or __progname) as well. But from the previous analyzed source code, we see the it actually validates the program name—meaning we should try the other macro AFL_INIT_SET0.

3.2.3. Argv Constraints

Problem: sudo enforces a whitelist of valid program names very early in main():

const char * const allowed_prognames[] = { "sudo", "sudoedit", NULL };
initprogname2(argc > 0 ? argv[0] : "sudo", allowed_prognames);

and then:

/* Only allow "sudo" or "sudoedit" as the program name. */
initprogname2(argc > 0 ? argv[0] : "sudo", allowed_prognames);

Meaning:

  • If argv[0] isn't "sudo" or "sudoedit", execution dies instantly.
  • Wasting fuzz cycles on invalid names.

So for accurate fuzzing, we generally use AFL_INIT_SET0("sudo") (or "sudoedit") to pin argv[0] and let AFL mutate the rest:

fuzz_sudo_1-3

3.2.4. Override Progname

But there's a twist.

The function initprogname2() in lib/util/progname.c doesn't just trust argv[0]. On Linux, it can override it with the global symbol __progname (set up by crt0).

C
#include <config.h>

...

// [1] On systems that support getprogname() (e.g., BSD variants),
#ifdef HAVE_GETPROGNAME

# ifndef HAVE_SETPROGNAME
/* Assume __progname if have getprogname(3) but not setprogname(3). */
extern const char *__progname;	// Global variable

void
sudo_setprogname(const char *name)	// Substitution for the missing setprogname
{
  ...	// Just logic to define it as the global __progname
}
# endif
    
void
initprogname2(const char *name, const char * const * allowed)
{
  ... 	// logic to use getprogname() syscall to initialize program name 
}

// [2] On systems without getprogname() (e.g., non-BSD Linux)
#else /* !HAVE_GETPROGNAME */

static const char *progname = "";	

void
initprogname2(const char *name, const char * const * allowed)
{
  int i;
// [2-1] Config 
# ifdef HAVE___PROGNAME
  extern const char *__progname;	// Global variable

  if (__progname != NULL && *__progname != '\0')
      progname = __progname;	// Use __progname 
  else
# endif
  ... // logic to define program name if there's no HAVE___PROGNAME config
}

...

The purpose of the progname.c file is to initialize and manage the program name (progname) used internally by sudo, under different OS and environment.

Our deployed environment is a non-BSD Linux, thus the code will head into branch [2] by skipping [1]. Then the code path will be decided on if HAVE___PROGNAME is configured. Before running ./configure ... we see this options listed in the config.h.in at line 1015 under the source root:

C
/* Define to 1 if your crt0.o defines the __progname symbol for you. */
#undef HAVE___PROGNAME

But once we run ./autogen.sh and ./configure ... with no special flags specified, it's set to 1 by default:

fuzz_sudo_1-4

Translation: we think we're fuzzing argv[0], but the binary cheats and resets it—no matter what AFL injects, progname snaps back to __progname.

To actually fuzz argv[0], we must stop this normalization. Simply null out this section in progname.c by:

fuzz_sudo_1-5

This leaves argv[0] raw and fuzzer-controlled

3.3. Harness Compilation

Once we've patched the source for auth bypass and argv[] fuzzing, it's time to build.

Configure the harness with AFL++ as the compiler, plus sanitizers for crash fidelity:

Bash
cd ~/fuzz/proj/sudo-1.9.5p1/src

# To install it to a local directory
mkdir -p ~/fuzz/proj/sudo-1.9.5p1/harness
./autogen.sh

# Configure AFLplusplus compiler and sanitizers:
CC=afl-clang-lto CXX=afl-clang-lto++ \
./configure --prefix=$HOME/fuzz/proj/sudo-1.9.5p1/harness --disable-shared --enable-static \
        	  CFLAGS="-fsanitize=address,undefined -g" \
          	LDFLAGS="-fsanitize=address,undefined -g" \
          	LIBS="-lcrypt"

In my setup environment, I will have to fix some compilation issues:

On some setups logsrvd/Makefile.in fails at link stage. Patch it like so at line 45:

Makefile
LT_LIBS = $(top_builddir)/lib/iolog/libsudo_iolog.la \
      	  $(top_builddir)/lib/eventlog/libsudo_eventlog.la \
      	  $(top_builddir)/lib/logsrv/liblogsrv.la \
      	  $(top_builddir)/lib/util/libsudo_util.la

Now build with sanitizers + AFL instrumentation:

Bash
AFL_USE_ASAN=1 AFL_USE_UBSAN=1 LLVM_CONFIG=llvm-config-15 make -j$(nproc)
sudo make install

Resulting harness binaries land here:

$ ls -lh  ~/fuzz/proj/sudo-1.9.5p1/harness/bin
-rwxr-xr-x 1 root root 1.2M Aug  1 21:07 cvtsudoers
-rwsr-xr-x 1 root root 2.8M Aug  1 21:07 sudo
lrwxrwxrwx 1 root root    4 Aug  1 21:07 sudoedit -> sudo
-rwxr-xr-x 1 root root 625K Aug  1 21:07 sudoreplay

3.4. Harness Validation

A small strict: insert a debug print inside main() to show argv[0] after AFL initialization before build:

fuzz_sudo_1-6

After integrating AFL-style instrumentation, we no longer pass arguments directly. Inputs must be NUL-separated argv buffers (\0 between args, \0\0 at the end).

Example input file:

Bash
echo -ne 'sudo\0-l\0\0' | tee test_input

Run through the harness:

Bash
cat test_input | harness/bin/sudo

This transforms into:

argv[0] = "sudoedit"   // hardcoded by AFL_INIT_SET0()
argv[1] = "-l"
argv[2] = NULL

If we previously create the harness using AFL_INIT_SET0("sudoedit"), even if we supply "sudo" as the first argument (argv[0]) in this input file, the output remains as "sudoedit":

fuzz_sudo_1-7

This helps us control which code paths get fuzzed, just by changing the string parameter inside AFL_INIT_SET0(_p).

Additionally, no password prompt appears—instead, auth fails instantly (as intended, thanks to our patched verify_user()):

C
int verify_user(...) {
    return false;
    ...
}

Trace it with strace to confirm:

Bash
strace -e trace=write ./harness/bin/sudo < test_input
fuzz_sudo_1-7-2

We see silent error writes to stderr — proof that auth short-circuits properly.

Finally, run the harness with afl-showmap to show which code paths (edges) are hit after instrumentation:

Bash
AFL_DEBUG=1 afl-showmap -q -o /dev/null -- harness/bin/sudo < test_input
fuzz_sudo_1-7-3

Now that our harness is functional, we need to feed it a corpus — a set of initial input files that AFL++ will mutate to explore different execution paths.