Master's Thesis (TFM) Antonio García Alcón — Universidad Europea de Madrid, 2026
This project implements a collaborative meaconing detection system for multi-robot GNSS navigation. A meaconing attack consists of receiving legitimate GNSS signals, delaying them, and rebroadcasting them — causing all victim receivers in range to report the same fake position. The attack is particularly dangerous for autonomous vehicle fleets because it is undetectable by any single receiver.
Meaconing versus normal navigation. The comparison shows how the GNSS manipulation changes the reported navigation state while the physical robots continue moving. This visual motivates the collaborative consistency check between GNSS and UWB used by the detector.
The detection strategy is based on a simple insight:
Compare two independent measurements of the same physical quantity — the inter-robot distance.
| Source | Measurement | Vulnerable to meaconing? |
|---|---|---|
| GNSS positions | ✅ Yes — both meaconed to same point → |
|
| UWB ranging |
|
❌ No — measures true Euclidean distance |
Under normal operation
A CUSUM (Cumulative Sum) sequential detector accumulates this bias and triggers an alarm when the statistic crosses a threshold:
The CUSUM is superior to a fixed threshold because it accumulates evidence over time rather than reacting to single-sample noise. To reject brief noise transients, the alarm only fires after alert_confirm_time = 2 s by default), so a genuine detection is reported ~2 s after the statistic first crosses the threshold.
- Bhatti & Humphreys (2017). Hostile Control of Ships via False GPS Signals.
- Chen et al. (2022). A Survey of Robot Swarms' Relative Localization Method. Sensors (MDPI), 22(11), 4212.
- Fishberg et al. (2024). MURP: Multi-Agent Ultra-Wideband Relative Pose Estimation.
Waypoint-follower under meaconing attack. Robot1 (red trajectory) navigates toward a waypoint using GNSS-meaconed position; robot2 (green) uses clean odometry. When the attack activates (purple line), robot1's controller steers it off course while the CUSUM detector accumulates evidence. Three synchronized panels: (1) CUSUM S_k + innovation δ, (2) physical drift ‖p(t) − p_ref(t)‖, (3) top-down robot trajectories.
tfm_meaconing_ws/ # ROS 2 workspace root
├── README.md # ← this file
│
├── src/collaborative_detection/ # Source package
│ ├── package.xml # ROS 2 package manifest
│ ├── setup.py # Python entry points
│ ├── setup.cfg
│ │
│ ├── config/
│ │ └── params.yaml # All tunable parameters (noise, CUSUM, attack)
│ │
│ ├── resource/
│ │ └── collaborative_detection # ROS 2 package marker (required by ament)
│ │
│ ├── launch/
│ │ ├── experiment.launch.py # Full pipeline: Gazebo + sensors + CUSUM
│ │ └── two_robots.launch.py # Two TurtleBot3 robots in Gazebo Sim
│ │
│ ├── scripts/
│ │ └── run_experiment.sh # Run ONE experiment at a time (E0–E6)
│ │
│ ├── analysis/
│ │ ├── plot_results.py # Generate detection metrics and plots from rosbags
│ │ └── make_video.py # Generate experiment video (3-panel animation)
│ │
│ └── collaborative_detection/ # Python package
│ ├── __init__.py
│ └── nodes/
│ ├── gnss_sim_node.py # GNSS simulator (odometry → noisy GNSS)
│ ├── uwb_sim_node.py # UWB ranging simulator (odometry → distance)
│ ├── meaconing_injector.py # Attack injector (meacons GNSS positions (signal retard + rebroadcast))
│ ├── cusum_detector_node.py # CUSUM sequential detector
│ ├── robot_mover_node.py # Autonomous circular motion controller
│ ├── waypoint_follower_node.py # GNSS-meaconed waypoint navigation
│ └── gnss_viz_node.py # RViz2 Marker visualizer
│
├── build/ # Colcon build artifacts (auto-generated)
├── install/ # Colcon install artifacts (auto-generated)
├── log/ # Build logs (auto-generated)
└── results/ # Experiment rosbags + params snapshots
├── E0_baseline/
├── E1_slow_drift/
├── E2_fast_drift/
├── E3_hot_start/
├── E4_wide_separation/
├── e5_ref_waypoint_reference/
├── e5_waypoint_attack/
└── e6_dual_meaconing/
┌─────────────────────────────────────────────────────────────────────┐
│ GAZEBO SIM │
│ ┌──────────┐ ┌──────────┐ │
│ │ Robot1 │──── UWB ranging ────│ Robot2 │ Physical layer │
│ │ (x₁,y₁) │ (D_UWB ≈ 3m) │ (x₂,y₂) │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ odom │ odom │
└───────┼─────────────────────────────────┼───────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ GNSS Sim │ │ UWB Sim │ Sensor layer
│ + noise │ │ + noise │
│ (world frame)│ │ (world frame)│
└──────┬───────┘ └──────┬───────┘
│ gnss_clean │ uwb_distance
▼ │
┌──────────────┐ │
│ Meaconing │ ── gnss_spoofed ──┐ │ Attack layer
│ Injector │ │ │
│ (passthrough │ │ │
│ or meacon) │ │ │
└──────────────┘ │ │
▼ ▼
┌──────────────────┐
│ CUSUM Detector │ Detection layer
│ δ = D_UWB−D_GNSS │
│ S_k = max(0, ...)│
└────────┬─────────┘
│ /system/meaconing_alert
▼
🚨 ALARM
- Gazebo Sim runs two TurtleBot3 Waffle robots moving in autonomous circles(E0-E4) or following GNSS positions (E5-E6).
-
GNSS Sim Node reads odometry from both robots, converts from local
odomframe to globalworldframe using spawn offsets, adds Gaussian noise, and publishesgnss_cleanat 30 Hz. -
UWB Sim Node reads odometry from both robots, converts to world frame, computes the Euclidean distance, adds Gaussian noise (σ = 0.24 m), and publishes
uwb_distanceat 30 Hz. -
Meaconing Injector subscribes to
gnss_cleanand, when inactive, passes it through asgnss_spoofed. When the attack activates (auto-delay or manual service call), both robots' GNSS outputs are gradually dragged toward a common fake target atdrift_velocityplus independent noise — a single-antenna 'drag-off' meaconing attack. Slower drift collapsesD_GNSSmore slowly, so the CUSUM rises at a rate proportional todrift_velocity. -
CUSUM Detector subscribes to
gnss_spoofed(both robots) anduwb_distance, computes$D_{GNSS}$ and$\delta$ , updates the CUSUM statistic, and publishes an alert once$S_k$ has stayed above$\tau$ for thealert_confirm_timeconfirmation window (2 s).
| Topic | Type | Description |
|---|---|---|
/robot1/gnss_clean |
PoseStamped |
Simulated GNSS position (world frame, with noise) |
/robot2/gnss_clean |
PoseStamped |
Simulated GNSS position (world frame, with noise) |
/robot1/gnss_spoofed |
PoseStamped |
GNSS position after meaconing injector (clean or meaconed) |
/robot2/gnss_spoofed |
PoseStamped |
GNSS position after meaconing injector (clean or meaconed) |
/robots/uwb_distance |
Float64 |
Simulated UWB range between robots (m) |
/system/cusum_value |
Float64 |
Current CUSUM statistic (max of both tails) |
/system/cusum_plus |
Float64 |
Positive-tail accumulator |
/system/cusum_minus |
Float64 |
Negative-tail accumulator |
/system/delta_value |
Float64 |
Baseline-corrected, filtered innovation used by CUSUM |
/system/delta_raw |
Float64 |
Raw innovation before startup baseline correction |
/system/meaconing_alert |
Bool |
Detection alarm (true = meaconing detected) |
/meaconing/active |
Bool |
Attack active status |
/meaconing/activation_event |
Float64 |
One-shot activation marker used for precise TTD measurement |
/robot1/cmd_vel |
TwistStamped |
Velocity command for robot 1 |
/robot2/cmd_vel |
TwistStamped |
Velocity command for robot 2 |
| Service | Type | Description |
|---|---|---|
/meaconing/set_active |
SetBool |
Manually activate/deactivate the attack |
/system/reset_cusum |
Trigger |
Reset CUSUM accumulator to zero |
- macOS (tested on Apple Silicon) or Linux
- RoboStack with ROS 2 Jazzy (via pixi)
- Gazebo Sim (Harmonic or Ionic, installed outside pixi)
- Python 3.12+
If you prefer not to use pixi, you can install ROS 2 Jazzy and Gazebo Sim natively on Ubuntu 24.04 (Noble) or other compatible Linux distributions.
# Add ROS 2 apt repository
sudo apt update && sudo apt install -y software-properties-common curl
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
# Install ROS 2 Jazzy desktop (includes rviz2, rosbag2, etc.)
sudo apt update && sudo apt install -y ros-jazzy-desktop
# Install additional packages used by this project
sudo apt install -y \
ros-jazzy-ros-gz-sim \
ros-jazzy-turtlebot3 \
ros-jazzy-turtlebot3-simulations \
ros-jazzy-gazebo-ros-pkgs \
python3-colcon-common-extensions \
python3-rosdep \
python3-pipROS 2 Jazzy pairs with Gazebo Harmonic. The ros-jazzy-ros-gz-sim package above provides the ROS-Gazebo bridge. For the Gazebo simulator itself:
# Add Gazebo repository
sudo apt update && sudo apt install -y lsb-release wget gnupg
sudo wget https://packages.osrfoundation.org/gazebo.gpg -O /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] http://packages.osrfoundation.org/gazebo/ubuntu-stable $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/gazebo-stable.list > /dev/null
# Install Gazebo Harmonic
sudo apt update && sudo apt install -y gz-harmonic# Initialize rosdep
sudo rosdep init
rosdep update
# Install Python dependencies (numpy, matplotlib, etc. for analysis)
pip3 install --user numpy matplotlib scipy
# Or install system-wide:
# sudo apt install -y python3-numpy python3-matplotlib python3-scipyAdd to your ~/.bashrc (or run manually each session):
echo "source /opt/ros/jazzy/setup.bash" >> ~/.bashrc
source ~/.bashrccd ~/tfm_meaconing_ws
colcon build --packages-select collaborative_detection
source install/setup.bashNote: After building, you must source
install/setup.bashin every new terminal (or add it to~/.bashrcafter the ROS 2 source line).
cd ~/robostack
pixi run -e jazzycd ~/tfm_meaconing_ws
colcon build --packages-select collaborative_detection
source install/setup.bashexport TURTLEBOT3_MODEL=waffle
ros2 launch collaborative_detection experiment.launch.pyThis starts everything in sequence:
| t (s) | Event |
|---|---|
| 0 | Gazebo Sim server + GUI |
| 2–3 | Two TurtleBot3 robots spawn |
| 5 | GNSS + UWB simulators start publishing |
| 5.5 | Meaconing injector starts (passthrough mode) |
| 6 | CUSUM detector starts (10 s warmup from first data sample) |
| 7 | Robots begin motion |
| 30 | 🛑 Attack auto-activates (configurable) |
In a second terminal (with install/setup.bash sourced):
# Check that sensors are publishing
ros2 topic echo /robots/uwb_distance # Should show ~3 m
ros2 topic echo /system/cusum_value # Should stay near 0 before attack
# Manually activate the attack (skip the 30 s wait)
ros2 service call /meaconing/set_active std_srvs/srv/SetBool "{data: true}"
# Watch the CUSUM statistic grow and trigger the alarm
ros2 topic echo /system/cusum_value
ros2 topic echo /system/meaconing_alert # Should become trueThe script scripts/run_experiment.sh runs one experiment at a time (run them one-by-one so Gazebo and node processes never accumulate). It starts the recorder before the launch, so new bags include a short pre-roll of the startup sequence.
| Experiment | Command | Parameter | Description |
|---|---|---|---|
| E0 — Baseline | run_experiment.sh e0 |
activation_delay: 9999.0 |
No attack — validates zero false positives |
| E1 — Slow drift | run_experiment.sh e1 |
drift_velocity: 0.1 |
Subtle attack, measures detection sensitivity |
| E2 — Fast drift | run_experiment.sh e2 |
drift_velocity: 0.5 |
Obvious attack, measures minimum TTD |
| E3 — Hot start | run_experiment.sh e3 |
activation_delay: 2.0 |
Attack active from the beginning |
| E4 — Wide separation | run_experiment.sh e4 |
x2: 5.0 |
Robots 5 m apart — tests distance effect on TTD |
| E5 — Waypoint attack | run_experiment.sh e5 |
waypoint_mode: true |
Robot1 navigates via GNSS-meaconed position — measures physical drift before detection |
| E6 — Dual meaconing | run_experiment.sh e6 |
r2_gnss_source: spoofed |
Both robots navigate via GNSS-meaconed positions |
cd ~/tfm_meaconing_ws
source install/setup.bash
export TURTLEBOT3_MODEL=waffle
./src/collaborative_detection/scripts/run_experiment.sh e1Each run lasts 90 seconds by default (override with --duration N) and records a
rosbag in results/e<X>_<name>/. The script automatically:
- Kills any leftover processes from previous runs (clean slate)
- Modifies
params.yamlfor the experiment - Rebuilds the package and verifies the params reached the install tree
- Starts rosbag2 before launching Gazebo to preserve a short startup pre-roll
- Launches Gazebo headless (no GUI) + all nodes — pass
--guito keep the GUI - Records a rosbag with all relevant topics, including
/meaconing/activation_event - Tears down every node/Gazebo/bridge process so nothing lingers
Tip: run one experiment, check the rosbag, then run the next. Headless mode avoids the broken OGRE GUI on macOS and saves a lot of CPU/RAM.
# Activate attack
ros2 service call /meaconing/set_active std_srvs/srv/SetBool "{data: true}"
# Deactivate
ros2 service call /meaconing/set_active std_srvs/srv/SetBool "{data: false}"
# Reset CUSUM (useful between tests)
ros2 service call /system/reset_cusum std_srvs/srv/TriggerAll parameters live in config/params.yaml under the /** wildcard node.
| Parameter | Default | Description |
|---|---|---|
sigma_gnss |
1.0 |
GNSS noise standard deviation (m) |
sigma_uwb |
0.24 |
UWB noise standard deviation (m) |
beta |
0.5 |
CUSUM drift parameter after startup baseline correction |
tau |
3.0 |
CUSUM detection threshold |
filter_window |
30 |
Moving-average window over |
alert_confirm_time |
2.0 |
Time |
startup_delay |
10.0 |
CUSUM warmup period from the first data sample (s) |
drift_velocity |
0.2 |
Fake position drift speed during attack (m/s) |
activation_delay |
30.0 |
Auto-activation delay for the attack (s) |
attack_type |
single_antenna |
Attack mode: single_antenna (meaconing — signal retard + rebroadcast) |
random_seed |
42 |
Fixed seed for NumPy reproducibility |
update_rate |
30.0 |
Sensor/CUSUM update frequency (Hz) |
robot1.x / robot1.y
|
0.0 / 0.0
|
Robot 1 world spawn position (m) |
robot2.x / robot2.y
|
3.0 / 0.0
|
Robot 2 world spawn position (m) |
robot1_linear_vel |
0.15 |
Robot 1 linear velocity (m/s) |
robot1_angular_vel |
0.30 |
Robot 1 angular velocity (rad/s) |
robot2_linear_vel |
0.12 |
Robot 2 linear velocity (m/s) |
robot2_angular_vel |
0.25 |
Robot 2 angular velocity (rad/s) |
waypoint_x / waypoint_y
|
5.0 / 0.0
|
E5 waypoint target coordinates (m) |
linear_speed |
0.2 |
E5 max linear speed toward waypoint (m/s) |
linear_gain |
0.3 |
E5 proportional gain — speed per metre of remaining distance |
angular_gain |
1.0 |
E5 proportional gain — turn rate per radian of heading error |
publish_robot2 |
false |
If true, robot2 runs open-loop circle (legacy mode) |
robot2_waypoint_mode |
false |
E5: if true, robot2 follows waypoint via odometry (ground truth) |
After recording experiments, generate the metrics and plots with the ROS 2 Jazzy environment active:
python3 src/collaborative_detection/analysis/plot_results.pyResults are written to results/plots/. The analysis uses rosbag2_py and reports attack time, time-to-detection (TTD), false alarms, CUSUM evolution, UWB distance, and waypoint physical drift.
The script automatically:
- Discovers all experiment rosbags in
results/ - Loads time series for all topics using
rosbag2_py(MCAP format) - Diagnoses which topics have data
- Generates plots:
-
CUSUM evolution —
$S_k$ over time for each experiment - UWB distance — physical inter-robot distance
- Fixed threshold vs CUSUM — demonstrates sequential detector advantage
- Detection metrics — TTD, false alarm count
-
CUSUM evolution —
Note: the red "Alarm active" regions in the CUSUM plots come from the confirmed
/system/meaconing_alerttopic, so they begin ~2 s after$S_k$ first crosses$\tau$ (thealert_confirm_timeconfirmation window). Attack time uses the one-shot/meaconing/activation_eventmarker for new bags, with/meaconing/activeas a fallback for historical bags.
Requires the ROS 2 Jazzy environment (
pixi run -e jazzy) forrosbag2_pyandrclpy.
The optional synchronized experiment video can be generated with:
python3 src/collaborative_detection/analysis/make_video.py e5_waypoint_attack
python3 src/collaborative_detection/analysis/make_video.py e6_dual_meaconing-
World-frame GNSS: The DiffDrive plugin publishes odometry in a per-robot local frame starting at (0,0) regardless of world spawn position. The GNSS and UWB simulators add the known spawn offset to obtain world-frame coordinates, making
$D_{GNSS}$ and$D_{UWB}$ directly comparable. -
Per-robot Gazebo topics: Each robot's SDF is dynamically patched at launch time to use model-specific transport topics (
/model/robot1/odom,/model/robot2/odom, etc.), preventing the two bridges from receiving identical data from the shared global topics Gazebo uses by default. -
Baseline-corrected signed CUSUM innovation: The detector estimates the normal median of
$D_{UWB} - D_{GNSS}$ duringstartup_delay, subtracts it, and feeds the corrected signed value to the two CUSUM tails. This prevents the Euclidean GNSS range bias from producing a false negative-tail alarm before activation, while preserving the positive meaconing signal. -
Deterministic reproducibility: A fixed
random_seed: 42ensures identical noise sequences across runs, making experiments comparable.
| Symptom | Likely cause | Fix |
|---|---|---|
ros_gz_sim not found |
Wrong ROS distro (Humble lacks ros_gz_sim) |
Use pixi run -e jazzy |
| Robots not moving in Gazebo | Bridge topic mismatch | Ensure two_robots.launch.py uses per-robot SDF patching |
| UWB distance ≈ 0 | Both robots' odometry in local (0,0) frame | Check that GNSS/UWB nodes read robot1.x/robot2.x spawn offsets |
| CUSUM publishes nothing | Meaconing injector crashed | Check params for type errors (e.g. 9999 integer when float expected) |
colcon build fails |
Missing resource/ marker |
touch src/collaborative_detection/resource/collaborative_detection |
| Notebook shows no data | Ran outside ROS env | Launch Jupyter from pixi run -e jazzy |

