Safe Penetration Testing Labs · advanced · ~25 min
By the end of this lesson you will be able to: - Write a correct, deterministic **libFuzzer entry point** (`LLVMFuzzerTestOneInput`) around a single C parser or validator. - Explain the roles of the **fuzzer**, the **harness**, the **corpus**, and the **sanitizers** — and how they fit together as a bug-finding pipeline. - Turn raw fuzzer bytes into a **safe, NUL-terminated input** without introducing bugs the harness itself owns (leaks, over-reads, false crashes). - Build and run a harness with **AddressSanitizer (ASan)** and **UndefinedBehaviorSanitizer (UBSan)**, and **replay** a saved crash artifact to reproduce a finding. - Apply the **authorization and cleanup rules** for running fuzzing safely in a local, isolated lab. - Recognise when a crash is a **real defect** to report versus a bug you accidentally put in the harness.
Security objective. The asset you are protecting is the memory safety and availability of a program that parses untrusted input — a username validator, a config reader, a network-message parser. The threat is an attacker who sends malformed or hostile input that triggers a buffer overflow, out-of-bounds read, use-after-free, or undefined behaviour, leading to a crash (denial of service) or, worse, memory corruption an attacker can steer. A fuzz harness lets you find those bugs first, on your own machine, before an attacker does — so this is a purely defensive technique.
A fuzz harness is a small piece of code that hands one of your functions to a fuzzer such as libFuzzer or AFL++. The roles are deliberately narrow:
This lesson builds directly on your prerequisites. From Safe parsing — the defensive parser shape you already know that a parser must check lengths before it reads and reject malformed input at the boundary; fuzzing is how you prove your parser actually does that under adversarial pressure. From Memory safety — the whole bug class you know the failure modes (overflow, over-read, use-after-free, uninitialised reads); fuzzing plus sanitizers is the industrial way to surface those failures automatically instead of hoping a code review catches them. The harness you write here is the glue that connects those two ideas: it drives your defensive parser with the exact kind of hostile input the memory-safety lesson warned you about.
In authorized professional work — secure development, product-security review, an internal red team testing your own company's software — fuzzing is the highest-leverage way to find memory-safety bugs in parsers. A five-line harness around a single validator, run for an hour with sanitizers on, routinely finds bugs that survived years of manual review.
The reason it matters so much:
Crucially, fuzzing is defensive: you run it against code you own or are authorized to test, in an isolated environment, to remediate bugs. It is not a tool for attacking someone else's live systems.
Definition. A program that repeatedly generates, mutates, and feeds inputs to your target, watching for crashes and using code-coverage feedback to reach new paths.
Plain explanation. Think of it as a tireless tester that starts from some sample inputs (the corpus) and keeps tweaking bytes — flipping bits, splicing, growing, shrinking — favouring mutations that exercise new lines of code.
How it works. Coverage-guided fuzzers (libFuzzer, AFL++) instrument your code at compile time. Each run reports which edges were hit; inputs that reach new code are kept and mutated further. This is far more effective than blind random input.
When / when not. Ideal for functions that take a byte buffer and parse it. Poor fit for functions that need a complex live environment (open sockets, a populated database) unless you stub that environment out.
Pitfall. Without a seed corpus and without coverage instrumentation, fuzzing degrades to slow random testing that rarely gets past the first length check.
Definition. The single function libFuzzer calls for every input:
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);
Plain explanation. It receives a pointer to size bytes the fuzzer produced, converts them into whatever shape your function expects, calls that function, cleans up, and returns 0.
How it works. You compile this function together with your parser and link against libFuzzer (-fsanitize=fuzzer). The resulting binary is the fuzzer for that target.
When / when not. One harness targets one function or one clearly-bounded feature. Do not try to fuzz your whole program through a single entry point — small targets find bugs faster and give cleaner reports.
Pitfall. Treating data as a C string. It is not NUL-terminated and may contain embedded \0 bytes. You must copy it into a size + 1 buffer and add the terminator yourself if the parser expects a string.
Definition. The same input must always produce the same result.
Plain explanation. A harness must not depend on the clock, randomness, global mutable state, network, or files. If run N of the same input can crash while run N+1 does not, the bug cannot be reproduced and the finding is worthless.
Pitfall. Reading rand(), time(), an environment variable, or leftover global state from a previous input. Reset or avoid all of it.
Definition. Free every allocation the harness makes, and never over-read or over-write in the glue code.
Plain explanation. The fuzzer cannot tell your bug from the parser's bug. A leak in the harness gets blamed on the parser; an over-read in the harness is a false crash that wastes hours.
Pitfall. Forgetting free(copy), or writing copy[size] into a buffer of only size bytes.
Definition. Compiler instrumentation that detects memory and undefined-behaviour errors at runtime.
Plain explanation. Without a sanitizer a bad read might return garbage and look fine; with ASan it aborts immediately with a precise report and backtrace. Sanitizers are what convert "weird behaviour" into "actionable crash."
Pitfall. Fuzzing without sanitizers finds only the bugs that already crash on their own — a small fraction of the real total.
Definition. When the target crashes, libFuzzer writes the offending bytes to a file, by default named crash-<sha1>.
Plain explanation. That file is the reproducer. Run the same fuzz binary with the file as an argument and it replays exactly that one input, deterministically, so you can debug it under gdb or read the ASan report.
Pitfall. Deleting crash files before triaging them, or committing them to a public repo (they can encode sensitive input shapes). Store them with the finding, in the lab.
TRUST BOUNDARY: your program's parser boundary
Untrusted input Your program (the ASSET)
---------------- ---------------------------
files / packets / ==========> parse_input() <-- attack surface
form fields | reads header, fields
(attacker-controlled) | MUST length-check first
v
internal state / memory
FUZZING (authorized lab, localhost only):
----------------------------------------
libFuzzer --mutates--> LLVMFuzzerTestOneInput --calls--> parse_input()
^ (the HARNESS) |
| v
+------ crash artifact <----- ASan/UBSan detect memory/UB error
Entry point under test: the parser (right of the trust boundary)
Who supplies bytes in prod: the attacker
Who supplies bytes in the lab: the fuzzer (standing in for the attacker)
Knowledge check.
The one function libFuzzer requires. In C you do not need extern "C" (that is only for C++ harnesses, to prevent name mangling):
#include <stdint.h> /* uint8_t */
#include <stddef.h> /* size_t */
/* Called once per fuzzer-generated input.
data : pointer to `size` bytes (NOT NUL-terminated, may contain \0)
size : number of valid bytes at `data`
return: always 0 (non-zero is reserved by libFuzzer) */
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
/* 1. shape the bytes into what the target expects
2. call the target
3. free anything you allocated
4. return 0 */
return 0;
}
Build command (all three instrumentations at once):
clang -g -O1 -fsanitize=address,undefined,fuzzer harness.c parser.c -o parser-fuzz
-fsanitize=fuzzer links libFuzzer and supplies main.address,undefined enable ASan + UBSan.-g keeps symbols so backtraces are readable; -O1 keeps it fast but debuggable.Useful run flags:
./parser-fuzz -max_total_time=300 # stop after 300s (CI budget)
./parser-fuzz -max_len=4096 corpus/ # cap input size, seed from corpus/
./parser-fuzz crash-<sha1> # replay one saved crash
Fuzzing throws millions of pseudo-random inputs at a function and watches for crashes.
The standard C entry point is:
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
You write this function so that it calls into the parser you want to exercise.
With ASan and UBSan (Undefined Behavior Sanitizer) enabled, every crash is a real bug to investigate.
The shape below is insecure parser → secure parser → verify, with the same harness driving both. The harness stays tiny and correct; the difference is in the function under test.
/* parser.c + harness.c combined for teaching.
* Build (lab only):
* clang -g -O1 -fsanitize=address,undefined,fuzzer this.c -o parser-fuzz
*/
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
/* ----------------------------------------------------------------
* (1) WARNING: intentionally vulnerable — use only in a local,
* isolated, authorized lab. Do not deploy.
*
* Bug: assumes the input has at least 4 bytes of "header" and reads
* len from it, then copies `len` bytes into a fixed 16-byte buffer
* with NO bound check. A fuzzer finds this in seconds.
* ---------------------------------------------------------------- */
#ifdef USE_VULNERABLE
bool parse_record(const uint8_t *data, size_t size) {
char name[16];
/* insecure assumption: size >= 4, and data[3] <= 16 */
size_t len = data[3]; /* over-read if size < 4 */
memcpy(name, data + 4, len); /* overflow if len > 16 */
name[len] = '\0'; /* off-by-one on top */
return name[0] != 'X';
}
#else
/* ----------------------------------------------------------------
* (2) SECURE fix: validate BEFORE every read. Reject anything that
* does not fit. Never read past `size`; never write past the
* buffer. This is the defensive parser shape from the prereqs.
* ---------------------------------------------------------------- */
bool parse_record(const uint8_t *data, size_t size) {
const size_t HEADER = 4;
const size_t NAME_CAP = 16; /* max name length */
if (size < HEADER) return false; /* not enough for a header */
size_t len = data[3]; /* safe: index 3 < HEADER */
if (len > NAME_CAP) return false; /* reject oversize name */
if (len > size - HEADER) return false;/* body must fit in input; */
/* size-HEADER can't underflow, size>=4 */
char name[NAME_CAP + 1]; /* +1 for the NUL */
memcpy(name, data + HEADER, len); /* len <= NAME_CAP: safe */
name[len] = '\0'; /* index <= NAME_CAP: safe */
return name[0] != 'X';
}
#endif
/* ----------------------------------------------------------------
* The HARNESS — identical for both versions of parse_record.
* Deterministic, no globals, no leaks.
* ---------------------------------------------------------------- */
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
/* parse_record takes a bounded buffer, so no string copy needed.
* We pass data/size straight through. If the target needed a C
* string we would malloc(size+1), memcpy, NUL-terminate, free. */
parse_record(data, size);
return 0;
}
A separate, standalone verifier proves the secure version rejects bad input and accepts good input. This is an ordinary unit test you run without the fuzzer — it is your mitigation-verification step:
/* verify.c — build: clang -g -fsanitize=address,undefined verify.c -o verify
* Links the SECURE parse_record (compiled without USE_VULNERABLE). */
#include <assert.h>
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <stdio.h>
bool parse_record(const uint8_t *data, size_t size);
int main(void) {
/* REJECT: too short to hold a header */
uint8_t tiny[] = {1, 2};
assert(parse_record(tiny, sizeof tiny) == false);
/* REJECT: header claims len=200, far bigger than input/buffer */
uint8_t huge[] = {0, 0, 0, 200, 'A'};
assert(parse_record(huge, sizeof huge) == false);
/* REJECT: len=5 but only 1 body byte present */
uint8_t short_body[] = {0, 0, 0, 5, 'A'};
assert(parse_record(short_body, sizeof short_body) == false);
/* ACCEPT: 4-byte header, len=3, exactly 3 body bytes 'abc' */
uint8_t good[] = {0, 0, 0, 3, 'a', 'b', 'c'};
assert(parse_record(good, sizeof good) == true);
printf("all mitigation checks passed\n");
return 0;
}
Expected output of the verifier when the secure parser is correct:
all mitigation checks passed
When you build the harness with -DUSE_VULNERABLE and run ./parser-fuzz, ASan aborts within seconds on a stack-buffer-overflow in parse_record. Rebuild without -DUSE_VULNERABLE (the secure version) and the fuzzer runs its full time budget without a crash — that contrast is the proof the fix holds.
Walkthrough of the secure parse_record plus the harness, tracing one hostile input the fuzzer might produce: data = {0, 0, 0, 200, 'A'}, size = 5.
| Step | Line | What happens | Values |
|---|---|---|---|
| 1 | if (size < HEADER) |
Need 4 header bytes. size is 5, so we continue. |
size=5, HEADER=4 |
| 2 | len = data[3] |
Read the declared name length. Index 3 is inside the header, so this read is safe. | len=200 |
| 3 | if (len > NAME_CAP) |
200 > 16, so reject immediately. Return false. |
returns false |
The hostile input is refused before any copy, so no overflow occurs. Now trace a valid input data = {0,0,0,3,'a','b','c'}, size = 7:
| Step | Line | What happens | Values |
|---|---|---|---|
| 1 | size < HEADER |
7 >= 4, continue. | size=7 |
| 2 | len = data[3] |
Name length is 3. | len=3 |
| 3 | len > NAME_CAP |
3 <= 16, continue. | ok |
| 4 | len > size - HEADER |
size-HEADER = 3; 3 > 3 is false, continue. Body fits. |
ok |
| 5 | memcpy(name, data+4, 3) |
Copy 'a','b','c' into name. Reads indices 4,5,6 — all < size. |
name = "abc?" |
| 6 | name[3] = '\0' |
Terminate. Buffer is NAME_CAP+1 = 17 bytes, index 3 is safe. |
name = "abc" |
| 7 | return name[0] != 'X' |
'a' != 'X' is true. |
returns true |
Why the ordering matters: check 3 before 4 so size - HEADER (unsigned subtraction) can never underflow — we only reach step 4 knowing size >= HEADER. Each check removes one insecure assumption the vulnerable version made.
The harness side is trivial by design: it forwards data/size and returns 0. Because parse_record already takes a bounded buffer, the harness needs no allocation — which means it also has no cleanup to get wrong and no leak to mis-attribute. When the target does expect a C string, the harness grows to malloc(size+1) → memcpy → copy[size]='\0' → call → free(copy), and every one of those four lines is a place a careless harness introduces its own bug.
| Wrong approach | Why it's wrong | Corrected | How to recognise / prevent |
|---|---|---|---|
Passing data straight to a function that expects a char * C string |
data is not NUL-terminated and may contain embedded \0; the target reads past size |
malloc(size+1), memcpy, set copy[size]='\0', then call, then free |
ASan reports a heap-buffer-overflow read inside the target on the very first inputs |
Forgetting free() in the harness |
Leak accumulates over millions of runs; libFuzzer blames the parser for a leak the harness caused | Free every allocation before return 0 |
LeakSanitizer report whose stack points into the harness, not the parser |
Writing copy[size] into a malloc(size) buffer |
One-byte heap overflow inside the harness — a false crash | Allocate size + 1 bytes |
ASan heap-buffer-overflow on the terminator line of the harness |
Depending on globals, rand(), time(), files, or the network |
Non-deterministic: crashes don't reproduce, findings are worthless | Reset/avoid all shared state; keep the harness pure | A saved crash artifact that does not re-crash on replay |
| Fuzzing without ASan/UBSan | Only self-crashing bugs surface; silent corruption slips through | Build with -fsanitize=address,undefined,fuzzer |
Fuzzer "finds nothing" yet manual review later finds overflows |
| One giant harness for the whole program | Shallow coverage, slow, unclear reports | One small harness per parser/feature | Coverage plateaus early; crashes are hard to attribute |
| Testing against a live/third-party system "to be realistic" | Out of scope, likely unauthorized, and non-reproducible | Fuzz the isolated function on localhost only | Any target that is not code you own or are authorized to test |
Replay first, always. A crash artifact is a deterministic reproducer:
./parser-fuzz crash-<sha1>
Read the ASan report top-down: the error type (stack-buffer-overflow, heap-use-after-free, …), the operation (READ/WRITE and size), and the first frame in your code in the backtrace — that line is where to look.
Concrete steps when a run crashes:
LLVMFuzzerTestOneInput, you have a harness bug (usually the size+1 / terminator mistake). Fix that before trusting any finding../parser-fuzz -minimize_crash=1 -runs=10000 crash-<sha1>. A smaller reproducer makes the root cause obvious.gdb --args ./parser-fuzz crash-<sha1>, then run; ASan will stop at the fault so you can inspect len, size, and the buffer.undefined too.ASAN_OPTIONS=detect_leaks=1 ./parser-fuzz crash-<sha1> to distinguish a real leak from a normal exit.Questions to ask when it fails:
size for the crashing input? Is it below the header size I assumed?This is a C, memory-safety-critical topic, so both angles apply.
Memory / UB safety of the harness and target:
size + 1 when you need a NUL-terminated copy; write the terminator at index size, never size into a size-byte buffer.data[i] without first proving i < size. Order your checks so unsigned subtractions like size - HEADER can't underflow.free exactly once, and only what you allocated; set no dangling pointer, reuse no freed buffer. libFuzzer restarts the process on crash, but leaks accumulate across the millions of in-process runs.(ptr, len) — fewer lines, fewer harness-owned bugs.size == 0: malloc(1) is fine, but calling the target with an empty buffer must be safe; add an early return 0 if the target can't accept it.Security & safety — detection and logging (for the software you are hardening, not the fuzzer): Fuzzing finds the bug in the lab; in production you also want to detect the hostile input that reaches the deployed parser. When the secure parser rejects input, log a structured security event:
too_short, len_exceeds_cap, body_truncated), a correlation id to tie it to the request, and the security decision made.too_short / len_exceeds_cap rejections from one source, or many distinct malformed shapes in a short window, suggests someone is probing the parser — the same behaviour your fuzzer produces, now coming from a real client.Correcting a common misconception: a fuzz run that finds no crash in 5 minutes does not prove the parser is secure. Coverage may be shallow, the corpus weak, or the bug behind a check the fuzzer never satisfied. "No crash yet" means "not disproven yet," never "completely secure."
Authorized real-world use case. You maintain an internal service that parses an uploaded config file. Before release you write one libFuzzer harness around the config parser, seed it with a handful of valid config samples, and run it in CI for five minutes per commit with ASan + UBSan. Week two, it finds a heap-buffer-overflow on a malformed length field. You fix it, add the crashing input to a regression corpus, and re-run to confirm the fix — a bug that would otherwise have been a production crash or a memory-corruption vulnerability is closed in the lab.
Where this happens professionally:
Best-practice habits (beginner):
-fsanitize=address,undefined,fuzzer.size+1 for string copies.Best-practice habits (advanced):
FuzzedDataProvider-style splitter) so bytes map cleanly to fields.All tasks are lab-only: build and run on your own machine / container with -fsanitize=address,undefined,fuzzer. Fuzz only code you wrote or are authorized to test.
Authorization checklist before you start any task: (1) the target is code you own or are explicitly permitted to fuzz; (2) it runs on localhost / a container / an isolated VM with no network target; (3) you have somewhere to store crash artifacts privately; (4) you know the cleanup step below.
Beginner 1 — Wrap a validator.
LLVMFuzzerTestOneInput around a given int valid_username(const char *s).size == 0.size + 1.malloc(size+1), memcpy, copy[size]='\0'. Check malloc for NULL.Beginner 2 — Build and run with sanitizers.
clang -g -O1 -fsanitize=address,undefined,fuzzer …; run with -max_total_time=60.crash-<sha1> artifact.Intermediate 1 — Triage and remediate a crash.
parse_record from the lesson (built with -DUSE_VULNERABLE), reproduce the crash and fix it.-minimize_crash=1 to shrink the input; look for READ vs WRITE and the value of size.Intermediate 2 — Prove the fix with a verifier.
verify.c (no fuzzer) that asserts the secure parser rejects three bad inputs and accepts one good input.all mitigation checks passed.Challenge — Structured, deterministic harness with a regression corpus.
corpus/regressions/ directory and re-run against it as a fast, crash-free replay.Lab cleanup / reset: stop the fuzzer, move or delete crash-* and leak-* artifacts into your private findings folder (do not commit them to a shared/public repo), remove the parser-fuzz binary if you rebuild, and clear any temp corpus you don't intend to keep.
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) that hands one parser to the fuzzer, which generates and mutates millions of inputs under coverage guidance.data are not a C string: copy into a size + 1 buffer and NUL-terminate only when the target needs a string; keep the harness deterministic and leak-free so bugs aren't mis-attributed.-fsanitize=address,undefined,fuzzer; sanitizers turn silent corruption into reproducible crashes, and a crash-