Remotely Unlocking Electric Scooters

Aug 22, 2026 04:32 AM - 2 hours ago 3

Note: to protect the company, I swapped retired thing that could constituent back to it for clone examples. The domain electricscootercompany.com.br, the app package, and the personification specifications (slug, name, and email) are each made up. None of it matches the existent company.

It started pinch a news article. A institution had conscionable dropped a bunch of electric scooters successful my city. Most group saw a caller measurement to get astir town. I saw a fleet of internet-connected devices moving connected a backend cipher had poked astatine yet.

First I needed 2 things: which institution this was, and really the work worked for a normal user. The sanction was correct location successful the article, and a speedy Google sewage me to their site, which laid retired the flow:

  1. open the app connected your phone;

  2. scan the scooter's QR Code;

  3. pay to unlock the vehicle;

  4. ride.

That's the happy way for immoderate user. I wanted to spot what was going connected down it.

Step 1: Recon

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

www app api privacidade privacidade2 dev membro vouchers planos validate painel

Not each of those were existent apps. app, membro, vouchers, and planos all served fundamentally the aforesaid page that conscionable pushed you to the app stores. Lots of names, not overmuch caller to look at.

The dev big threw a 500 and group a PHP convention cookie, but thing I could use. validate came backmost pinch Conta não localizada. // "Account not found.", though I didn't cognize yet which parameter it wanted. I wrote these down and moved on.

Three things stood out:

  • www.electricscootercompany.com.br: WordPress trading site;

  • api.electricscootercompany.com.br: REST API utilized by clients;

  • painel.electricscootercompany.com.br: Angular sheet for operators.

Nothing connected the tract linked to the panel. I only recovered it done enumeration. Just because thing isn't linked doesn't mean it's locked down.

Step 2: Opening the sheet without getting in

The sheet loaded for anyone: a accumulation Angular app. I pulled down each 32 JavaScript chunks and dug done the bundles, wherever I recovered 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 maine really juicy the target was, but it sewage maine precisely nowhere. I deed about 38 protected routes pinch nary valid convention and each 1 gave maine the aforesaid thing: HTTP 401.

A batch of what I tried conscionable didn't work:

  • Direct way access: blocked by authentication.

  • Unsigned admin JWT: rejected by the backend.

  • Tampering pinch token claims: didn't nutrient a valid session.

  • SQL injection connected login: ran SQLMap against the auth fields and recovered no injectable parameter.

  • Report endpoint: php/report.php returned an quiet 500.

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

Auth was holding up good against the nonstop stuff. And the app was already pointing maine astatine an easier road: find a existent personification and spell aft their password.

Step 3: WordPress hands complete the first piece

The nationalist WordPress REST API happily fto maine database authors:

GET /wp-json/wp/v2/users GET /wp-json/wp/v2/users/1?context=view

The consequence gave up personification ID 1, nationalist sanction admin, slug electricscootercompany. Normally that's conscionable run-of-the-mill WordPress enumeration. Here, I could return that aforesaid identifier and effort it connected the operations panel.

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

existing personality + incorrect password → "Senha inválida" // "Invalid password" nonexistent personality → "E-mail não encontrado" // "Email not found"

So I didn't person to wonderment if electricscootercompany was conscionable a blog author. The backend told maine consecutive up that the aforesaid personality existed successful the operational system too. That turned a generic enumeration into a target database pinch 1 name worth a lot.

Step 4: The brute force

With the personification confirmed, I threw a brute unit astatine the login. Nothing throttled the repeated tries, and a moving password yet turned up, truthful the sheet login went through.

This is the portion that really explains the guidelines cause. With nary existent complaint limiting, one known personality was each it took to move a guessing loop into a valid session.

Everything aft this rests connected a existent convention I caught successful Burp. The JWT decoded to an relationship with:

{ "data": { "PK_Usuario": 2, "email": "electricscootercompany", "nome": "ElectricScooterCompany", "nivel": 1000, "fk_empresa_grupo": 1 } }

Level 1000 was the admin role. And the token stayed bully for astir 950 days, so a convention grabbed erstwhile would support moving for years unless personification went retired of their measurement to termination it.

Step 5: The sheet stops being a hypothesis

With a valid session, everything changed astatine once. Routes that utilized to springiness maine 401 now handed backmost existent operational data. In the seizure I logged 168 first-party requests crossed 118 unsocial host/method/path combos.

The sheet gave maine publication entree to:

  • fleet representation astatine /mapas/__veiculos;

  • docks astatine /docas;

  • garages and operational infrastructure;

  • geofence polygons astatine /fronteiras/__coordenadas;

  • IoT instrumentality inventory, pinch identifiers and state;

  • individual vehicles and the afloat fleet;

  • app users, wherever the interface mentioned complete 408,000 records;

  • companies, permissions, transactions, voucher batches, and financial data.

The representation connected the integer broadside to the existent cognition connected the ground: wherever the garages sat, wherever the docks were, which vehicles were scattered astir town, and which IoT instrumentality belonged to each one.

Fleet representation showing each scooter, its artillery level, and its existent position successful the city.

Clicking a marker opened up the vehicle's details: code, type, and wherever it was sitting (a dock, for example):

Map popup pinch conveyance code, type (Scooter), and location (Dock).

Some of the responses were large capable to show really overmuch entree this was:

/docas ~312 KB /mapas/__veiculos ~593 KB /iots/.../free/true/... ~1.2 MB /fronteiras/__coordenadas ~32 KB

Burp trim disconnected large consequence bodies astatine astir 3 KB, truthful I don't person each full response saved, but the statuses, paths, and sizes I logged are each solid.

Step 6: It wasn't conscionable looking

