Airoverflow logo
Back to Blog
Blog2026-07-22AirOverflow

Fuzzing Menu-Based CTF Challenges with AFL++

#AFL++#CTF#Heap#Fuzzing#Harness Design

The problem with menu challenges

Almost every pwn challenge you have ever seen has the same shape: a menu loop. 1) create 2) edit 3) show 4) delete 5) exit. You pick a number, it asks for an index and some data, you go around again. The bug is hidden somewhere in how those operations interact: an edit that writes past its allocation, a delete that leaves a dangling pointer, a show that reads freed memory.

That interaction is exactly what makes menu challenges hard to fuzz naively. A classic fuzzer mutates a single flat input buffer. But a heap bug here is not triggered by one input, it is triggered by a sequence: create two notes, free the first, allocate a third that reuses the freed chunk, then edit the dangling pointer. No single byte string expresses that. You need the fuzzer to explore sequences of stateful operations.

This guide does it end to end. We write a small but realistic vulnerable heap challenge, then build a structure-aware AFL++ harness that interprets the fuzzer's bytes as a little program of menu operations. That one idea turns AFL++ from useless-on-menus into a machine that finds the heap bug in seconds.

The challenge

Here is our target: a tiny note manager. It is deliberately buggy in the two ways real heap challenges usually are, a missing bounds check on edit and a missing NULL after free. Save it as notes.c.

notes.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MAX_NOTES 16

static char  *notes[MAX_NOTES];
static size_t sizes[MAX_NOTES];

static int read_int(const char *prompt) {
    int v;
    printf("%s", prompt);
    if (scanf("%d", &v) != 1) exit(0);
    return v;
}

static void create(void) {
    int idx = read_int("index: ");
    int size = read_int("size: ");
    if (idx < 0 || idx >= MAX_NOTES) return;
    notes[idx] = malloc(size);
    sizes[idx] = size;
    printf("created note %d (%d bytes)\n", idx, size);
}

static void edit(void) {
    int idx = read_int("index: ");
    int len = read_int("length: ");
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx]) return;

    // BUG: len is never checked against sizes[idx]. A note allocated with
    // size 8 can be edited with length 64 -> heap buffer overflow.
    printf("data: ");
    for (int i = 0; i < len; i++) {
        int c = getchar();
        if (c == EOF) break;
        notes[idx][i] = (char)c;
    }
}

static void show(void) {
    int idx = read_int("index: ");
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx]) return;
    // BUG: if the note was deleted, notes[idx] still points at freed memory
    // (see delete). Reading it here is a use-after-free.
    printf("note %d: %s\n", idx, notes[idx]);
}

static void delete(void) {
    int idx = read_int("index: ");
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx]) return;
    free(notes[idx]);
    // BUG: no notes[idx] = NULL. The dangling pointer stays usable by show
    // and can be freed a second time -> use-after-free / double free.
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    for (;;) {
        int choice = read_int(
            "\n1) create\n2) edit\n3) show\n4) delete\n5) exit\n> ");
        switch (choice) {
            case 1:
                create();
                break;
            case 2:
                edit();
                break;
            case 3:
                show();
                break;
            case 4:
                delete();
                break;
            case 5:
                return 0;
            default:
                puts("invalid");
        }
    }
}

Two bugs, both idiomatic:

  1. Heap overflow in edit. len is read straight from the user and never compared to the note's real size. Allocate 8 bytes, edit 64, and you write 56 bytes past the chunk.
  2. Use-after-free in delete/show. delete frees but never clears the pointer, so show reads freed memory and a second delete double-frees.

A human finds these by reading. We want the fuzzer to find them by playing.

Why you cannot just pipe bytes at it

Your first instinct is to build the binary and let AFL++ throw random bytes at stdin. Try it and it barely moves: the program reads decimal numbers with scanf("%d"), so almost every random byte is rejected as invalid menu input. The fuzzer spends its whole budget failing to type a valid 1\n. Coverage flatlines. It never reaches the heap logic where the bugs live.

The fix is structure-aware fuzzing: stop making the fuzzer discover the text protocol, and instead give it a compact binary encoding where every byte does something useful. We refactor the challenge's operations into callable functions and write a harness that maps the fuzz buffer onto them.

Refactor the logic into a library

Split the pure heap logic away from the scanf I/O so the harness can call it directly. This is the single most important step, and it is exactly what you do for real targets too.

notes_lib.h
#ifndef NOTES_LIB_H
#define NOTES_LIB_H
#include <stddef.h>

#define MAX_NOTES 16

void notes_reset(void);                              // free everything, clear state
void notes_create(int idx, size_t size);
void notes_edit(int idx, const unsigned char *buf, size_t len);
void notes_show(int idx);
void notes_delete(int idx);

#endif
notes_lib.c
#include "notes_lib.h"
#include <stdlib.h>
#include <stdio.h>

