BLOG

Broken access control: taking over a smart pool controller

A connected swimming pool is still a web application with a motor attached. On a previous assessment the target was a smart pool system. A controller sat in the plant room, a mobile app ran on the customer’s phone, and a cloud portal let installers manage every unit they had sold. The login worked. The problem was everything after it. The platform checked that you were authenticated, and almost never checked what you were allowed to do.

One rule sits above the rest. Only test a system you are authorised to test, inside an agreed scope. The steps below move a real device and change a real chemical setpoint, and the authorisation is what separates an assessment from sabotage.

The system on one page

Four parts mattered. A cloud portal for administrators and installers. A mobile app with two roles, installer and end user. A Linux single-board controller in the plant room. And the physical equipment that controller drives: a heat pump, a salt chlorinator, a circulation pump, lighting and a UV lamp. The app and the portal both talk to one backend API, and the controller reaches the cloud over a message broker. Every one of those hops was in scope, and the interesting failures were in the API.

Architecture of the smart pool platform: apps and cloud on the left, the on-site controller and its physical equipment on the right
The target environment, and the path the broken check exposed

Authentication was checked, authorisation was not

The portal’s idea of “logged in” was the presence of a token in the browser. The whole client-side check was one function.

// The entire client-side "auth" gate
public isAuthenticated(): boolean {
 return localStorage.getItem('accessToken') !== null;
}

A random string such as abcdef did not get you far, because the backend still validated the token’s signature. Any genuine token did, and that is where it fell apart. When a new account was created, the platform e-mailed a verification link carrying a short-lived JWT, valid for about fifteen minutes. That verification token was accepted as a full access token. An account that had never finished registration could call administrative endpoints with it.

DELETE /api/controllers/inventory HTTP/2
Host: cloud.example.net
Authorization: Bearer eyJhbGci...
 (type: access, exp +15m, from the e-mail link)
Content-Type: application/json

{"controller_uuid":"1234"}
HTTP/2 200 OK
Content-Type: application/json

{"message":"Controller deleted: 1234"}

No admin role, no finished account, a token meant only to confirm an e-mail address, and the server carried out a destructive administrative action.

Authentication answers who you are. Authorisation answers what you may do. A platform that checks the first and trusts the second hands every valid token the same keys.

Any account could reach any device

The same flaw ran into normal use. The mobile app named a device by a controller UUID, and the backend trusted that UUID without checking that the caller owned it. The UUIDs were not secret either. The admin console listed all of them, and it sat on the public internet with no VPN or IP restriction in front of it; the same missing check meant any signed-in account could open it and read every UUID. One end user could drive another end user’s hardware by supplying their controller UUID.

The admin console listed every controller by UUID
The public admin console, every controller listed by UUID

POST /api/device/execute HTTP/2
Host: cloud.example.net
Authorization: Bearer <end user B's token>
Content-Type: application/json

{"controller_uuid":"<user A's controller>",
 "device_id":"PoolLight","operation":"turn_on"}
HTTP/2 200 OK
Content-Type: application/json

{"ack":"turn_on",
 "controller_uuid":"<user A's controller>",
 "user_id":"<user A>"}

The response echoed the victim’s own user id back, which confirmed the action ran against their account and not the attacker’s. This is object-level authorisation missing entirely: the classic IDOR, on a physical device instead of a database row.

From a normal account to physical control

Turning on a light is a nuisance. The heat pump and the salt chlorinator are not. Reaching them took one more step, and the platform provided it. Installers can request temporary access to a customer’s controller for support, and the customer is supposed to approve it. The approval was never enforced. An attacker could insert the access grant directly and receive an Access inserted reply. From there they exchanged it for a fresh access token, scoped to the victim’s controller with a privilege claim. That token drove the device-execute endpoint.

POST /api/device/execute HTTP/2
Host: cloud.example.net
Authorization: Bearer <privilege token,
 scope ["privilege"], access_for: user A>
Content-Type: application/json

