Fuzzing Embedded Firmware: AFL++, libFuzzer, and Protocol Parser Testing
```tldr Embedded firmware fuzzing runs protocol parsers and data handlers on the host (not on-target) with AFL++ or libFuzzer providing random mutated inputs. Vulnerabilities found: buffer overflows, integer overflows, null dereferences, and infinite loops in parsing code — all without a physical device. ```
```takeaways Fuzz the parser, not the hardware. Extract parsing logic into a host-compilable function. Fuzzing runs thousands of inputs per second on a standard laptop.
Write a thin harness that feeds fuzzer input to the target function. The harness is usually 10-20 lines — it calls your parser with data and size from the fuzzer.
Use AddressSanitizer (ASAN) to catch memory bugs. Compile with -fsanitize=address,undefined — this turns silent corruption into immediate crashes that the fuzzer can report.
Start with a corpus of valid inputs. Give the fuzzer real protocol packets as seeds. It mutates from there — covering edge cases you wouldn't write manually.
Differential fuzzing catches regressions. Run the same input through old and new firmware, assert identical output — any divergence is a bug. ```
What to Fuzz in Firmware
Good fuzzing targets in embedded code:
- Protocol parsers — MQTT, CoAP, Modbus, custom binary protocols
- File format parsers — firmware update packages, configuration files
- Command parsers — AT commands, serial command handlers
- Crypto/compression — custom implementations of TLS handshakes, DEFLATE
- CAN frame handlers — parsing variable-length CAN frames
Bad fuzzing targets (don't bother):
- Code that directly drives hardware registers (GPIO, timers)
- Code that requires real-time scheduling guarantees
- Interrupt service routines
Extracting Parseable Code
Before fuzzing, isolate the parser from hardware:
// Before: tightly coupled to hardware
void uart_receive_handler(UART_HandleTypeDef *huart) {
uint8_t byte = huart->Instance->DR;
packet_buffer[packet_len++] = byte;
if (byte == '\n') {
parse_command(packet_buffer, packet_len);
packet_len = 0;
}
}
// After: parser isolated and fuzzable
typedef struct {
const uint8_t *data;
size_t len;
} ParseResult;
ParseResult parse_command(const uint8_t *buf, size_t len) {
// All parsing logic here — no hardware dependencies
if (len == 0 || buf == NULL) {
return (ParseResult){ .data = NULL, .len = 0 };
}
// ... parse AT commands, return result
}libFuzzer Harness
// fuzz_parse_command.c
#include <stdint.h>
#include <stddef.h>
#include "command_parser.h"
// libFuzzer entry point
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// Feed fuzzer input directly to parser
ParseResult result = parse_command(data, size);
// Optional: assert invariants that should always hold
if (result.data != NULL) {
// If parser returned data, it must be within bounds
assert(result.len <= MAX_RESPONSE_SIZE);
}
// Return 0 — any crash is a bug
return 0;
}Compile and run:
# Compile with libFuzzer and sanitizers
clang -g -O1 \
-fsanitize=fuzzer,address,undefined \
-DFUZZ_BUILD \
fuzz_parse_command.c command_parser.c \
-o fuzz_parse
# Create corpus from real packets
mkdir corpus
cp real_packets/*.bin corpus/
# Run fuzzer
./fuzz_parse corpus/ -max_total_time=300 -workers=4
# Run until crash
./fuzz_parse corpus/ -runs=-1AFL++ Harness
// afl_harness.c
#include <stdio.h>
#include <stdlib.h>
#include "command_parser.h"
int main(int argc, char *argv[]) {
// Read from file (AFL feeds input via stdin or file)
FILE *f = stdin;
if (argc > 1) {
f = fopen(argv[1], "rb");
if (!f) return 1;
}
uint8_t buf[MAX_PACKET_SIZE];
size_t len = fread(buf, 1, sizeof(buf), f);
if (argc > 1) fclose(f);
if (len == 0) return 0;
parse_command(buf, len);
return 0;
}# Build with AFL++ instrumentation
AFL_USE_ASAN=1 afl-clang-fast -g -O1 \
afl_harness.c command_parser.c \
-o fuzz_afl
# Run AFL++
mkdir afl_in afl_out
cp seed_packet.bin afl_in/
afl-fuzz -i afl_in -o afl_out -- ./fuzz_afl @@Fuzzing a Binary Protocol Parser
Example: custom sensor protocol
+--------+--------+------------------+----------+
| MAGIC | LENGTH | PAYLOAD (N bytes)| CHECKSUM |
| 0xAB | 1 byte | 0-255 bytes | 1 byte |
+--------+--------+------------------+----------+// sensor_protocol.h
typedef struct {
uint8_t sensor_id;
int16_t temperature; // in 0.1°C units
uint8_t status;
} SensorReading;
typedef enum {
PARSE_OK,
PARSE_INVALID_MAGIC,
PARSE_CHECKSUM_FAIL,
PARSE_TRUNCATED,
} ParseStatus;
ParseStatus parse_sensor_packet(const uint8_t *buf, size_t len, SensorReading *out);// sensor_protocol.c
ParseStatus parse_sensor_packet(const uint8_t *buf, size_t len, SensorReading *out) {
if (len < 4) return PARSE_TRUNCATED;
if (buf[0] != 0xAB) return PARSE_INVALID_MAGIC;
uint8_t payload_len = buf[1];
if (len < (size_t)(payload_len + 3)) return PARSE_TRUNCATED; // BUG: off-by-one possible
uint8_t checksum = 0;
for (size_t i = 0; i < payload_len + 2; i++) {
checksum ^= buf[i];
}
if (checksum != buf[payload_len + 2]) return PARSE_CHECKSUM_FAIL;
if (payload_len >= 4 && out != NULL) {
out->sensor_id = buf[2];
out->temperature = (int16_t)((buf[3] << 8) | buf[4]); // BUG: buf[4] might OOB
out->status = buf[5];
}
return PARSE_OK;
}// fuzz_sensor_protocol.c
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
SensorReading reading = {0};
parse_sensor_packet(data, size, &reading);
return 0;
}AFL++ will find the off-by-one and the out-of-bounds read within minutes.
Corpus Building
# Create seeds from real traffic capture
tshark -r capture.pcap -Y "custom_protocol" -T fields -e data.data | \
xxd -r -p | split -b 512 - corpus/packet_
# Or generate minimum seeds manually
printf '\xAB\x04\x01\x00\x64\x00\xFF' > corpus/valid_reading.bin
printf '\xAB\x00\xFF' > corpus/edge_empty_payload.bin
printf '\xAB' > corpus/edge_truncated.binA good seed corpus covers:
- Valid packets (all fields present)
- Minimum valid packet
- Packets with maximum-length payload
- Known edge cases (zero-length payload, max sensor id, negative temperature)
Crash Triage
When the fuzzer finds a crash:
# Reproduce the crash
./fuzz_parse crash-input-file
# Get stack trace with ASAN
ASAN_OPTIONS=symbolize=1 \
ASAN_SYMBOLIZER_PATH=$(which llvm-symbolizer) \
./fuzz_parse crash-input-file 2>&1 | head -50Typical output:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 1 at 0x602000001234 thread T0
#0 0x401abc in parse_sensor_packet sensor_protocol.c:23
#1 0x401def in LLVMFuzzerTestOneInput fuzz_sensor_protocol.c:5
#2 0x402111 in fuzzer::Fuzzer::ExecuteCallbackFile the bug with the crash input:
# Minimize the crash to smallest reproducer
./fuzz_parse -minimize_crash=1 -exact_artifact_path=minimal_crash.bin crash-input-fileCI Integration
# .github/workflows/fuzz.yml
name: Fuzz Tests
on:
schedule:
- cron: '0 2 * * *' # nightly
workflow_dispatch:
inputs:
fuzz_duration:
default: '300'
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install clang
run: sudo apt-get install -y clang llvm
- name: Build fuzzer
run: |
clang -g -O1 -fsanitize=fuzzer,address,undefined \
fuzz/fuzz_sensor_protocol.c src/sensor_protocol.c \
-Iinclude -o fuzz_sensor
- name: Run fuzzer
run: |
./fuzz_sensor corpus/ \
-max_total_time=${{ inputs.fuzz_duration || 300 }} \
-artifact_prefix=crashes/
- name: Upload crashes
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: crashes/Differential Fuzzing for Regression Testing
// Run same input through v1 and v2 parser, assert identical behavior
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
SensorReading r1 = {0}, r2 = {0};
ParseStatus s1 = parse_sensor_packet_v1(data, size, &r1);
ParseStatus s2 = parse_sensor_packet_v2(data, size, &r2);
// If both succeed, output must match
if (s1 == PARSE_OK && s2 == PARSE_OK) {
assert(r1.sensor_id == r2.sensor_id);
assert(r1.temperature == r2.temperature);
assert(r1.status == r2.status);
}
// Error codes don't need to match (v2 might add new codes)
return 0;
}Any divergence is caught immediately — no manual test case needed.
Common Fuzzing Targets in Embedded Systems
| Target | What to fuzz | Key bugs found |
|---|---|---|
| MQTT packet parser | Complete packet bytes | Buffer overflow on malformed topic length |
| AT command parser | Command string bytes | Format string injection, stack overflow |
| Firmware update parser | OTA binary header | Integer overflow in length field |
| CAN frame handler | 8-byte CAN data | Signed/unsigned integer confusion |
| Modbus RTU parser | PDU bytes | Off-by-one in register address range |
| CoAP option parser | Option TLV bytes | OOB read in delta encoding |