static char  *notes[MAX_NOTES];
static size_t sizes[MAX_NOTES];

void notes_reset(void) {
    for (int i = 0; i < MAX_NOTES; i++) {
        free(notes[i]);
        notes[i] = NULL;
        sizes[i] = 0;
    }
}

void notes_create(int idx, size_t size) {
    if (idx < 0 || idx >= MAX_NOTES) return;
    free(notes[idx]);                 // avoid leaking on re-create
    notes[idx] = malloc(size);
    sizes[idx] = size;
}

void notes_edit(int idx, const unsigned char *buf, size_t len) {
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx]) return;
    // Same bug as the original: len is not bounded by sizes[idx].
    for (size_t i = 0; i < len; i++) notes[idx][i] = buf[i];
}

void notes_show(int idx) {
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx]) return;
    // Same UAF: no check that the note is still live.
    printf("%s\n", notes[idx]);
}

void notes_delete(int idx) {
    if (idx < 0 || idx >= MAX_NOTES || !notes[idx]) return;
    free(notes[idx]);
    // Bug preserved: pointer left dangling.
}

The logic is byte-for-byte the same as the challenge. We only moved it behind a function boundary so a fuzzer can call it a million times a second.

The structure-aware harness

Now the key piece. The harness reads the AFL++ test case as a stream of operations. Each op is one command byte followed by its arguments. This is a tiny bytecode interpreter, and the fuzzer's job becomes writing valid bytecode, which it is extremely good at.

harness.c
#include <stdint.h>
#include <stddef.h>
#include "notes_lib.h"

__AFL_FUZZ_INIT();

// Encoding, one operation at a time, consumed from the fuzz buffer:
//   0x00 idx size            -> create(idx % MAX_NOTES, size)
//   0x01 idx len <len bytes> -> edit(idx, bytes, len)
//   0x02 idx                 -> show(idx)
//   0x03 idx                 -> delete(idx)
// Anything else, or running out of bytes, ends the program.
static void run(const unsigned char *data, size_t size) {
    size_t p = 0;
    notes_reset();                       // fresh heap state every test case

    while (p < size) {
        unsigned char op = data[p++];
        if (op == 0x00) {
            if (p + 2 > size) break;
            int idx = data[p++] % MAX_NOTES;
            size_t sz = data[p++];        // 0..255 bytes
            notes_create(idx, sz);
        } else if (op == 0x01) {
            if (p + 2 > size) break;
            int idx = data[p++] % MAX_NOTES;
            size_t len = data[p++];
            if (p + len > size) len = size - p;
            notes_edit(idx, data + p, len);
            p += len;
        } else if (op == 0x02) {
            if (p + 1 > size) break;
            notes_show(data[p++] % MAX_NOTES);
        } else if (op == 0x03) {
            if (p + 1 > size) break;
            notes_delete(data[p++] % MAX_NOTES);
        } else {
            break;
        }
    }
    notes_reset();                        // free anything left, catch leaks
}

int main(void) {
    __AFL_INIT();                         // deferred fork server
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;

    while (__AFL_LOOP(10000)) {            // persistent mode
        size_t len = __AFL_FUZZ_TESTCASE_LEN;
        run(buf, len);
    }
    return 0;
}

Three details that matter:

  • notes_reset() at the top and bottom of every run. Persistent mode reuses the process, so without a reset the heap from iteration N leaks into N+1 and every crash becomes unreproducible. Reset is what makes each test case independent.
  • idx % MAX_NOTES. We fold the index into range so the fuzzer never wastes inputs on out-of-bounds indices that the code just rejects. Every byte stays productive.
  • Bounded len. We clamp the edit length to the bytes actually available so the harness itself never reads out of the fuzz buffer. The target's overflow bug is still fully reachable, because len can still exceed the note's allocation.

Build it

Compile the library and harness together with an AFL++ instrumenting compiler and ASAN. ASAN is what turns the silent heap overflow into a hard crash the fuzzer can see.

build.sh
# afl-clang-lto gives the best coverage; afl-clang-fast is a fine fallback.
export AFL_USE_ASAN=1

afl-clang-lto -g -O1 \
    harness.c notes_lib.c \
    -o notes_fuzz

# Sanity check: it must be instrumented ("__afl" symbols present).
nm notes_fuzz | grep -q __afl_area && echo "instrumented OK"

If AFL_USE_ASAN=1 is set at compile time, AFL++ automatically sizes the shared memory and sets the right ASAN options at runtime.

Seed the corpus

Give the fuzzer a couple of valid operation sequences so it starts inside the heap logic instead of at byte zero. A seed is just the raw encoding. Generate one with a few bytes:

make-seeds.sh
mkdir -p in

