逆向工程Lyft自行车,仅供娱乐(和盈利?)
Reverse engineering Lyft Bikes for fun (and profit?)

原始链接: https://ilanbigio.com/blog/lyft-bikes.html

## Lyft 自行车破解:一个夏季项目 这记录了2019年对 Lyft 自行车共享系统的一次引人入胜(且已道德披露)的安全探索。作者因自行车丢失而感到沮丧,逆向工程了 Lyft 的 iOS 应用 API,以远程解锁自行车。这包括使用 Charles Proxy 等工具拦截和解密网络流量,使用自定义证书克服 SSL 加密,并最终利用一个漏洞。 核心挑战是绕过地理围栏并获取有效的自行车 ID。由于无法直接检索 ID,作者采用了一种暴力破解方法,最初需要数小时,但使用 `aiohttp` 的异步请求优化到约 15 秒。这使得远程解锁自行车成为可能,甚至发现了一个允许同时解锁的错误。 在被 Lyft 实习生发现后,作者通过 HackerOne 负责任地披露了这些发现。尽管最初对暴力破解方法有所担忧,但 Lyft 还是为该报告奖励了 500 美元的赏金,以表彰该漏洞。 这次经历突出了逆向工程的力量、负责任披露的重要性,以及隐藏在看似物理系统中的令人惊讶的数字接口。Lyft 随后修补了这些漏洞并推出了自行车预订功能,从而使这些技术过时了。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 逆向工程Lyft自行车,为了乐趣(和利润?) (ilanbigio.com) 9点 由 ibigio 1小时前 | 隐藏 | 过去 | 收藏 | 2评论 ibigio 54分钟前 [–] 你好。 2019年,我逆向工程了Lyft自行车的API,以便从床上解锁它们。这是我最喜欢的故事之一,在讲述了数十次之后,我终于决定将其完整的技术细节写下来。 我过去喜欢通过博客文章/报告学习安全知识,所以我尽量包含尽可能多的细节。如果你喜欢这种风格,请告诉我!回复 spydum 1分钟前 | 父评论 [–] 难以置信,直接进监狱!开玩笑的,写得很好。我知道这没什么突破性的,但让我惊讶的是,有多少产品懒得去处理速率限制控制。回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

One cold San Francisco summer morning in Haight-Ashbury, my commute down to Market was interrupted by the sight of a lucky duck taking the last Lyft bike – again.

"I should really just wake up 15 minutes earlier", I thought, fleetingly. Then instead proceeded to spend the next month reverse engineering Lyft's private API, bypassing SSL encryption, chasing loose bikes across the city, triggering an internal incident, and somehow making a profit.

I learned a lot doing this, so I'm writing it up in case you might too.

 Technical summary, for the impatient (spoilers!)

Goal: Remotely unlock a Lyft bike.

Steps:

  1. Capturing iOS App Encrypted Traffic – to re-construct request
  2. Replaying Modified Unlock Request – to bypass geofence
  3. Brute-forcing Bike ID – since not available remotely

Capturing iOS App Encrypted Traffic

I used Charles Proxy to capture outgoing requests from the Lyft app on my iPhone.

Normal SSL handshake

Charles supports SSL Proxying, which injects its own ephemeral certificates during SSL handshake, making sure requests from both sides are being signed with keys it controls. Reading decrypted traffic in Charles

Intercepted SSL handshake via Charles

This allows us to decrypt, read, and re-encrypt traffic in transit.

Reading decrypted traffic in Charles

The ephemeral certificates are signed by a Charles Certificate Authority, which needs to be installed on your phone so Charles's certificates are not rejected. SSL traffic content is then viewable.

api-routes.png

Replaying Modified Unlock Request

From the Charles captures, we see the unlock request uses a rent endpoint with the following structure:

POST "https://layer.bicyclesharing.net/mobile/v2/fgb/rent"

