Contents

BreizhCTF 2026 - Ghost Operator

Ghost Operator

  • Difficulty: Medium
  • Category: Forensic
  • Author: Lamarr

Description

Breizh Aero Survey operates a fleet of 5 mapping drones (ALPHA, BRAVO, CHARLIE, DELTA, ECHO) along the Breton coast. The aircraft communicate with the ground station via a standard aeronautical telemetry protocol.

This morning, during a routine flight, one of the drones stopped responding to commands and left its trajectory heading toward an isolated area. The network team had an active tcpdump on the drone/ground link during the incident.

The preliminary analysis reveals an unidentified network actor who exchanged messages with the fleet. The SOC suspects a takeover followed by data exfiltration.

Your mission: find what the attacker extracted.

Files:

  • ghost_operator.pcap

Solve

Step 1 — Initial reconnaissance

tshark -r ghost_operator.pcap -q -z conv,udp
                                                           |       <-      | |       ->      | |     Total     |    Relative    |   Duration   |
                                                           | Frames  Bytes | | Frames  Bytes | | Frames  Bytes |      Start     |              |
192.168.4.10:14550         <-> 192.168.4.1:14550              231 19 kB         141 10 kB         372 30 kB         0.000000000       119.0100
192.168.4.2:14550          <-> 192.168.4.10:14550              18 1,499 bytes     234 19 kB         252 21 kB         0.013000000       119.0000
192.168.4.3:14550          <-> 192.168.4.10:14550              18 1,552 bytes     231 19 kB         249 20 kB         0.016000000       119.0000
192.168.4.4:14550          <-> 192.168.4.10:14550              14 1,149 bytes     228 19 kB         242 20 kB         0.019000000       119.0000
192.168.4.5:14550          <-> 192.168.4.10:14550              14 1,151 bytes     228 19 kB         242 20 kB         0.022000000       119.0000
192.168.4.42:54321         <-> 192.168.4.1:14550               18 1,586 bytes      36 3,347 bytes      54 4,933 bytes    42.999000000        76.4000  ← suspect

192.168.4.42:54321 shows up 43 seconds into the capture and doesn’t fit the normal pattern.

Port 14550 is the default port for MAVLink, the open telemetry protocol used by ArduPilot, PX4, and most open-source autopilots. It runs over UDP with no encryption or authentication by default.

Check the first payload byte:

tshark -r ghost_operator.pcap -Y "udp" -T fields -e data | head -1 | cut -c1-2
# fd

0xFD is the MAVLink v2 magic byte. Each packet starts with this byte, followed by length, flags, sequence number, sysid (sender system ID), compid, msgid, payload, and a CRC.

/breizhctf-2026/ghost-operator/packet_mavlink_v2.jpg

Step 3 — Decode the attack sequence

We use scapy to extract UDP payloads and pymavlink to parse them. Routine telemetry messages (heartbeats, GPS, attitude) are filtered out to keep only the attack-relevant traffic.

pip install scapy pymavlink
from scapy.all import rdpcap, UDP, IP
from pymavlink import mavutil

pkts = rdpcap("ghost_operator.pcap")

ACTORS = {
    "192.168.4.42": "[Attacker]   ",
    "192.168.4.1":  "[Drone Alpha]",
}

SKIP = {"HEARTBEAT", "SYS_STATUS", "GPS_RAW_INT",
        "GLOBAL_POSITION_INT", "ATTITUDE", "STATUSTEXT"}

mav = mavutil.mavlink.MAVLink(None)
mav.robust_parsing = True

events = sorted(
    [(float(p.time), p[IP].src, bytes(p[UDP].payload))
     for p in pkts if p.haslayer(IP) and p.haslayer(UDP)
     and p[IP].src in ACTORS],
    key=lambda x: x[0]
)

for ts, src, data in events:
    for msg in mav.parse_buffer(data) or []:
        if msg.get_type() in SKIP:
            continue
        print(f"{ACTORS[src]}  {msg.get_type():<35}  sysid={msg.get_srcSystem()}  {msg.to_dict()}")

The output is raw but readable. A few MISSION_ACK and PARAM_VALUE lines appear at the top from pre-existing drone traffic — ignore them. The attack sequence starts at the first PARAM_REQUEST_LIST from the attacker.

Phase 1 — Reconnaissance (t ≈ 43s)

The attacker starts with sysid=255 and sends PARAM_REQUEST_LIST, triggering the drone to dump its 12 configuration parameters:

[Attacker]     PARAM_REQUEST_LIST                   sysid=255  {'target_system': 1, ...}
[Drone Alpha]  PARAM_VALUE                          sysid=1    {'param_id': 'SYSID_MYGCS',   'param_value': 255.0, ...}
[Drone Alpha]  PARAM_VALUE                          sysid=1    {'param_id': 'FENCE_ENABLE',  'param_value': 1.0,   ...}
[Drone Alpha]  PARAM_VALUE                          sysid=1    {'param_id': 'FS_THR_ENABLE', 'param_value': 1.0,   ...}
...

Key parameters dumped by the drone:

ParameterValueMeaning
SYSID_MYGCS255Accepts commands from any GCS (wildcard)
FENCE_ENABLE1Geofence active
FS_THR_ENABLE1Failsafe: auto return-to-launch if GCS signal lost

Phase 2 — Disabling safety systems (t ≈ 47-50s)

