Trying to uncloud an Ecovacs mower

The O500: root access, TLS interception, and the unfinished local protocol

Philipp A. Baer

Computer scientist · DevSecOps engineer · roboticist

  • Research: PhD at Kassel; distributed systems and cooperative robotics.
  • Robotics: RoboCup (Ulm / Kassel); research group lead at DFKI.
  • GE HealthCare, since 2012: Software, infrastructure, and DevSecOps.
  • Since 1 October 2026: GEHC Product Security, assessment automation and security architecture.

npw.net · git.baer.one/phbaer

At home, before the mower

Home Assistant is the control center.

Job What runs at home
Video Frigate
DIY devices ESPHome
Sensors and devices Zigbee, plus a little Matter

That is the setup I wanted the mower to fit into.

The target: an Ecovacs O500 Panorama

What would it take to make an owner-controlled mower work without Ecovacs services?

First target: start, stop, and status.
Not yet: camera streaming. It has a separate Kinesis / STUN / ICE path.

IMAGE SLOT · Ecovacs O500 Panorama in the yard; landscape photo

Step 1 — follow the packets

Router capture. One action at a time: DNS, TLS names, timing, flow direction.

Observed phone and mower connections, with a dashed NG-IoT-to-JMQ link indicating inferred backend routing. Hydra handles the mower connection; video uses a separate path.

Solid lines: observed connections. Dashed link: inferred backend routing.

The first useful map

What I saw Transport First guess
Regional JMQ pool Long-lived TLS/443 Mower command and status
NG-IoT API HTTPS/443 App control
Kinesis + STUN/ICE TLS + UDP Camera/video

Three paths. The app and mower both depend on the cloud for ordinary control.

How I looked inside the app

Rooted Android · ECOVACS HOME 3.14.0 · Frida

Frida observes OkHttp requests and MQTT calls inside the app process, before transport encryption.

Frida reads app objects before transport encryption.

Step 2 — ask the app what “start” looks like

POST /api/iot/endpoint/control

body.data.act              start | stop
body.data.content.type     auto

A request shape, not a working recipe. Most fields, authentication, and responses are still unknown.

Other observed body.data keys: name, schedAct, sid, subsets, using (values withheld).

Side quest — is there a local control port?

A focused TCP scan found only port 53: Unbound DNS.

It answered recursive public-name queries over UDP and TCP. We found no security vulnerability in this check, and no mower-local HTTP control API.

Step 3 — find the console

A ten-pin service connector exposed two UARTs. The Baize controller TX gave boot diagnostics; the main console showed:

Horizon X5 ASIC BootROM
U-Boot 2022.10
Linux 6.1 · Ubuntu 22.04

Pin 2 = main-console TX · Pin 3 = ground · Pin 4 = main-console RX
At 115200 8N1, pin 4 stopped autoboot at Hobot>.

IMAGE SLOT · Close-up of the 10-pin service connector; annotate TX, RX, and ground

Inside the firmware

Firmware layers: Horizon X5, U-Boot, Linux and Ubuntu, then ROS and Ecovacs services. Verified system and writable userdata are separate.

Verified system_a is read-only; /data is separate writable userdata.

UART bytes without command execution

At Hobot>, the raw monitor records TX and RX separately, with no local echo.

TX 68 65 6C 70 0D   help + CR
RX 68 65 6C 70 0D   matching bytes only

No U-Boot response. CR, LF and version at 100 ms per byte did not restore execution.

Matching RX bytes establish receipt at the adapter. Their source remains unresolved.

Another door: USB ADB

The USB-A receptacle enumerated as an ADB gadget.

18d1:4e26 · hobot / xj3
ADB Interface
state: unauthorized

No shell. No filesystem commands sent.

The temporary U-Boot experiment (RAM only)

This was not a replacement bootloader and nothing was flashed.

One-boot experiment: change the RAM environment, append init=/bin/sh, and boot the known slot through hb_avb_helper.

