Mutation-Based Fuzzing: From Commodore 64 POKEs to Drone Firmware
1. Mutation testing is not mutation-based fuzzing
The two terms are often used interchangeably, but they test different things. Everything in this article is the second one.
| Mutation testing | Mutation-based fuzzing | |
|---|---|---|
| What gets mutated | The program’s source code (e.g. swapping > for <) | The program’s input data (flipped bits, altered bytes, truncated packets) |
| Question answered | “Would my test suite notice this bug?” | “Does the program survive hostile or corrupted input?” |
| Typical output | A mutation score for the test suite | Crashes, hangs, sanitizer reports, and the inputs that caused them |
2. A first mutator: text telemetry
Ground-control software and mobile telemetry apps often parse simple text frames such as GPS:37.123,-122.456.
The smallest useful mutator changes one character at a time:
import random import string def mutate_string(data: str, rng: random.Random) -> str: """Replace one character at a random position with a random printable character.""" if not data: return data idx = rng.randrange(len(data)) return data[:idx] + rng.choice(string.printable) + data[idx + 1:] rng = random.Random(1337) # fixed seed: every run produces the same cases telemetry_packet = "GPS:37.123,-122.456" for _ in range(5): print(repr(mutate_string(telemetry_packet, rng)))
'GPS:37.123,-122.4{6'
'GPS:37.123,,122.456'
'GPS:37.123,-122.45~'
'GPS:3\x0c.123,-122.456'
'GPS:37.123N-122.456'
| Component | Mechanism | Why it matters for telemetry |
|---|---|---|
| Random index | Picks one character position to change. | Models localized corruption in transit, such as one altered digit in a latitude. |
| Printable replacement | Inserts an unexpected character, including control characters like \x0c. | Checks that numeric, delimiter, and header parsing rejects bad symbols instead of throwing an unhandled exception. |
| Seeded generator | random.Random(1337) produces the same sequence on every run. | Any failing case can be reproduced exactly, which is what makes a fuzz finding actionable. |
Notice the second mutant: GPS:37.123,,122.456. The longitude lost its minus sign and gained an empty field.
A parser that silently accepts it could place a waypoint in the wrong hemisphere — exactly the class of bug fuzzing exists to find.
3. Binary telemetry: MAVLink frames and the checksum problem
Real flight links carry binary protocols. A MAVLink v2 frame is a 10-byte header (magic 0xFD,
payload length, incompatibility and compatibility flags, sequence, system ID, component ID, and a 24-bit message ID),
the payload, and a 16-bit checksum. The checksum is what makes naive fuzzing ineffective: almost every random mutation
breaks it, and the receiver drops the frame before the interesting parsing code ever runs.
The mutator below starts from a valid HEARTBEAT frame, applies one binary-aware mutation, then re-signs the frame so the corrupted content actually reaches the message decoder.
import random import struct def x25_crc(data: bytes, crc: int = 0xFFFF) -> int: """CRC-16/MCRF4XX, the checksum MAVLink uses.""" for b in data: tmp = b ^ (crc & 0xFF) tmp = (tmp ^ (tmp << 4)) & 0xFF crc = ((crc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4)) & 0xFFFF return crc def heartbeat(seq: int = 0, sysid: int = 1, compid: int = 1) -> bytes: """A valid MAVLink v2 HEARTBEAT frame (message id 0, CRC_EXTRA 50).""" # custom_mode u32, type u8 (2 = quadrotor), autopilot u8 (12 = PX4), # base_mode u8, system_status u8 (4 = active), mavlink_version u8 payload = struct.pack("<IBBBBB", 0, 2, 12, 0, 4, 3) header = struct.pack("<BBBBBBB", 0xFD, len(payload), 0, 0, seq, sysid, compid) header += (0).to_bytes(3, "little") # 24-bit message id crc = x25_crc(bytes([50]), x25_crc(header[1:] + payload)) return header + payload + struct.pack("<H", crc) def mutate_binary_packet(packet: bytes, rng: random.Random) -> bytes: """Bit flip, boundary-integer overwrite, byte swap, or truncation.""" while True: data = bytearray(packet) kind = rng.choice(["bit_flip", "boundary_int", "byte_swap", "truncate"]) if kind == "bit_flip": data[rng.randrange(len(data))] ^= 1 << rng.randrange(8) elif kind == "boundary_int": idx = rng.randrange(len(data) - 3) value = rng.choice([0x00000000, 0x7FFFFFFF, 0x80000000, 0xFFFFFFFF]) data[idx:idx + 4] = struct.pack("<I", value) elif kind == "byte_swap": i, j = rng.sample(range(len(data)), 2) data[i], data[j] = data[j], data[i] else: data = data[:rng.randrange(1, len(data))] if bytes(data) != packet: # discard no-op mutations return bytes(data) def fix_crc(packet: bytes, crc_extra: int = 50) -> bytes: """Re-sign the frame so the mutation reaches the parser instead of the CRC check.""" if len(packet) < 12: return packet crc = x25_crc(bytes([crc_extra]), x25_crc(packet[1:-2])) return packet[:-2] + struct.pack("<H", crc) rng = random.Random(1337) seed = heartbeat() print("seed ", seed.hex(" ")) for _ in range(3): print("mutant ", fix_crc(mutate_binary_packet(seed, rng)).hex(" "))
seed fd 09 00 00 00 01 01 00 00 00 00 00 00 00 02 0c 00 04 03 b6 bd mutant fd 09 00 00 00 01 01 00 00 00 00 00 00 00 02 0c 00 04 bd c8 1d mutant fd 09 00 00 00 01 01 00 00 00 ff ff ff ff 02 0c 00 04 03 bd 5d mutant fd 09 00 00 00 01 01 00 00 00 00 00 00 00 00 0c 00 04 03 e0 b5
pymavlink library produces for the same fields. Fuzzing both ways is useful: leave the checksum broken to test
frame rejection, and repair it to test everything behind the checksum.
The four mutation types mirror what production fuzzers do on binary formats: bit flips model radio
noise, boundary integers (0x7FFFFFFF, 0x80000000, 0xFFFFFFFF) hunt
for signed/unsigned and overflow bugs, byte swaps test field-order assumptions, and truncation
tests length handling — the classic source of out-of-bounds reads.
4. Fuzzing binaries without source code
Mutating input is the easy half. Modern fuzzers are coverage-guided: they keep mutants that reach new code and discard the rest. Without source code, that coverage has to be recovered from the running binary.
| Technique | How coverage is obtained | Examples |
|---|---|---|
| Dynamic binary translation | The binary runs inside an emulator that records basic-block transitions as it translates code. | AFL++ QEMU mode, AFL++ Unicorn mode |
| Dynamic binary instrumentation | A runtime engine is injected into the process to trace blocks and edges. | AFL++ Frida mode, WinAFL (DynamoRIO) |
| Hardware tracing | The CPU records branches itself (Intel Processor Trace), with low overhead. | kAFL, Nyx, Honggfuzz |
| Static rewriting | Instrumentation is patched into the binary before it runs, avoiding emulator overhead. | e9patch / e9afl, ZAFL, afl-dyninst |
Speed comes from not restarting the process. Launching a fresh process for every test case is slow; AFL’s fork server removes most of that cost, and persistent mode goes further by looping over the target function inside one process and resetting its state between iterations. The AFL++ documentation cites roughly a 10–20× speedup from persistent mode.
Structure-aware mutation is the other big lever. For formats such as Protobuf, TLV, or JSON, custom mutators
(for example libprotobuf-mutator or AFL++ custom mutators) keep the outer structure valid while corrupting the contents —
the same idea as repairing the MAVLink checksum above.
5. Where the idea started: the Commodore 64
Anyone who spent time in a machine-language monitor on a Commodore 64 has done manual fuzzing. POKE wrote an
arbitrary byte into memory, SYS jumped into machine code, and the result was either the expected effect or a
screen full of garbage and a locked machine.
| Commodore 64 (MOS 6510) | Modern fuzzing equivalent |
|---|---|
POKE address, value — write any byte to RAM or an I/O register (POKE 53280,0 turns the border black) | The mutation engine: byte overwrites, bit flips, boundary values |
SYS address — jump straight into machine code | The harness: calling a target function directly with a controlled input |
A machine-language monitor (SuperMon, the Final Cartridge) — inspect A, X, Y, PC, SP, set breakpoints | Instrumentation and debugging: GDB, Frida, QEMU tracing |
| Lock-ups, garbage on screen, reaching for RUN/STOP + RESTORE or the power switch | Crash triage: SIGSEGV, hangs, sanitizer reports |
Two things changed. The 6510 had no memory protection, so a bad POKE could corrupt anything, including the VIC-II
video registers at $D000. Modern systems isolate each process, so a fuzzer can crash its target thousands of times a
second without taking itself down — and it records the exact input and machine state behind every crash instead of relying
on a human to remember what was typed.
6. Drone firmware: when a bug does not crash
On a server, a parser bug usually ends in a crash. On a drone, the worst bugs leave the software running and do the wrong thing in the physical world: a bad mode transition, a failsafe that fires late, a motor command at the wrong time. Drone fuzzing therefore needs oracles that understand flight behavior, not just crash detection.
The two main open-source autopilots are PX4, a modular stack built around the uORB publish/subscribe
message bus and typically running on the NuttX RTOS, and ArduPilot (Copter, Plane, Rover, Sub), built on its
AP_HAL hardware abstraction layer and running on ChibiOS for most flight controllers. Both can also be compiled to
run on Linux or macOS as SITL (Software-in-the-Loop), which is what makes large-scale fuzzing practical.
The three layers worth testing
| Layer | What is mutated | What you are looking for |
|---|---|---|
| Protocols & parsers | MAVLink, DroneCAN, MSP, GNSS NMEA/UBX frames before they reach the serial or network interface | Buffer overflows, out-of-bounds reads, and lock-ups from malformed lengths and truncated payloads |
| Parameters & state machine | Out-of-order commands and boundary values in configuration parameters | Invalid transitions — disarming in flight, ignoring altitude limits, failsafes that never trigger |
| Environment (in simulation) | Sensor noise, IMU drift, barometer jumps, GPS errors, obstacle placement | Estimator (EKF) failures and unsafe flight that the software does not detect |
Research tools
| Tool | Target | Approach |
|---|---|---|
| PGFuzz (Purdue, NDSS 2021) | PX4, ArduPilot, Paparazzi | Policy-guided fuzzing: safety policies written in metric temporal logic steer mutation of inputs, parameters, and environment toward policy violations. |
| RoboFuzz (Georgia Tech, ESEC/FSE 2022) | ROS 2–based robots, including a PX4 quadcopter | Type-aware mutation of ROS messages with correctness oracles for physical-law violations, specification violations, and cyber-physical discrepancies. |
| AFL++ / LibAFL + SITL | Protocol parsers and decoders | Coverage-guided fuzzing of autopilot code compiled for SITL with AddressSanitizer. |
| CAMBA, DeepHyperion-UAV (SBFT 2024 UAV tool competition) | PX4 obstacle avoidance | Search-based generation of obstacle placements that force the drone to fly unsafely close to obstacles. |
Running an ArduPilot SITL target
No hardware is needed. ArduPilot’s build system has a documented AddressSanitizer option for SITL builds:
# 1. Build ArduCopter for SITL with clang's AddressSanitizer cd ardupilot CC=clang CXX=clang++ ./waf configure --board sitl --asan ./waf copter # 2. Start the simulated vehicle; its primary MAVLink port listens on TCP 5760 Tools/autotest/sim_vehicle.py -v ArduCopter # 3. Point your own mutator (e.g. the Section 3 code, sent with pymavlink) at tcp:127.0.0.1:5760
Port 14550 is where MAVProxy forwards telemetry for ground-control software; to inject mutated frames into the
vehicle itself, connect to the SITL port directly.
7. Choosing a toolchain
| Target | Good starting point |
|---|---|
| Linux x86/ARM binary, no source | AFL++ in QEMU or Frida mode |
| Library you can link against | libFuzzer or LibAFL harness (build your own fuzzer from LibAFL’s mutators, observers, and feedbacks) |
Raw Cortex-M firmware (.bin / .hex) | Fuzzware (automatic MMIO modeling) or Hoedur (separate input streams per peripheral); Renode for full-board emulation; Avatar² to bridge an emulator and real hardware |
| Kernels, hypervisors, full RTOS images | kAFL or Nyx (snapshot-based, Intel PT) |
| Network and serial protocols | Boofuzz for stateful protocol fuzzing; Scapy for crafting and mangling packets |
| Drone autopilot behavior | SITL plus PGFuzz- or RoboFuzz-style oracles |
| Radio and over-the-air links | A software-defined radio (HackRF, USRP) with GNU Radio — only on equipment and spectrum you are authorized to test |
8. What good looks like
For a ground-control station or telemetry app, a parser that has survived serious fuzzing should:
- Reject malformed input without crashing — including on the UI thread of a mobile app.
- Flag or discard corrupted coordinates instead of turning them into waypoints or map offsets.
- Fail predictably, keeping the link, the telemetry log, and the operator’s situational awareness intact.
References
Books
- Zeller, Gopinath, Böhme, Fraser, Holler — The Fuzzing Book (free, interactive).
- Dowd, McDonald, Schuh — The Art of Software Security Assessment (Addison-Wesley, 2006).
Papers
- Zalewski — AFL technical whitepaper.
- Fioraldi, Maier, Eißfeldt, Heuse — AFL++: Combining Incremental Steps of Fuzzing Research (WOOT 2020).
- Nagy, Hicks — Full-speed Fuzzing: Reducing Fuzzing Overhead through Coverage-Guided Tracing (IEEE S&P 2019).
- Schumilo et al. — kAFL (USENIX Security 2017) and Nyx (USENIX Security 2021).
- Scharnowski et al. — Fuzzware (USENIX Security 2022) and Hoedur (USENIX Security 2023).
- Kim et al. — PGFuzz: Policy-Guided Fuzzing for Robotic Vehicles (NDSS 2021).
- Kim, Kim — RoboFuzz (ESEC/FSE 2022).
- Khatiri et al. — SBFT Tool Competition 2024 — CPS-UAV Test Case Generation Track.
Tools and documentation