# create(idx=0,size=8), edit(idx=0,len=4,"AAAA"), show(0), delete(0)
printf '\x00\x00\x08\x01\x00\x04AAAA\x02\x00\x03\x00' > in/basic

# create two notes, free one, create a third (drives chunk reuse)
printf '\x00\x00\x10\x00\x01\x10\x03\x00\x00\x02\x10' > in/reuse

You do not need many seeds. Two or three that reach different operations are plenty; the mutator does the rest.

A dictionary of opcodes

Hand AFL++ the operation bytes as a dictionary so its havoc stage splices whole valid instructions in rather than discovering them one bit at a time.

ops.dict
create="\x00"
edit="\x01"
show="\x02"
delete="\x03"

Run it

fuzz.sh
afl-fuzz -i in -o out -x ops.dict -- ./notes_fuzz

Because the encoding is dense, coverage climbs immediately. Within seconds the fuzzer discovers that an edit with a large length after a small create corrupts the heap, and ASAN aborts. The crashing input lands in out/default/crashes/.

Watch the UI: saved crashes ticks up, and the map coverage number keeps rising as it learns the create/edit/free/show interactions.

Triage the crash

A crash file is not a finding yet. Turn it into one.

1. Minimize

Strip the crashing input down to the operations that actually matter.

minimize.sh
afl-tmin -i out/default/crashes/id:000000* -o crash.min -- ./notes_fuzz

A minimized heap-overflow crash is usually tiny: one create with a small size, one edit with a large length. That is the whole bug.

2. Read the ASAN report

Replay the minimized input and let ASAN explain the bug in full:

replay.sh
./notes_fuzz < crash.min
asan-report.txt
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000038
WRITE of size 1 at 0x602000000038 thread T0
    #0 notes_edit notes_lib.c:22
    #1 run harness.c:29
0x602000000038 is located 0 bytes to the right of 8-byte region
    allocated by thread T0 here:
    #0 malloc
    #1 notes_create notes_lib.c:15

That report names the bug class (heap-buffer-overflow), the exact line (notes_lib.c:22, the unchecked edit loop), the allocation site (notes_create), and the overflow distance. For the UAF path you would see a heap-use-after-free with both the free and the later access backtraced.

3. Decode the input back to a script

Translate the minimized bytes back into human operations so you understand and can hand-write the exploit path:

decoded.txt
00 00 08   -> create(idx=0, size=8)
01 00 40   -> edit(idx=0, len=64, ...)   <- 64 > 8, overflow

That two-line script is your reproducer. From here you would confirm it against the original notes.c (feeding the equivalent menu input over stdin) and write the actual heap exploit.

When you only have a binary and a decompiler

Most CTF pwn challenges ship a binary, not source. You open it in Ghidra or IDA, stare at pseudocode, and there is nothing to refactor into a library. You still have three good ways to fuzz it, in rough order of effort and payoff.

First: read the decompilation to map the target

Before choosing a strategy, recover the same facts you would have gotten from source. In Ghidra or IDA, find:

  • The menu dispatcher: the switch (or if-else chain) on the choice value. Each case calls one handler. Note each handler's address.
  • The handlers themselves: create, edit, show, delete. Read how each parses its arguments and touches the heap.
  • The global state: the note pointer array and any size array, with their addresses and element counts. You need the bound (here, 16) to fold indices.
  • The allocation sizes and any struct layout, so your input encoding matches what the binary actually expects.

A decompiled edit handler usually looks like this, names auto-generated:

edit_decompiled.c
void FUN_004011a6(void)
{
    int idx;
    int len;
    int i;

    idx = read_int("index: ");
    len = read_int("length: ");
    if ((idx < 0) || (15 < idx) || (notes[idx] == 0)) {
        return;
    }
    i = 0;
    while (i < len) {
        notes[idx][i] = (char)getchar();
        i = i + 1;
    }
    return;
}

The missing check of len against the note size is visible right there in the pseudocode. That is your bug, and it tells you which handler to aim the fuzzer at.

Strategy 1: reconstruct the logic, then harness the reconstruction

Rewrite the handlers in clean C straight from the pseudocode, then harness the reconstruction exactly like the source case earlier in this guide. Translate the decompiled loop above into readable code, one statement per line:

notes_edit_recon.c
void notes_edit(int idx, const unsigned char *buf, size_t len)
{
    if (idx < 0 || idx >= MAX_NOTES) {
        return;
    }
    if (notes[idx] == NULL) {
        return;
    }
    size_t i = 0;
    while (i < len) {
        notes[idx][i] = buf[i];
        i = i + 1;
    }
}

Then reuse the structure-aware harness.c from before against your reconstructed notes_lib.c. This is the fastest path and gives you full ASAN, so crashes point at a line.

