Remotely Unlocking Electric Scooters

henriemategui1 pts0 comments

Remotely unlocking electric scooters — Henri Emateguihenriemategui.com@henriemateguisystem

Remotely unlocking electric scooters<br>August 21, 2026 · 9 min read

Note: to protect the company, I swapped out anything that could point back<br>to it for fake examples. The domain electricscootercompany.com.br, the app<br>package, and the user details (slug, name, and email) are all made up. None of<br>it matches the real company.

It started with a news article. A company had just dropped a bunch of electric<br>scooters in my city. Most people saw a new way to get around town. I saw a fleet<br>of internet-connected devices running on a backend nobody had poked at yet.

First I needed two things: which company this was, and how the service worked for<br>a normal user. The name was right there in the article, and a quick Google got me<br>to their site, which laid out the flow:

open the app on your phone;

scan the scooter's QR Code;

pay to unlock the vehicle;

ride.

That's the happy path for any user. I wanted to see what was going on behind it.

Step 1: Recon

I started by mapping everything tied to electricscootercompany.com.br.<br>Subdomain enumeration pulled up a bunch, including:

www app api privacidade privacidade2 dev<br>membro vouchers planos validate painel<br>Not all of those were real apps. app, membro, vouchers, and planos all<br>served basically the same page that just pushed you to the app stores. Lots of<br>names, not much new to look at.

The dev host threw a 500 and set a PHP session cookie, but nothing I could use.<br>validate came back with Conta não localizada. // "Account not found.", though<br>I didn't know yet which parameter it wanted. I wrote these down and moved on.

Three things stood out:

www.electricscootercompany.com.br: WordPress marketing site;

api.electricscootercompany.com.br: REST API used by clients;

painel.electricscootercompany.com.br: Angular panel for operators.

Nothing on the site linked to the panel. I only found it through enumeration.<br>Just because something isn't linked doesn't mean it's locked down.

Step 2: Opening the panel without getting in

The panel loaded for anyone: a production Angular app. I pulled down all 32<br>JavaScript chunks and dug through the bundles, where I found 83 endpoints for:

users and permissions;

vehicles and maps;

trip activation and finalization;

IoT devices;

garages, docks, and geofences;

vouchers, transactions, and financial modules.

That told me how juicy the target was, but it got me exactly nowhere. I hit about<br>38 protected routes with no valid session and every one gave me the same thing:<br>HTTP 401.

A lot of what I tried just didn't work:

Direct route access: blocked by authentication.

Unsigned admin JWT: rejected by the backend.

Tampering with token claims: didn't produce a valid session.

SQL injection on login: ran SQLMap against the auth fields and found no<br>injectable parameter.

Report endpoint: php/report.php returned an empty 500.

Classic exposed files: .git, .env, and source maps weren't accessible.

Auth was holding up fine against the direct stuff. And the app was already<br>pointing me at an easier road: find a real user and go after their password.

Step 3: WordPress hands over the first piece

The public WordPress REST API happily let me list authors:

GET /wp-json/wp/v2/users<br>GET /wp-json/wp/v2/users/1?context=view<br>The response gave up user ID 1, public name admin, slug<br>electricscootercompany. Normally that's just run-of-the-mill WordPress<br>enumeration. Here, I could take that same identifier and try it on the operations<br>panel.

The login gave different answers depending on what I fed it:

existing identity + wrong password → "Senha inválida" // "Invalid password"<br>nonexistent identity → "E-mail não encontrado" // "Email not found"<br>So I didn't have to wonder if electricscootercompany was just a blog author.<br>The backend told me straight up that the same identity existed in the operational<br>system too. That turned a generic enumeration into a target list with one name<br>worth a lot.

Step 4: The brute force

With the user confirmed, I threw a brute force at the login. Nothing throttled the<br>repeated tries, and a working password eventually turned up, so the panel login<br>went through.

This is the part that really explains the root cause. With no real rate limiting,<br>one known identity was all it took to turn a guessing loop into a valid session.

Everything after this rests on a real session I caught in Burp. The JWT decoded to<br>an account with:

"data": {<br>"PK_Usuario": 2,<br>"email": "electricscootercompany",<br>"nome": "ElectricScooterCompany",<br>"nivel": 1000,<br>"fk_empresa_grupo": 1<br>Level 1000 was the admin role. And the token stayed good for about 950 days, so<br>a session grabbed once would keep working for years unless someone went out of<br>their way to kill it.

Step 5: The panel stops being a hypothesis

With a valid session, everything changed at once. Routes that used to give me 401<br>now handed back real operational data. In the capture I logged 168...

electricscootercompany session panel user real step

Related Articles