September 3, 2026
I Counted to 2 Million and a Company’s Payment Data Fell Out
A short bug bounty story about lazy numbers, a cloud bucket with no pants on, and why “nobody will guess it” is not a security strategy.
By Fauzi Agusti
4 min read
Boring but important disclaimer: everything here is anonymized. The target is a big consumer company that ran a legit research program I'll call it Redacted Corp because my lawyer likes me. Domains, IDs, tokens, and personal data are all fake or blacked out. Both bugs were reported and fixed. Nothing here is a treasure map. Relax.
TL;DR for people who don't scroll
Two bugs, one free account:
- Bug #1 (High): the payment API gave me anyone's payment info if I just changed a number in the URL. The numbers went 1, 2, 3 …. You see where this is going.
- Bug #2 (Critical): a Firebase storage bucket full of customers' (national ID) photos was wide open to the entire internet. No login. No password. No shame.
Put them together and you get a stranger's payments and a photo of their government ID. Fun for the whole family
Step 1: make the world's most boring account
I signed up for a normal free account. Zero payments, zero privileges the same account any of their millions of customers has. I checked my profile just to be sure:
http
GET /api/web/profilehttp
GET /api/web/profilePerfect. I own nothing. So from here on, if I can see a payment, it definitely isn't mine. Great alibi..
Bug 1: the number that snitched
The suspicious endpoint:
GET /api/web/reservations/payment-status/{id}GET /api/web/reservations/payment-status/{id}An ID in the URL. My two favorite questions:
- Is the ID a scary random UUID, or a friendly little integer? (It was
500000. Friendly.) - Does the server check if I actually own this thing, or just that I'm logged in?
Dropping my token gave a 401, so login was enforced. But here's the classic mixup: being logged in ≠ being allowed. It's the difference between having a hotel key card and having the key card for that specific room. One of those the app checked. Guess which one. So I asked for a payment I had no business seeing:
/api/web/reservations/payment-status/500000 Authorization: Bearer [my account — owns nothing, remember]
And the server went "sure, king":
{ "payment": {
"id": 500000,
"amount": 8000000,
"type_text": "Bca",
"status_text": "Completed",
"invoice_url": "https://checkout.[processor].co/...",
"user_ip_address": "112.215.[REDACTED]"
}}{ "payment": {
"id": 500000,
"amount": 8000000,
"type_text": "Bca",
"status_text": "Completed",
"invoice_url": "https://checkout.[processor].co/...",
"user_ip_address": "112.215.[REDACTED]"
}}That's an 8,000,000 IDR transaction belonging to a complete stranger, handed to an account that owns nothing. Show Image Changing the ID returned someone
This is IDOR / BOLA the number one item on the OWASP API list, and apparently number zero on their to-do list. One hit could be luck, so I tried a few more numbers. 1600000? Someone else's payment. 2100500? A third stranger. The IDs just counted upward, and the highest I confirmed was north of 2.1 million. Which means the "security" protecting two million payment records was... me not being able to count. Except I can. I have a forloop.
Meanwhile a neighboring endpoint, reservation/{id}, did it right — ask for something you don't own, get a 404. Same app, same login, correct behavior. So payment-status wasn't following the rules; it was the one kid who didn't do the group project.
What leaked: every customer's payment amount, method, date, IP address, and live invoice links. All from a free account and the ability to add one.
Bug 2: the bucket with the door wide open
While looking at where the app stored files, I spotted Firebase (Google's cloud backend). The bucket looked like redacted-xxxxx.appspot.com.
Here's the trick that makes this whole thing work Firebase Storage has two front doors.
- The Google Cloud Storage door, guarded by IAM permissions.
- The Firebase Storage door, guarded by Firebase Security Rules a totally separate bouncer.
I knocked on the first door 403. Locked. Nice. Most people would go home happy here. But the second door has its own bouncer who's never heard of the first one so I knocked there, with no account and no token at all
curl -s "https://firebasestorage.googleapis.com/v0/b/redacted-xxxxx.appspot.com/o?maxResults=20"curl -s "https://firebasestorage.googleapis.com/v0/b/redacted-xxxxx.appspot.com/o?maxResults=20"HTTP response 200. It just... listed the files. To a random stranger. And nestled in the list
app-identity-card/1739…jpg app-identity-card/1752…jpg … (many, many more, with a nextPageToken for extra dread) …
app-identity. Yeah. Customer ID cards. And they weren't just listed I could grab one:
curl -s "...app-identity-card%2F<object>.jpg?alt=media" -o evidence.jpgcurl -s "...app-identity-card%2F<object>.jpg?alt=media" -o evidence.jpgHTTP 200, and a real customer's id landed on my disk full name, NIK, address, date of birth, photo, the whole identity starter pack. No login. No token. Just vibes.
I downloaded exactly one, confirmed it was a real KTP, redacted it, and closed the tab. I'd proven all three things I could list, read metadata, and download and hoarding a million ID cards is how you turn a bug bounty into a court date.
Root cause: they locked the IAM door and assumed the building was secure. But Firebase Security Rules were basically allow read: if true;the digital equivalent of a "PLEASE DON'T STEAL" sticky note. IAM doesn't guard that door. Nobody did.
Why two "mediums" make a very bad day
Bug #1 tells you what someone paid. Bug #2 shows you their government ID. Stack them and you can rebuild a stranger's financial + legal identity from your couch. photos are sensitive data under UU PDP, so a public bucket full of them isn't a cute bug it's a reportable breach. That's the sentence that turns "we'll fix it next sprint" into "everyone in the war room, now."
Both bugs were reported through the target's program and fixed. If you run an API go check whether your object lookups are scoped to the user, and whether your buckets are locked on every door not just the one you remembered. Do it before someone with a for loop and free time does it for you.
Thanks for reading.