HEADERS
{
  "api-key": "sk-XXXXX",
  "authorization": "bearer-XXXXX",
  ...
}

DATA
{
  "userLocation": { "lat": 37.7714859, "lon": -122.4449036 },
  "qrCode": { "memberId": "user-XXXXX", "qrCode": "12345" },
  ...
}

A simple python replay script:

import requests

url="https://layer.bicyclesharing.net/mobile/v2/fgb/rent"

headers={
  "api-key": "sk-XXXXX",
  "authorization": "bearer-XXXXX",
}

station_coords = { "lat": 37.7730627, "lon": -122.4390777 }    # from maps
bike_id = "12345"                                              # dummy id

data={
  "userLocation": station_coords,
  "qrCode": { "memberId": "user-XXXXX", "qrCode":  bike_id},
}

requests.post(url, headers=headers, json=data)

Brute-forcing Bike ID

Bike IDs are only accessible through the physical bikes (not counting eBikes, which were out of scope), to unlock one remotely, we need to brute force it. Five-digit IDs, but in practice only the 10000 to 20000 range is used, so 10,000 IDs to try.

A naive implementation takes ~3 hours:

def payload(i):
    return {
        "userLocation": station_coords,
        "qrCode": { "memberId": "mem123", "qrCode":  i},
    }

def send_one(i):
    requests.post(url, headers=headers, json=payload(i))

for i in range(10_000, 20_000):
    send_one(i)

But we can use asyncio and aiohttp to reduce that to ~15 seconds:

import asyncio, aiohttp

async def send_one(session, i):                              # non-blocking
  async with session.post(url, headers=headers, json=payload(i)): pass

async def main():
  async with aiohttp.ClientSession() as s:
    tasks = [send_one(s, i) for i in range(10_000, 20_000)]  # start all
    await asyncio.gather(*tasks)                             # wait for all

asyncio.run(main())

Et voilà.

Disclaimer: This writeup is meant for educational purposes only. Vulnerabilities discussed here were disclosed to Lyft in 2019, who promptly responded and patched them. Not long after, they also introduced bike reservations as an official feature, solving my original problem and rendering the below techniques obsolete.

Table Of Contents

The Acquisition

Back in 2019 Lyft Bikes (BayWheels) used to be called Ford GoBikes, and used to be unlocked on a per-station basis. You'd generate a temporary code for a specific station on your app, then punch it into that station which would release a random bike.

Ford GoBikes

My goal was to make sure nobody would take a bike while I was on-route to the station, so what if I just kept manually generating codes until I arrived? Maybe that might block others from doing so. So I tried it. No luck. Generating a code didn't block others, and that was the only way to unlock bikes. Welp, nothing left to try...

...until the next day when Lyft, who had apparently just acquired Ford GoBikes, rebranded it to BayWheels, and changed the whole unlock mechanism. All hail Lyft.

The new BayWheels map also showed bikes at stations, but now you'd unlock a bike directly by scanning a QR code on it. Each bike also had a 5-digit number you could use in case scanning didn't work. Cool! This means maybe if I typed a bike's code into my app when I left my house, it would be unlocked (and hopefully still there) by the time I arrived? So I tried it.

You are too far from this station.

They had geofenced it. I spent a solid day Googling how to spoof GPS on iPhone but no luck. I then wondered, fatefully, "what does the app actually send to Lyft during an unlock?", and my journey of capturing encrypted iOS traffic began.

Intercepting iOS App Requests

If you've used Chrome DevTools (aka Inspect Element) you may have noticed a Network tab that lets you see the traffic between a website and its backend. Unfortunately it's not so simple for iOS. Some helpful Reddit posts led me to Charles Proxy which lets you see all traffic from your computer, and a friendly eight sentences explained how to wire it up to my phone's traffic. It's basically a consensual man-in-the-middle attack.

