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...