I skipped eco_ab_select, which records boot attempts in A/B metadata.

What the temporary boot got us: no shell

The device echoed id and Ctrl-C, but returned no prompt or command output.

The separate recovery configuration also gave no UART output, USB device, or normal LAN address.

It was inconclusive—not a root shell. Power-cycle restored normal boot.

The filesystem explained the missing login

The boot log: serial-getty@ttyS0 started, then stopped. No login prompt.

On system_a, serial-getty@.service calls /usr/bin/serial_tty.sh:

if [[ $1 != "ttyS0" || -f /data/serial_to_be_run ]]; then
    /sbin/agetty ... "$1" "$2"
fi

The marker enables agetty but supplies no credentials. I left it absent.

Root came from a separate hook: /data/autostart/*.sh, launched after ROS.

How we installed the root shell

U-Boot’s USB Mass Storage (UMS) mode exported eMMC userdata as a USB disk. Controller 1 was the working path:

U-Boot: ums 1 mmc 0:10
   host: /dev/sda (ext4 userdata)
   copy: /data/autostart/10-serial-root.sh (mode 0700)
   boot: normal startup

This was USB storage access—not the separate ADB interface.

Why the first boot was silent — and the fix

After ROS starts, autostart.service runs /data/autostart/*.sh as root.

The first script got no UART response. The stock getty was competing for ttyS0.

The working script stopped the getty, set the UART to 115200, then exec’d interactive Bash on /dev/ttyS0.

And then it worked

On a normal boot, the UART showed:

GOAT# id
uid=0(root)

Interactive root Bash worked over the console. The hook opens no network service.

Maintenance access is blocked

After the reported RX-pin accident, normal boot still reaches GOAT#.

id returns matching RX bytes, but no output or fresh prompt. A second boot reproduced the failure with serial-getty stopped.

The bootstrap remains installed. UART cannot currently remove it, UMS cannot be started, and USB ADB remains unauthorized.

The next UART check

Probe X5 TX and RX independently with a 3.3 V logic analyzer or oscilloscope.

  • Establish the adapter’s intentional loopback signature, then remove the jumper.
  • Disconnect adapter TX from mower RX and check for host-side loopback.
  • Reconnect and verify delivery on pin 4 and output on pin 2 independently.

A no-echo Bash diagnostic is staged, not installed. Deployment requires a verified /data backup and tested rollback.

Step 4 — get Hydra talking locally

Hydra is the mower process that connects to JMQ. Its mqCa setting points to a writable trust file:

/data/certs/master.pem

With our test CA first in that file and JMQ traffic redirected to a local listener, Hydra completed TLS 1.3.

We restored the original trust file and removed the redirect. The mower reconnected to Ecovacs.

MQTT was visible; local control is still open

A separate MQTT probe observed:

  • MQTT 3.1.1 CONNECT framing
  • A clean session and 60-second keepalive
  • Four QoS-1 subscriptions

We still need accepted device authentication, command topics, and working status updates.

The TLS listener did not request a client certificate. The normal JMQ service does; that identity path remains unresolved.

What worked—and what is still missing

We know

Root shell confirmed earlier
Local TLS 1.3 succeeded
MQTT framing observed

We don’t know

Accepted auth
What the topics mean
How to send a command
How the app gets status

That gap is the project right now.

What I’d try next

  1. Isolate the UART echo source and restore reliable maintenance input.
  2. Map one safe command and the matching status update.
  3. Build the JMQ and app-facing pieces needed for that loop.
  4. Test with cloud access absent on an isolated network.

No “unclouded” claim until start, stop, and status work locally.

The projects are public

The freely available projects are at git.baer.one/phbaer.

Browse, reuse, fork, improve.

IMAGE SLOT · Project-list screenshot or QR code linking to the repo collection

Questions

What would you try next—and what result would change your mind?