The one risk is fidelity: your reconstruction is only as correct as your reading of the pseudocode. Guard against drift by differential testing. Feed the same inputs to the real binary and to your reconstruction and confirm they behave the same before you trust a crash:

difftest.sh
# Same input to both; outputs (and crashes) should match.
for seed in in/*; do
    ./chall              < "$seed" > real.out   2>&1
    ./notes_recon_stdin  < "$seed" > recon.out  2>&1
    if ! diff -q real.out recon.out > /dev/null; then
        echo "DIVERGENCE on $seed"
    fi
done

If they diverge, your model is wrong somewhere and any crash it produces may not reproduce on the real binary. Fix the reconstruction until they agree.

Strategy 2: black-box the real binary with QEMU mode

When you cannot or do not want to reconstruct, fuzz the untouched binary. AFL++ QEMU mode instruments it at translation time, so you need no source and no recompile.

qemu-blackbox.sh
# -Q enables QEMU mode. Feed menu text on stdin.
afl-fuzz -Q -i in -o out -x menu.dict -- ./chall

Here the fuzzer is back to speaking the raw text protocol, so help it the way we helped the structured version: seed with real menu transcripts and hand it a dictionary of the literal strings the parser expects.

menu.dict
one="1\n"
two="2\n"
three="3\n"
four="4\n"
newline="\n"
digits="0123456789"
menu-seed.sh
# A valid transcript: create note 0 size 8, edit it, show it, delete it.
printf '1\n0\n8\n2\n0\n4\nAAAA\n3\n0\n4\n0\n' > in/session

This is slower and less directed than a structured harness, but it needs nothing but the binary and finds shallow bugs quickly.

Strategy 3: persistent QEMU around a single function

The fast version of black-box fuzzing loops QEMU over just the target function instead of re-running the whole program each time. You point AFL++ at the function entry and return addresses you read out of the decompiler.

qemu-persistent.sh
# Addresses come straight from Ghidra/IDA. For a PIE binary, QEMU's default
# load base is 0x5555_5555_4000; add the function offset to it. For a no-PIE
# binary use the raw address as shown in the decompiler.
export AFL_QEMU_PERSISTENT_ADDR=0x0000555555555abc   # target function entry
export AFL_QEMU_PERSISTENT_RET=0x0000555555555b40    # its return site
export AFL_QEMU_PERSISTENT_GPR=1                      # restore registers each loop

afl-fuzz -Q -i in -o out -- ./chall

This is the binary-only equivalent of persistent mode: one function, looped, no process restart. It is the highest-throughput option when you cannot rebuild.

Getting crashes when ASAN is not available

On an untouched binary you do not get ASAN reports, only raw signals such as SIGSEGV. A heap overflow that corrupts but does not immediately fault can be missed. Two ways to sharpen detection:

  • Fuzz with QEMU to find crashing inputs by signal, then replay each crash against your ASAN-compiled reconstruction to get the clean report and the exact line.
  • Or run the binary under a hardened allocator or valgrind during triage so the corruption faults closer to where it happened.

Prefer the source refactor when you have source, and reconstruct-plus-harness when you only have a decompiler. Fall back to QEMU black-box only when reconstruction is impractical. Whichever you pick, the structure-aware idea still wins: the more of the menu protocol you encode for the fuzzer, the faster it reaches the heap.

Pre-flight checklist

Before you leave this running, confirm each of these. Click to check them off:

  • Heap logic refactored behind functions the harness can call directly
  • Harness resets all heap state at the start and end of every iteration
  • Indices folded into range so no input is wasted on rejected values
  • Built with an AFL++ compiler and AFL_USE_ASAN=1
  • nm confirms the binary is instrumented
  • Seed corpus contains at least one valid create/edit/free/show sequence
  • Opcode dictionary passed with -x
  • Crashes minimized with afl-tmin before analysis
  • No source: handler addresses and index bounds read out of the decompiler
  • Reconstruction differential-tested against the real binary before trusting a crash
  • Binary-only crashes replayed against an ASAN build for a clean root cause

Takeaways

Menu-based heap challenges are stateful, and the bug lives in the interaction between operations, not in any single input. The move that makes them fuzzable is to stop fighting the text protocol and instead:

  1. Refactor the operations into callable functions.
  2. Encode operations as a compact bytecode.
  3. Let a persistent-mode harness interpret the fuzzer's bytes as that bytecode, resetting state every iteration.

Do that and AFL++ explores create/edit/free/show sequences the way a player would, and it finds the overflow and the use-after-free far faster than you would by hand. The same harness pattern scales straight up to real stateful targets: parsers, protocol state machines, and allocator-heavy services.


Found a bug with this workflow? We coordinate disclosure and publish the full root-cause analysis as an advisory. Subscribe below to get the next one the day it drops.

Get the next one the day it drops.

New research, advisories, and deep dives from the AirOverflow team, straight to your inbox. No spam.