First I had to forward my phone's traffic to Charles on my laptop. To do this I enabled "HTTP Proxy" on my phone's wifi settings, and set the [ip]:[port] to 192.168.0.7:8888:

  • 192.168.0.7 is my laptop's local IP which I got by running ipconfig getifaddr en0
  • 8888 is the port Charles Proxy is running on
Normal SSL handshake

Now my traffic was being forwarded to Charles Proxy and huzzah! I could see all requests coming out of my phone. But... I can't see the content? Oh, right. SSL encryption. The thing making sure we can trust the internet was getting in my way.

Spoofing SSL Root Certificate Authorities

SSL ensures traffic from the Lyft app is encrypted using the lyft.com public key, so only lyft.com can decrypt it. All modern applications & websites do this, and you can find the public key on a website's SSL certificate.

SSL Certificate

In theory, this means my traffic can't be decrypted once it leaves my phone, even by me. However, Charles has a workaround: by enabling SSL Proxying, Charles will prevent the real lyft.com SSL certificate from making it back to your phone, and instead sends a new one it generates on the fly.

Normal SSL handshake Intercepted SSL handshake via Charles

This means your phone is now encrypting lyft.com traffic with Charles's public key, so Charles can decrypt it, save it, then re-encrypt it with the real lyft.com cert and forward it along.

Reading decrypted traffic in Charles

But there's a catch – if anyone between you and Lyft can do this (coffee shop, cell provider, etc.), how can we ever know the certificate is really Lyft's? Well, your phone will reject a certificate unless it's been signed by a Certificate Authority, endorsing it actually belongs to lyft.com. These CAs are third-party organizations acting like notaries that issue "root certificates", and your phone comes with many trusted CA root certificates pre-installed. So Charles just asks you to install one more root certificate – the Charles Root Certificate, used to sign all the other certificates Charles creates. And just like that, my phone trusts Charles, and I can see SSL traffic.

Anatomy of a Lyft Request

So, let's unlock a bike from my phone, shall we?

Vehicle not found.

Right. Well, I used 12345 as the bike ID, so that's expected. Charles managed to capture some traffic anyway. Let's see if we can find the unlock request.

api-routes.png

So many requests and API routes! I see one endpoint called rent, which I bet is the unlock request. Let’s look at the contents.

POST "https://layer.bicyclesharing.net/mobile/v2/fgb/rent"

HEADERS
{
  "api-key": "sk-XXXXX",
  "authorization": "bearer-XXXXX",
  ...
}

DATA
{
  "userLocation": { "lat": 37.7714859, "lon": -122.4449036 },
  "qrCode": { "memberId": "user-XXXXX", "qrCode": "12345" },
  ...
}

Yup, looks like it is. Look at these fields (I omitted the unrelated, redacted the sensitive.) There's some auth in the headers (api-key, authorization), the bike qrCode I used (12345), a memberId which I assume identifies my account, and... userLocation coordinates! Bingo. Now I just need to replay that request with a python script, but set the lat, lon to be right next to the station (whose coordinates I got using google maps).

import requests

url="https://layer.bicyclesharing.net/mobile/v2/fgb/rent"

headers={
  "api-key": "sk-XXXXX",
  "authorization": "bearer-XXXXX",
}

station_coords = { "lat": 37.7730627, "lon": -122.4390777 }    # from maps
bike_id = "12345"                                              # dummy id

data={
  "userLocation": station_coords,
  "qrCode": { "memberId": "user-XXXXX", "qrCode":  bike_id},
}

requests.post(url, headers=headers, json=data)

Sweet, now I just needed a real bike_id to test it on. It was very late at night but I was excited, so out I went with my PJs, flip flops, and laptop to squat by my target bike. I found its ID, entered it into my script, hit run, and holy shit it worked. The bike unlocked. I re-locked it, ran back to my apartment, hit run again, ran back, and there she was. Unlocked, and inconspicuously so. Nobody would think to take it… but me.

I was in business.

I Promise it’s not a Denial of Service Attack

