C / UNDEFINED BEHAVIOUR AND DEFENSIVE C
Fuzzing and sanitizers in a testing workflow
Set up an ASan/UBSan build, write a libFuzzer entry point for a parser, and turn each crashing input into a committed regression test.
What you will learn
- Build a separate -fsanitize=address,undefined target instead of fuzzing a release build
- Write a fuzz entry point that is deterministic and keeps no state between inputs
- Explain why a no-crash fuzzing run proves nothing without instrumentation
- Turn every crash file into a replayable regression test in CI
Understanding Fuzzing and sanitizers in a testing workflow
Fuzzing is two separate things bolted together: something that generates inputs, and something that decides an input was bad. C gives you almost nothing for the second half, because reading one byte past a malloc'd block normally returns whatever the allocator happened to leave there, and the program carries on and exits 0. That is how an uninstrumented run can execute forty million inputs, report no crashes, and still walk off the end of every buffer. Sanitizers supply the missing half: ASan surrounds each allocation with poisoned redzones and checks every load and store against a shadow map, while UBSan inserts a test in front of operations the standard leaves undefined.
Modern fuzzers do not guess blindly. -fsanitize=fuzzer, like AFL++'s compiler wrappers, instruments every branch with coverage counters, and the fuzzer keeps an input in its corpus when that input reaches an edge nothing else reached, then mutates that input further. The mental model is hill climbing over the control-flow graph rather than random sampling of byte strings. That is why the harness must be deterministic, single threaded, fast, and free of state carried between calls: if the same bytes take a different path twice, the feedback signal is noise and the crash will not reproduce.
A sanitizer is a build configuration, not a command you run, so the practical unit of work is a second build directory. ASan roughly doubles CPU time and greatly increases resident memory, so it belongs in the test and fuzz builds and never in what you ship. Each finding is just a file of bytes, which makes the loop cheap to close: the fuzzer writes crash-<hash>, you replay that one file under the sanitizer build to get a stack trace, and the file then lives in the repository as a regression test that runs in milliseconds.
/* parser.c
* test build : cc -g -Wall -Wextra -fsanitize=address,undefined -fno-sanitize-recover=undefined parser.c && ./a.out
* fuzz build : clang -g -O1 -DFUZZING -fsanitize=fuzzer,address,undefined parser.c -o fuzz && ./fuzz corpus/
*/
<stdint.h>
<stddef.h>
<stdio.h>
struct msg { uint8_t tag; const uint8_t *payload; size_t len; };
/* wire format: [tag][len][len bytes of payload] */
static int parse_msg(const uint8_t *b, size_t n, struct msg *out)
{
if (n < 2) return -1;
out->tag = b[0];
out->len = b[1]; /* the length is trusted: this is the bug */
out->payload = b + 2;
return 0;
}
static unsigned checksum(const struct msg *m)
{
unsigned s = 0;
for (size_t i = 0; i < m->len; i++) s += m->payload[i];
return s;
}
/* one input in, no globals, nothing left behind: the fuzz entry point */
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
struct msg m;
if (parse_msg(data, size, &m) != 0) return 0;
FUZZING
static volatile unsigned sink;
sink = checksum(&m); /* volatile store: keeps the read alive at -O1 */
if (m.len > size - 2) { /* size >= 2 here, so this cannot wrap */
printf("would read %zu bytes past the end of a %zu byte input\n",
m.len - (size - 2), size);
return 0;
}
printf("tag=%u len=%zu checksum=%u\n",
(unsigned)m.tag, m.len, checksum(&m));
return 0;
}
FUZZING
int main(void)
{
const uint8_t good[] = { 0x01, 0x03, 'a', 'b', 'c' };
const uint8_t bad[] = { 0x01, 0x03, 'a' };
LLVMFuzzerTestOneInput(good, sizeof good);
LLVMFuzzerTestOneInput(bad, sizeof bad);
return 0;
}
A fuzzer only reports bugs the binary is instrumented to notice, so the sanitizer, not the input generator, is what makes fuzzing find memory errors.
Worked examples
A mutator small enough to read
Every single-bit flip of one seed input, fed to an explicit oracle, showing what a fuzzer's inner loop actually does.
<stdio.h>
<stdint.h>
<string.h>
/* the oracle: does the claimed payload length exceed the bytes present? */
static int violates(const uint8_t *b, size_t n)
{
if (n < 2) return 0;
return (size_t)b[1] > n - 2;
}
int main(void)
{
const uint8_t seed[3] = { 0x01, 0x01, 'a' };
uint8_t buf[3];
unsigned tried = 0;
for (size_t byte = 0; byte < sizeof seed; byte++) {
for (unsigned bit = 0; bit < 8; bit++) {
memcpy(buf, seed, sizeof buf);
buf[byte] ^= (uint8_t)(1u << bit);
tried++;
if (violates(buf, sizeof buf)) {
printf("input %u: flip byte %zu bit %u -> len=%u, available=%zu\n",
tried, byte, bit, (unsigned)buf[1], sizeof buf - 2);
return 0;
}
}
}
printf("%u mutations, nothing found\n", tried);
return 0;
}Example explained
Line 1memcpy restores the seed every round, so each candidate differs from it by exactly one bit instead of accumulating damage.
Line 2violates() is a hand-written oracle; in a real harness ASan plays that role and this function disappears from your code.
Line 3Attempt 9 sets the length byte to 0, which is legal, so the search continues: most mutations are uninteresting, and coverage feedback is what stops a real fuzzer wasting an hour on them.
Line 4The bug falls out on attempt 10 only because the length field sits two bytes into the format; a field behind a magic header or checksum is unreachable by blind flipping, which is why you seed a corpus.
Redzones by hand
Reimplements what ASan does around an allocation, and shows why a magic-byte pattern catches writes but not the read overflow a fuzzer usually finds first.
<stdio.h>
<stdint.h>
<stdlib.h>
<string.h>
REDZONE
POISON
/* reads b[1] payload bytes without checking them against n */
static unsigned sum_payload(const uint8_t *b, size_t n)
{
unsigned s = 0;
size_t claimed;
if (n < 2) return 0;
claimed = b[1];
for (size_t i = 0; i < claimed; i++) s += b[2 + i];
return s;
}
int main(void)
{
const uint8_t input[] = { 0x01, 0x03, 'a' }; /* len says 3, one byte present */
size_t n = sizeof input;
size_t written = 0;
unsigned s;
uint8_t *p = malloc(n + REDZONE);
if (!p) return 1;
memcpy(p, input, n);
memset(p + n, POISON, REDZONE);
s = sum_payload(p, n);
for (size_t i = 0; i < REDZONE; i++)
if (p[n + i] != POISON) written++;
printf("checksum=%u\n", s);
printf("payload bytes claimed=%u present=%zu\n", (unsigned)input[1], n - 2);
printf("redzone bytes modified: %zu\n", written);
free(p);
return 0;
}Example explained
Line 1memset paints eight bytes past the input with 0xBE, the hand-made version of the redzone ASan places after every allocation.
Line 2The loop trusts b[1]=3 while only one payload byte exists, so two poison bytes join the sum: 97 + 190 + 190 = 477.
Line 3The redzone comes back unmodified because this is a read, not a write, so a pattern check sees nothing; ASan consults shadow memory on the load itself and reports the read.
Line 4Swap malloc(n + REDZONE) for malloc(n) and build with -fsanitize=address to get heap-buffer-overflow READ of size 1, which is exactly the situation libFuzzer creates by copying each input into a fresh exact-size heap block.
The finding UBSan stays quiet about
Shows that the size_t underflow fuzzers hit constantly is not undefined behaviour, so -fsanitize=undefined never mentions it.
<stdio.h>
<stddef.h>
static size_t available(size_t n) { return n - 2; } /* header is 2 bytes */
int main(void)
{
const size_t sizes[] = { 5, 2, 1, 0 };
for (size_t i = 0; i < sizeof sizes / sizeof sizes[0]; i++) {
size_t a = available(sizes[i]);
if (a > sizes[i])
printf("size=%zu: available underflowed\n", sizes[i]);
else
printf("size=%zu: available=%zu\n", sizes[i], a);
}
return 0;
}Example explained
Line 1size_t is unsigned, so 1 - 2 is defined to wrap to SIZE_MAX rather than being undefined behaviour.
Line 2-fsanitize=undefined therefore prints nothing here, because that group only covers operations the standard leaves undefined.
Line 3The damage surfaces one step later, when memcpy(dst, src, available) is asked for SIZE_MAX bytes, so the ASan report points at the copy and not at the subtraction that caused it.
Line 4Clang's -fsanitize=unsigned-integer-overflow does flag the subtraction, but it also fires on deliberate wrapping in hashes and CRC code, so it needs suppressions before it is usable.
Important notes
-fsanitize=address and -fsanitize=memory cannot coexist in one binary, and MSan only gives usable results when every library in the process is instrumented too, so keep one build directory per sanitizer rather than trying to merge them.
At -O1 and above the compiler may delete a load whose result is unused, and the sanitizer then has nothing to check; store the value into a volatile or feed it into the return value to keep the access in the program.
Common mistakes
Fuzzing the release build: -O2 with no -fsanitize, plus -DNDEBUG deleting the asserts, gives forty million executions and zero crashes, and you conclude the parser is safe while it reads past a malloc block on nearly every input.
Copying data into a fixed uint8_t buf[4096] at the top of the entry point: overflows now land in the unused tail of that buffer instead of a redzone, so ASan reports nothing. Pass the pointer and size through unchanged.
Treating UBSan output as informational: by default it prints the diagnostic and keeps running, the process exits 0, and libFuzzer never records a crash, so the finding is buried in the log unless you build with -fno-sanitize-recover=undefined.
Try it yourself
Change, predict, then run
Take the bit-flip driver, extend the oracle so it also rejects any tag other than 1 or 2, and print every mutation that trips either check instead of stopping at the first. Count how many of the 24 flips get reported and which byte they cluster in.
Open the C workspaceCheck your understanding
You fuzz a JSON parser for an hour. The build is -O2 with no -fsanitize flags. The fuzzer reports 40 million executions, three timeouts and no crashes. What does that tell you?
- The parser is memory safe for inputs up to max_len, since 40 million executions is thorough coverage
- The three timeouts are the only real bugs; a memory error would have shown up as a crash
- Almost nothing about memory safety: without instrumentation an out-of-bounds read usually returns a neighbouring byte and the process keeps running
- Undefined behaviour is ruled out, because -O2 makes UB crash far more readily than -O0
Show answer
No crash only means the OS never had to kill the process, and a read one byte past an allocation lands in allocator slack and silently returns garbage, so the run says nothing about memory safety. Option 0 is tempting because 40 million feels exhaustive, but execution count measures throughput, not detection: the oracle was missing. Rebuild with -fsanitize=address,undefined, replay the same corpus, and findings usually appear within seconds.