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.
#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:
- Heap overflow in
edit.lenis 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. - Use-after-free in
delete/show.deletefrees but never clears the pointer, soshowreads freed memory and a seconddeletedouble-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.
#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#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.
#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, becauselencan 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.
# 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:
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/reuseYou 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.
create="\x00"
edit="\x01"
show="\x02"
delete="\x03"Run it
afl-fuzz -i in -o out -x ops.dict -- ./notes_fuzzBecause 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.
afl-tmin -i out/default/crashes/id:000000* -o crash.min -- ./notes_fuzzA 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:
./notes_fuzz < crash.min==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:15That 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:
00 00 08 -> create(idx=0, size=8)
01 00 40 -> edit(idx=0, len=64, ...) <- 64 > 8, overflowThat 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:
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:
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:
# 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
doneIf 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.
# -Q enables QEMU mode. Feed menu text on stdin.
afl-fuzz -Q -i in -o out -x menu.dict -- ./challHere 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.
one="1\n"
two="2\n"
three="3\n"
four="4\n"
newline="\n"
digits="0123456789"# 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/sessionThis 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.
# 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 -- ./challThis 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
valgrindduring 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 -
nmconfirms 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-tminbefore 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:
- Refactor the operations into callable functions.
- Encode operations as a compact bytecode.
- 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.