{"controller_uuid":"<user A's controller>",
 "device_id":"HeatPump","operation":"turn_on"}
HTTP/2 200 OK
Content-Type: application/json

{"ack":"turn_on",
 "device":{"device_id":"HeatPump",
 "operation_params":{
 "port":"/dev/ttyS0","baud_rate":9600,
 "command":["0x01,0x06,0x00,0x64,0x00,0x28,0xC8,0x0B"]}}}

That last field is the whole point. The cloud request does not stop at the cloud. The response showed it being turned into a low-level serial command for the pool hardware: a port, a baud rate, and a raw byte instruction. We did not audit the controller internally. The frame has the shape of a Modbus RTU write to a single register: function code 0x06, a register and a value, then a two-byte checksum. That is the kind of serial protocol these actuators use. Either way the meaning is the same. A JSON call from a stranger’s phone became a physical instruction on a serial line to a motor.

The app itself would not let you do this. Its controls capped the temperature and the pH within safe ranges, but that cap lived only in the front end. Replayed through Burp Suite with an out-of-range value, the same request reached the backend, which stored it without a bounds check. From an account that owned none of it, a test controller’s heat pump target reached 100 degrees Celsius and its salt chlorinator a pH near 1. Those are safety extremes, not comfort settings. A software authorisation flaw had become a physical safety problem.

Heat pump driven to 100 degrees Celsius, and the device action log recording the change
The heat pump forced to 100 °C, and the device action log

The rest of the picture

The authorisation failures were the headline. The controller’s own local web app added more. Its device-status endpoint built SQL by string concatenation. A single quote in a device name threw a 500. A UNION query then read the stored controller UUID and account name straight back.

POST /local/device/status HTTP/1.1
Host: 192.0.2.61
Content-Type: application/json

{"device_id":"PoolLight'"}
HTTP/1.1 500 Internal Server Error
Server: nginx
Content-Type: text/html

<h1>Internal Server Error</h1>

On their own, none of these was the way in. Chained, they widen it. The controller’s local web app spoke plain HTTP with the bearer token in the clear. Anyone on the same network could capture a live token. With authorisation never enforced, that token alone was enough to drive the equipment, no account needed. The build side turned one device into all of them. Private keys, database and message-broker credentials, and encryption keys were committed to source and CI. The mobile app shipped unobfuscated, with a hard-coded key inside. Pull the app apart, recover the secrets, and you reach the broker and the database directly. A broken access check controls one pool. The leaked credentials control every pool.

What defenders should take from it

The fixes are not exotic. They are the controls this platform skipped.

  • Enforce authorisation on every request, on the server, for both the action and the specific object. Confirm the caller owns or may act on that exact controller before you touch it, and never trust an identifier the client supplies.
  • Keep verification tokens separate from access tokens. A link that confirms an e-mail address must not be accepted as a session; give it the narrowest scope and the shortest life.
  • Do not treat the presence of a token as proof of anything. Validate it, then check permissions.
  • Parameterise every query.
  • Put TLS on the internal network too, because “internal” is not a trust boundary.
  • Keep secrets out of source control and CI, and encrypt tokens at rest on the device.
  • For anything that moves a motor or doses a chemical, treat an authorisation check as a safety control, because that is what it is.

Steam rising from a heated outdoor pool at dusk
What an authorisation gap can reach

Closing

The lesson is not the pool. It is that access control is the whole game once authentication is solved, and that on a connected device the blast radius is physical. A login page that works can still sit in front of a platform that lets any account command any device. When the device heats water and adds chemicals, that gap stops being an IT risk and becomes a safety one.

For how Pentrox tests embedded and connected devices end to end, see the hardware security testing service page.

The application and console screenshots in this article are faithful reconstructions. Client-identifying detail has been changed or removed to protect the confidentiality of the assessment.

Share this article LinkedIn X

Ready to Secure Your Environment?

Schedule a free intake call to scope your assessment. Pentrox identifies vulnerabilities in your applications, infrastructure, and cloud environments before attackers do.

Schedule an intake call