Next I wanted to cognize if the sheet only publication information aliases could constitute it too. The session showed PUT calls against:

  • vehicle records;

  • user accounts;

  • voucher batches.

I could besides flip the free-ride emblem connected trial accounts. So this wasn't conscionable reading data. I could alteration business rules and records too.

The conveyance registry fto maine look up immoderate scooter successful the fleet (the interface showed thousands of records) and unfastened its edit form:

The "Vehicle Registry" surface searching for a circumstantial scooter; delicate columns already redacted.

This shape is wherever reference turned into control. On apical of redeeming changes, it had Unlock, Lock, and Restart IoT buttons, and those spell consecutive to the physical device.

Vehicle edit shape pinch the Unlock, Lock, and Restart IoT buttons.

The scariest portion was the IoT module. The sheet fired disconnected commands for illustration this:

POST /iot_sends/ Content-Type: application/json {"pk_veiculo":699,"comando":"open"}

And the backend replied:

{"retorno":"comandos enviados"} // "commands sent"

I fired a 2nd bid to adjacent the aforesaid vehicle:

{"pk_veiculo":699,"comando":"close"}

Same confirmation. I kept the full trial to 1 vehicle, pk_veiculo: 699: a stolen admin relationship could unlock the scooter from anyplace and fastener it correct back up.

The sheet moreover popped a occurrence message:

The connection "Gravado com sucesso." ("Saved successfully.") shown by the sheet aft the command.

But an API consequence and a greenish connection connected surface don't beryllium overmuch connected their own. I needed to cognize the bid really reached a existent scooter. I wasn't anyplace near one, truthful I sewage a friend to locomotion up to a scooter and movie it the 2nd I fired the command. And it worked: the scooter unlocked, the lights came on, and it was ready to ride, pinch cipher paying, scanning a QR Code, aliases moreover rubbing it.

What I didn't trial matters conscionable arsenic overmuch arsenic what I did. I ne'er automated this against a bunch of vehicles, and I ne'er touched thing that could cause movement, braking, aliases immoderate real-world danger. One bid connected 1 scooter was enough to show the web sheet reaches existent hardware.

Dead ends worthy documenting

Not each lead panned out. The dormant ends are worthy sharing too, because crossing them disconnected is what steered maine toward the way that worked.

Intercepting the mobile app

The Android app was Flutter and kept a batch of its logic compiled into libapp.so. It took maine a bunch of tries (emulator, certificates, repackaged APKs, traffic capture) earlier I sewage a convention I could really intercept. A batch of proxy and certificate combos conscionable didn't springiness maine the postulation I was after.

Once the seizure yet worked, 33 flows showed the validateApp protection leaned connected fixed headers and a bearer token, not connected a caller signature per request:

app: br.com.electricscootercompany.app device: <model> uuid: <fingerprint>@@Android versaoapp: 2.0.58 authorization: Bearer <JWT>

That helped maine representation the API, but I still needed a valid token. This bypass wasn't what sewage maine into the panel.

Broad representation queries

A normal bounding-box query gave backmost conveyance position and battery. When I stretched the area retired to astir the size of the country, the backend deed maine pinch a 403 telling maine to log successful again. After that, the aforesaid JWT started getting 403s even on elemental endpoints that had worked a infinitesimal earlier.

The wide query tripped immoderate defense and killed the session, either a unsmooth anomaly filter aliases a token revocation kicking successful erstwhile I sewage greedy. It was 1 of the few spots wherever the backend really pushed back.

Trip history

I threw conveyance references astatine the history endpoints and sewage quiet sets aliases 400s, so no transverse entree to different people's trips there. The responses did leak internal class names and PHP/ORM messages, though, worldly for illustration T_app_usuario_viagem and Undefined array cardinal 6.

SQL injection and forged JWT

I chased some of these earlier the credential route, and neither went anywhere. The login fields weren't injectable, and unsigned aliases messed-with tokens sewage rejected. These misses are worthy penning down: the entree I sewage successful the extremity didn't travel from SQLi aliases a JWT bug, it came from enumeration positive a anemic password positive measurement excessively much privilege.

Root origin of the chain

None of this needed immoderate exotic vulnerability. It each came from a heap of identity, authentication, and privilege problems stacking up:

WordPress exposes user login confirms the personification exists repeated attempts let brute force credential grants nonstop level-1000 access admin token lasts astir 950 days panel concentrates data, changes, and IoT commands remote scooter unlock

The biggest problem is that thing backed thing other up. The password was the only point opinionated betwixt the unfastened net and a fastener that moves physical hardware. Nowhere on the measurement was location a required 2nd login aliases an extra confirmation connected the IoT commands.

What this intends successful practice

With 1 stolen admin credential, I could get to:

  • the operational panel;

  • the representation of vehicles, garages, and docks;

  • the operational topology and geofences;

  • vehicle and IoT instrumentality inventories;

  • broad personification information and financial modules;

  • editing records and benefits connected trial accounts;

  • sending the unfastened and adjacent commands to a trial scooter, remotely.

In a existent attack, this could move into unauthorized fleet use, fraud, operational losses, exposed personification data, and indirect beingness risk, each of it doable astatine scale off a azygous credential.

Conclusion

The roadworthy to unlocking a scooter remotely didn't commencement pinch fancy reverse engineering aliases immoderate uncommon crypto flaw. It started pinch a forgotten subdomain, a public user, and 2 different correction messages.

The fancy attempts were the ones that flopped: the forged JWT sewage rejected, the SQL injection ne'er showed up, and the routes kept handing backmost 401. The thing that really worked was the astir evident onslaught location is. A known identity, a guessable password, and nary 2nd statement of defense were capable to move web access into power complete a beingness device.

More