Konstas.Engineering Notes
Networking · Cybersecurity · Infrastructure
Offensive Security

Mutation-Based Fuzzing: From Commodore 64 POKEs to Drone Firmware

Articles · September 2026 · Ioannis (John) Konstas

Summary. Mutation-based fuzzing takes a known-good input, corrupts it in small, controlled ways, and feeds the result to a program to see whether it fails safely. This note builds two working mutators in Python — one for text telemetry and one for checksum-valid MAVLink v2 frames — then explains how binary fuzzers get coverage feedback without source code, how the idea maps back to 8-bit machine-language hacking, and which tools fit which targets, from Cortex-M firmware to full PX4 and ArduPilot autopilots.

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 testingMutation-based fuzzing
What gets mutatedThe 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 outputA mutation score for the test suiteCrashes, 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:

Python — single-character mutation
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)))
Output
'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'
ComponentMechanismWhy it matters for telemetry
Random indexPicks one character position to change.Models localized corruption in transit, such as one altered digit in a latitude.
Printable replacementInserts 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 generatorrandom.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.

Python — MAVLink v2 mutator with checksum repair
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(" "))
Output
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
Verified. The seed frame above is byte-for-byte identical to the HEARTBEAT that the official 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.

TechniqueHow coverage is obtainedExamples
Dynamic binary translationThe binary runs inside an emulator that records basic-block transitions as it translates code.AFL++ QEMU mode, AFL++ Unicorn mode
Dynamic binary instrumentationA runtime engine is injected into the process to trace blocks and edges.AFL++ Frida mode, WinAFL (DynamoRIO)
Hardware tracingThe CPU records branches itself (Intel Processor Trace), with low overhead.kAFL, Nyx, Honggfuzz
Static rewritingInstrumentation 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 codeThe 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 breakpointsInstrumentation and debugging: GDB, Frida, QEMU tracing
Lock-ups, garbage on screen, reaching for RUN/STOP + RESTORE or the power switchCrash 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

LayerWhat is mutatedWhat you are looking for
Protocols & parsersMAVLink, DroneCAN, MSP, GNSS NMEA/UBX frames before they reach the serial or network interfaceBuffer overflows, out-of-bounds reads, and lock-ups from malformed lengths and truncated payloads
Parameters & state machineOut-of-order commands and boundary values in configuration parametersInvalid transitions — disarming in flight, ignoring altitude limits, failsafes that never trigger
Environment (in simulation)Sensor noise, IMU drift, barometer jumps, GPS errors, obstacle placementEstimator (EKF) failures and unsafe flight that the software does not detect

Research tools

ToolTargetApproach
PGFuzz (Purdue, NDSS 2021)PX4, ArduPilot, PaparazziPolicy-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 quadcopterType-aware mutation of ROS messages with correctness oracles for physical-law violations, specification violations, and cyber-physical discrepancies.
AFL++ / LibAFL + SITLProtocol parsers and decodersCoverage-guided fuzzing of autopilot code compiled for SITL with AddressSanitizer.
CAMBA, DeepHyperion-UAV (SBFT 2024 UAV tool competition)PX4 obstacle avoidanceSearch-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:

Shell — ArduPilot SITL with AddressSanitizer
# 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

TargetGood starting point
Linux x86/ARM binary, no sourceAFL++ in QEMU or Frida mode
Library you can link againstlibFuzzer 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 imageskAFL or Nyx (snapshot-based, Intel PT)
Network and serial protocolsBoofuzz for stateful protocol fuzzing; Scapy for crafting and mangling packets
Drone autopilot behaviorSITL plus PGFuzz- or RoboFuzz-style oracles
Radio and over-the-air linksA 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:

References

Books

Papers

Tools and documentation

Research and direction by Ioannis (John) Konstas; compiled and finalized with AI assistance.

← All articles

👁· views