Except… the bike IDs are only printed on the bikes. How would I know what bike_id to use without going to the station? Maybe some other request Charles captured might have all the bike IDs at a station? Short answer – no. Two days of digging through captured traffic yielded no way to fetch bike IDs.

After considering many fruitless ideas, like hiding a little camera pointed at the bikes and using OCR, I thought… could I just try all IDs? Five digits, that’s 100,000 combinations… and thinking back, I had only seen IDs between 10000 and 20000. 10,000 loop iterations is not that many for python!

This runs in less than a second:

for i in range(10_000, 20_000):
    print(i)

This, however, takes ~a second per request. So… ~three hours for 10,000 requests.

def payload(i):
    return {
        "userLocation": station_coords,
        "qrCode": { "memberId": "mem123", "qrCode":  i},
    }

def send_one(i):
    requests.post(url, headers=headers, json=payload(i))

for i in range(10_000, 20_000):
    send_one(i)

But we don’t have to wait for each request to come back – we can run them in parallel. After trying multiprocessing and threading, I massaged a stack overflow code snippet I found using aiohttp to start a bunch of requests without blocking on a response. Here’s a slightly simplified version.

import asyncio, aiohttp

async def send_one(session, i):                              # non-blocking
  async with session.post(url, headers=headers, json=payload(i)): pass

async def main():
  async with aiohttp.ClientSession() as s:
    tasks = [send_one(s, i) for i in range(10_000, 20_000)]  # start all
    await asyncio.gather(*tasks)                             # wait for all

asyncio.run(main())

I benchmarked this against Postman's API (meant for testing) and it ran in 15 seconds! That's ~650 RPS. But, hm… is that too much for their servers? In April 2019 there were about 9,000 trips per day, so even if 80% of those all happened during rush hour (8-10am, 5-7pm) that's still a whopping 0.5 RPS at its peak. I'd be single-handedly 1,300x-ing their peak traffic on this endpoint. To be fair, (Google informed me,) 650 RPS is not that crazy for most servers. But a sudden spike like that might still look to Lyft like a Denial-of-Service attack...

To: [email protected]

...which it's not. Let me know if this is an issue and I'll stop.

I'm just a student, please don't call the cops.

Sincerely, Ilan

Aaaand sent. Ok now let's run it on all the IDs.

The Test

I'm about to run python unlock_script.py when a thought occurs to me: Is there any chance, however slim, that I'm about to unlock every single Lyft Bike in and around the Bay Area? The geofence should prevent that, in theory. Only the station at my selected coordinates should respond. But what if it fails? What if– eh screw it, let's live a little.

Enter ⏎

10,000 IDs fly through my screen.

1000 not-so-milli seconds tick by. Then, I get my first blessed

You are too far from this station.

Then another. They start to trickle in, slowly at first, then suddenly flood my terminal. Ok, that's a good sign. I'm not actively unlocking the whole city. Then, another second or so pass by, until...

Bike 12539 unlocked

Jonah Hill screaming in excitement

Hellllll yeah! Oh my freaking god it worked! It actually wor–

Bike 17322 unlocked
Done.

Wait, what? Um. Ok? Two bikes got unlocked? That's strange, my Lyft membership only allows me to unlock one bike at a time. But hey, a) I didn't unlock the whole city, and b) it worked, so what do I care. Let's go find my bikes.

And there they were. Resting peacefully in their docks, but secretly not actually locked. If someone tried scanning it, they'd just see an error and try a different one. I had accomplished what I had set out to do.

The Good Days

And boy was it nice. Every morning I'd wake up, get ready for work, run my script, glance at the unlocked ID (sometimes two), leisurely stroll to the station, (re-lock the second bike if necessary), and be on my merry way.

I mostly kept this to myself, and a few trusted people including my parents, who were happy for me but nervous that I was now certainly a criminal waiting to be arrested.

But what fun, and what a pleasantly happy ending to this adventure.

