Hacking Voi Scooters: How I Created $100k Worth of Free Rides
27 Sept 2019
The scooter epidemic has taken over Stockholm - we currently have 9 different brands trying to win the market! That is completely ridiculous… I tried exploit their promo codes to get unlimited free rides (or rather, until they run out of VC money). Long story short, I now have $100k worth of Voi credits.
Timeline
15 Sept 2019: I discovered the issues described below. 16 Sept 2019: I reached out to Voi and talked to one of the founders. Voi's tech team said they would get back to me to understand more about what I actually did, but I haven't heard anything from them. 1 Jan 2020: this loophole has been fixed!
Parts:
- Background
- Reverse-engineering APIs
- Generating Unlimited Promo Codes
- Conclusion
- Further Investigation
Background
I had nothing to do one Sunday evening, so I played around with reverse-engineering the different scooter apps' APIs to see if I could find any loopholes. One would think that the more well-funded companies had thought more about their tech and would be good at preventing fraud. However, that isn't the case. The Stockholm market-leader Voi, which has raised $83M, seems to have the weakest tech of them all. After 3 hours, I managed to create credits on rides worth $100k. Thank you VCs for funding my rides! 💸
One would think that the more well-funded companies had thought more about their tech and would be good at preventing fraud. However, that isn't the case.
Reverse-engineering APIs
The first step was reverse engineering the APIs. None of the scooter apps had any advanced protection - simply proxying all traffic through Charles Proxy did the trick.
🚨 Potential improvement: use SSL pinning. It's not that much more protection (it can easily be circumvented with a jailbroken iPhone), but it adds another hurdle and requires a more committed effort.
Voi's signup flow looks like this:
- Sign up (ONLY an email address is required)
- Email verification
- Add payment information
- Enter promo codes
What we want is getting to 4, since all we want free rides. Here are the different requests (using Node's request):
1. Sign up
// 1. Create an account
request.post({
url: 'https://api.voiapp.io/v1/auth/sso/signup',
body: {
email: 'your@email.com',
},
json: true,
});
// => { "authenticationToken": "jwt-string" }
// 2. Create a session
request.post({
url: 'https://api.voiapp.io/v1/auth/session',
body: {
authenticationToken: 'jwt-string', // the token from the previous step
},
json: true,
});
// => { "authenticationToken": "jwt-string", "accessToken": "jwt-string" }
They don't seem to do much email validation at all. Using GMail, it's possible to have an unlimited number of email addresses like so:
david+1@gmail.com
david+2@gmail.com
Voi does not associate those two emails - they are treated as separate accounts.
🚨 Potential improvement: block different GMail emails from the same Google user. 🤫 This is easy to circumvent by having your own domain where you eg can forward all emails to the same GMail account.
🚨 Potential improvement: require more information than just an email address. Most other scooter companies require a phone number, which is more expensive to generate.
2. Email verification
In the app UI, email verification is required. However, the API does not care about this.
🚨 Potential improvement: on the backend, require email verification for further actions.
3. Add payment information
For payments, Voi uses Stripe. The app UI forces you to add payment information before adding promo codes. What about the API? Nope, no protection. So it's possible to skip this step. Also, they allow multiple accounts to use the same credit card.
🚨 Potential improvement: on the backend, require payment information before adding promo codes.
🚨 Potential improvement: prevent different users from using the same credit card. 🤫 This is easy to circumvent by generating unlimited virtual credit cards.
4. Enter promo codes
Here we need to figure out a way to generate valid promo codes. This is a bit trickier since promo codes can only be used once. We will get to this in a bit. However, once we have a promo code it's easy to use it:
request.post({
url: 'https://api.voiapp.io/v1/vouchers/redeem/YOUR+PROMOCODE',
headers: {
// token received when creating the session above
'x-access-token': accessToken,
},
json: true,
});
// => { "result": "redeemed" }
So after these 3 requests, we have a brand new Voi account preloaded with credits. The only missing piece is finding promo codes.
Generating Unlimited Promo Codes
Voi uses many partners to distribute promo codes. In Sweden, they work with SJ (Swedish Railroads), Bumble and Revolut among others. Each promo code is worth 6 Voi credits (60 SEK, ~$6).
The process to get a promo code through SJ works like this:
- Visit SJ's website and press the link "Hämta personlig kod". You will be redirected to sj.mkt.voiapp.io
- Submit an email address
- Promo code is sent to the email
The SJ promotion is active until September 30th. So until then, this will work. Pro tip: generate a few thousand promo codes and you'll be set for life.
Here is another promo code that can be combined with the ones above:
IMPACTWEEKXVOI
(5 credits, valid to activate through Sept 16 - 22)
Let's break down the steps:
1. Visit SJ's website
Why can't we go to sj.mkt.voiapp.io right away? Because that website assumes that a special _sj_session
cookie is set. Otherwise, Voi will not show the website. That's a good try to prevent people like from exploiting this, however it's possible to reuse the same cookie unlimited times.
🚨 Potential improvement: prevent using the same session cookie multiple times.
3. Submit an email address
Here we can reuse GMail emails like above. Also, the form requires that an authenticity_token
is submitted. This is related to Ruby on Rails and CSFR. It's possible to reuse the same token unlimited times.
🚨 Potential improvement: block different GMail emails from the same Google user.
🚨 Potential improvement: make sure the authenticity_token
can only be used once.
🚨 Potential improvement: rate-limit this form. I've sent the same request 1000s of times in a row from the same IP…
This is what a request would look like using Node:
request.post({
url: 'https://sj.mkt.voiapp.io/submit',
form: {
email: 'your@email.com',
// Try this once in the browser to get a token. It can be reused...
authenticity_token: 'some-token',
},
headers: {
// Same with this cookie
cookie: '_sj_session=some-cookie',
},
});
4. Promo code is sent to the email
It's easy to fetch emails through GMail's API and parse out voucher code like so:
// This matches promo codes like "SJ+ABCD1234"
const regex = /SJ\+[A-Z0-9]+/g;
const voucher = emailContent.match(regex)[0];
🚨 Potential improvement: don't email the promo codes in plain text. Maybe show them as an image. This would be terrible for UX though… It could also be built as a deep link into the app, which might be harder to reverse-engineer.
Conclusion
What I have created is a list of thousands of accounts loaded with 11 Voi credits. Whenever I've used up all credits on one account, I simply sign out and in again with a different email. When there are multiple promotions going on at the same time (eg Revolut, Bumble, ImpactWeek) it's possible to get more credits on the same account, which reduces the need to switch accounts. However, it's still kind of crazy that it's possible to create a lifetime of credits within just a few hours.
Further Investigation
I wouldn't be surprised if there are more holes in the system. An educated guess would be that it's easy to create an infinite referral loop:
- Register an account
- Copy an invite link
- Create a new account
- Ride a short ride using the 2.5 credits given through the referral program
- When the referred user has ridden, the referrer also gets 2.5 credits
- Repeat until there are 1000s of credits on the referrer account
However, the referral feature seems to be somewhat buggy right now. I've talked to support about why it's not working and haven't heard back yet. So a broken referral program is great for fraud prevention I guess…
Anyway, that's for another day. Voi, I'd be more than happy to help - just reach out! 🛴