[Attacker]     PARAM_SET  sysid=255  {'param_id': 'FENCE_ENABLE',  'param_value': 0.0, ...}
[Drone Alpha]  PARAM_VALUE sysid=1   {'param_id': 'FENCE_ENABLE',  'param_value': 0.0, ...}  ← confirmed
[Attacker]     PARAM_SET  sysid=255  {'param_id': 'FS_THR_ENABLE', 'param_value': 0.0, ...}
[Drone Alpha]  PARAM_VALUE sysid=1   {'param_id': 'FS_THR_ENABLE', 'param_value': 0.0, ...}  ← confirmed
[Attacker]     PARAM_SET  sysid=255  {'param_id': 'SYSID_MYGCS',   'param_value': 44.0, ...}
[Drone Alpha]  PARAM_VALUE sysid=1   {'param_id': 'SYSID_MYGCS',   'param_value': 44.0, ...} ← confirmed
  • FENCE_ENABLE = 0 → the drone can now fly anywhere
  • FS_THR_ENABLE = 0 → no automatic return-to-launch if GCS signal is lost
  • SYSID_MYGCS = 44decisive move: the drone now only accepts commands from sysid=44 (the attacker). The legitimate ground station is locked out.

Phase 3 — Takeover and redirect (t ≈ 51-62s)

The attacker now sends all messages with sysid=44:

[Attacker]     SET_MODE    sysid=44  {'base_mode': 1, 'custom_mode': 4}           ← GUIDED mode
[Drone Alpha]  COMMAND_ACK sysid=1   {'command': 176, 'result': 0}                ← ACCEPTED
[Attacker]     SET_POSITION_TARGET_GLOBAL_INT  sysid=44  {'lat_int': 480375000, 'lon_int': -48503000, 'alt': 50.0, ...}
[Attacker]     MISSION_CLEAR_ALL  sysid=44
[Drone Alpha]  MISSION_ACK        sysid=1   {'type': 0}                           ← ACCEPTED
[Attacker]     MISSION_COUNT      sysid=44  {'count': 1}
[Attacker]     MISSION_ITEM_INT   sysid=44  {'command': 16, 'x': 480375000, 'y': -48503000, 'z': 50.0, ...}
[Drone Alpha]  MISSION_ACK        sysid=1   {'type': 0}                           ← ACCEPTED

Note: coordinates are stored as integers × 10⁷ — lat_int: 48037500048.0375°N, lon_int: -48503000-4.8503°Île de Sein.

GUIDED mode allows real-time waypoint navigation. Drone ALPHA is now under exclusive attacker control.

Step 4 — Extracting the exfiltrated data

After the takeover, an unusual sequence appears:

[Attacker]  DATA_TRANSMISSION_HANDSHAKE  sysid=44  {'size': 263, 'packets': 4, 'payload': 80, ...}
[Attacker]  ENCAPSULATED_DATA            sysid=44  {'seqnr': 0, 'data': [31, 139, 8, 0, ...]}
[Attacker]  ENCAPSULATED_DATA            sysid=44  {'seqnr': 1, 'data': [68, 145, 9, ...]}
[Attacker]  ENCAPSULATED_DATA            sysid=44  {'seqnr': 2, 'data': [109, 90, 215, ...]}
[Attacker]  ENCAPSULATED_DATA            sysid=44  {'seqnr': 3, 'data': [173, 183, 251, ...]}

The first two bytes of seqnr=0 are 31, 1390x1f 0x8bgzip magic number.

DATA_TRANSMISSION_HANDSHAKE + ENCAPSULATED_DATA is a MAVLink mechanism designed for image transfer (onboard camera video stream). The attacker repurposes it as a covert channel to exfiltrate data inside what looks like legitimate protocol traffic.

The HANDSHAKE announces the transfer parameters:

  • size = 263 — total data bytes
  • packets = 4 — number of ENCAPSULATED_DATA packets
  • payload = 80 — useful bytes per packet (the rest is 0xFE padding)
seqnr=0 : bytes   0..79  → 80 bytes
seqnr=1 : bytes  80..159 → 80 bytes
seqnr=2 : bytes 160..239 → 80 bytes
seqnr=3 : bytes 240..262 → 23 bytes  (263 - 240), rest is 0xFE padding

The first two bytes of the assembled stream are 0x1f 0x8b — the gzip magic number:

import gzip
from scapy.all import rdpcap, UDP, IP
from pymavlink import mavutil

pkts = rdpcap("ghost_operator.pcap")

mav = mavutil.mavlink.MAVLink(None)
mav.robust_parsing = True

handshake = None
chunks = {}

for pkt in pkts:
    if not (pkt.haslayer(IP) and pkt.haslayer(UDP)):
        continue
    if pkt[IP].src != "192.168.4.42":
        continue
    for msg in mav.parse_buffer(bytes(pkt[UDP].payload)) or []:
        if msg.get_type() == "DATA_TRANSMISSION_HANDSHAKE":
            handshake = msg
        elif msg.get_type() == "ENCAPSULATED_DATA":
            chunks[msg.seqnr] = msg.data

raw = b""
for i in range(handshake.packets):
    remaining = handshake.size - len(raw)
    take = min(handshake.payload, remaining)
    raw += bytes(chunks[i])[:take]

print(gzip.decompress(raw).decode())

Output:

=== GHOST OPERATOR — MISSION LOG ===
Date: 2024-04-03T12:00:00Z
Operator: GH0ST_0P3R4T0R
Target: Drone ALPHA (sysid 1)
Method: MAVLink GCS Identity Spoof + Param Hijack
Redirect: 48.0375, -4.8503 (Ile de Sein)
Status: Target acquired and redirected
Flag: BZHCTF{ALPHA_GH0ST_0P3R4T0R}
=== END LOG ===

The attacker exfiltrated their own mission log, gzip-compressed and hidden inside a MAVLink image transfer protocol.

Flag : BZHCTF{ALPHA_GH0ST_0P3R4T0R}