Jun 21, 2019, 12:27 PM

hey ilan! super random, but are you doing anything with the lyft bikes api?

What.

Oh no.

Hey! um, potentially? Why?

oh lmao. i'm interning here and just saw a sev email about this and for some reason thought of you

Oh no.

12:54 PM

Oh lol wait is sev "severe"?

Should I stop?

It just means there was an incident lol

were you reverse engineering endpoints?

Ok I'm I don't want to get in trouble so

loll

Yeah, why?

ok yeah that's what the email was about

12:58 PM

Hmmmmm should I be nervous?

It says "potentially DoS but probably just trying to reverse engineer"

lmao

I think just stop doing it

Panic? Panic.

Hacker One

I think I spent ~two and a half minutes hyperventilating before I decided to start using my brain. I hadn't intended for this to cause an issue for Lyft: I had done the math, sanity checked with Google, and even let them know in advance. Even still, this could be interpreted maliciously and it'd be nice not to get arrested. So... what to do?

Well, how do hackers avoid getting arrested? Responsible disclosure! Companies will give bounties to people who report vulnerabilities, so hackers can keep hacking legally, and companies get to fix issues. Win-win! And maybe, just maybe, this might keep me out of jail. Win-win-win!

So I found HackerOne, and immediately a problem: Lyft's vulnerability disclosure guidelines state brute-force approaches aren't eligible. In reality, my approach wasn't bypassing anything at all – I was still unlocking a bike and paying like normal. No bugs to be reported. Although... the second bike! Definitely not normal behavior, and I wasn't getting charged for it. Let's hope it's enough to show I come in peace.

HackerOne Report

Summary: This vulnerability is specifically for the BayWheels bike sharing service. By brute-forcing the https://layer.bicyclesharing.net/mobile/v2/fgb/rent endpoint, an attacker is able to unlock more than one bicycle at a given station.

Proof of Concept: Trivial.

Steps To Reproduce:

  1. Locate relevant auth info (api-key and authorization code) from downloaded app (possibly using Charles proxy MitM).
  2. Discover rent endpoint (also using Charles proxy).
  3. Quickly send rent requests for all possible bike IDs.
  4. Retrieve bike.

Impact: An attacker could unlock more than one bike without having to go through the paywall.

(Yes I'm embarrassed to say I did actually write "trivial" because I was nervous about sharing my code.)

And now we wait. Except by sheer luck, my summer roommate was also working at Lyft (unrelated to the intern friend who messaged me), and found the thread discussing my vulnerability report. Even though Lyft could have labeled my submission ineligible due to my somewhat... unorthodox methods, their security team treated my newbie submission seriously, asked a couple follow-ups, and eventually decided to make an exception and award me a bounty of $250. They even threw in an extra $250 bonus for a "good report"!

My goodness $500 is better than jail.

In the end I did what any (relieved, not arrested) student would do with a surprise $500 and threw an absolutely stocked little house party...

...and, naturally, invited the Lyft interns.

Closing Thoughts

Two-bike unlock: While I never got confirmation, I believe the two-bike unlock issue was ultimately a race condition in the rent endpoint. Given the layer.bicyclesharing.net URL, I'm guessing Lyft inherited some legacy code during the Ford GoBikes which did not correctly handle multiple simultaneous requests from the same user. I expect they have since migrated these endpoints to their own first-party (likely more modern) backend.

Geofence bypass: As far as I understand, there's no easy way to enforce a geofence server-side other than timing, consistency, etc. You sort of just have to trust whatever the phone tells you.

So what did I learn?

  1. Even scary "physical" systems have digital interfaces you may recognize.
  2. There's few better ways to learn about a system than reverse engineering.
  3. Curiosity and determination are unreasonably effective, and seem to create luck.

If you made it to the end, you might just enjoy this stuff. So I leave you with homework: Go reverse engineer something. Just, be nice about it.

Happy hacking.

联系我们 contact @ memedata.com