远程解锁电动滑板车
Remotely unlocking electric scooters

原始链接: https://henriemategui.com/post/remotely-unlocking-electric-scooters

本摘要概述了一名安全研究人员获取电动滑板车车队未经授权控制权限的过程。 在确定目标公司后,研究人员进行了子域名枚举,发现了一个不受保护的管理后台。尽管复杂的攻击手段(如 SQL 注入和 JWT 伪造)均未奏效,但研究人员通过该公司的 WordPress 站点找到了一个简单且有效的路径。通过利用公开的 WordPress API 识别有效用户名,研究人员对缺乏速率限制的管理后台成功实施了暴力破解攻击。 进入后台后,研究人员获取了具有长期令牌的高权限会话。这使得研究人员能够访问敏感的运营数据,包括用户记录、财务信息以及实时的车队地图。关键在于,该后台包含对滑板车物联网硬件的直接控制功能。研究人员通过后台执行命令,在无需物理接触或支付的情况下远程解锁了一辆滑板车,从而证实了整个物理车队都处于易受攻击的状态。 根本原因在于缺乏“纵深防御”:一个简单的弱密码竟是保护物理资产的唯一屏障。该研究结果强调,当缺乏速率限制和多重身份验证等基本安全控制措施时,往往并不需要复杂的黑客技术即可入侵。

近期 Hacker News 上一篇题为“远程解锁电动滑板车”的文章引发了关于网络安全与负责任披露的讨论。作者分享了利用某滑板车公司的管理面板远程解锁车辆的研究结果。 评论者迅速质疑了作者的职业道德,指出该漏洞——一个面向公众的登录入口——似乎依然处于激活状态。批评人士认为,作者在公开漏洞详情之前,本应先告知公司,进行负责任的披露。另一些人则指出,许多此类滑板车服务依赖于“现成”软件,这暗示了该行业在安全性方面存在更广泛的系统性问题。讨论串还涉及了人工智能的作用,用户质疑该漏洞利用本身是否是自动化的,或者该文章是否仅由 AI 生成。
相关文章

原文

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

It started with a news article. A company had just dropped a bunch of electric scooters in my city. Most people saw a new way to get around town. I saw a fleet 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 a normal user. The name was right there in the article, and a quick Google got me to their site, which laid out the flow:

  1. open the app on your phone;

  2. scan the scooter's QR Code;

  3. pay to unlock the vehicle;

  4. 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. Subdomain enumeration pulled up a bunch, including:

www  app  api  privacidade  privacidade2  dev
membro  vouchers  planos  validate  painel

Not all of those were real apps. app, membro, vouchers, and planos all served basically the same page that just pushed you to the app stores. Lots of names, not much new to look at.

The dev host threw a 500 and set a PHP session cookie, but nothing I could use. validate came back with Conta não localizada. // "Account not found.", though 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. 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 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 38 protected routes with no valid session and every one gave me the same thing: 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 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 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
GET /wp-json/wp/v2/users/1?context=view

The response gave up user ID 1, public name admin, slug electricscootercompany. Normally that's just run-of-the-mill WordPress enumeration. Here, I could take that same identifier and try it on the operations panel.

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

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

So I didn't have to wonder if electricscootercompany was just a blog author. The backend told me straight up that the same identity existed in the operational system too. That turned a generic enumeration into a target list with one name worth a lot.

Step 4: The brute force

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

This is the part that really explains the root cause. With no real rate limiting, 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 an account 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 good for about 950 days, so a session grabbed once would keep working for years unless someone went out of 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 now handed back real operational data. In the capture I logged 168 first-party requests across 118 unique host/method/path combos.

The panel gave me read access to:

  • fleet map at /mapas/__veiculos;

  • docks at /docas;

  • garages and operational infrastructure;

  • geofence polygons at /fronteiras/__coordenadas;

  • IoT device inventory, with identifiers and state;

  • individual vehicles and the full fleet;

  • app users, where the interface mentioned over 408,000 records;

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

The map connected the digital side to the real operation on the ground: where the garages sat, where the docks were, which vehicles were scattered around town, and which IoT device belonged to each one.

Fleet map showing every scooter, its battery level, and its real position in the city.

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

Map popup with vehicle code, type (Scooter), and location (Dock).

Some of the responses were big enough to show how much access this was:

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

Burp cut off big response bodies at around 3 KB, so I don't have every full response saved, but the statuses, paths, and sizes I logged are all solid.

Step 6: It wasn't just looking

Next I wanted to know if the panel only read data or could write it too. The session showed PUT calls against:

  • vehicle records;

  • user accounts;

  • voucher batches.

I could also flip the free-ride flag on test accounts. So this wasn't just reading data. I could change business rules and records too.

The vehicle registry let me look up any scooter in the fleet (the interface showed thousands of records) and open its edit form:

The "Vehicle Registry" screen searching for a specific scooter; sensitive columns already redacted.

This form is where reading turned into control. On top of saving changes, it had Unlock, Lock, and Restart IoT buttons, and those go straight to the physical device.

Vehicle edit form with the Unlock, Lock, and Restart IoT buttons.

The scariest part was the IoT module. The panel fired off commands like 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 second command to close the same vehicle:

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

Same confirmation. I kept the whole test to one vehicle, pk_veiculo: 699: a stolen admin account could unlock the scooter from anywhere and lock it right back up.

The panel even popped a success message:

The message "Gravado com sucesso." ("Saved successfully.") shown by the panel after the command.

But an API response and a green message on screen don't prove much on their own. I needed to know the command actually reached a real scooter. I wasn't anywhere near one, so I got a friend to walk up to a scooter and film it the second I fired the command. And it worked: the scooter unlocked, the lights came on, and it was ready to ride, with nobody paying, scanning a QR Code, or even touching it.

联系我们 contact @ memedata.com