Fuzzing Embedded Firmware: AFL++, libFuzzer, and Protocol Parser Testing

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=-1

AFL++ 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.bin

A 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 -50

Typical 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::ExecuteCallback

File 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-file

CI 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
Start now free