IDOR / BOLA hunting - two-account methodology, identifier discovery and UUID leak chaining, the trusted-identifier test, GraphQL node and nested-object IDOR, cross-tenant escalation, write and delete operations. Bounded ID sampling, never range sweeps. Wiki-first, FIND schema output. Trigger on IDOR, BOLA, broken access control, object level authorization, cross-tenant, "read another user's data", "swap the id", or any API path or parameter carrying a numeric ID, UUID, or account identifier.
Installs into .claude/skills of the current project.
Are you the author of Hunt Idor?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/encod3d-sec-hunt-idor)
---
name: hunt-idor
description: >
IDOR / BOLA hunting - two-account methodology, identifier discovery and UUID leak chaining,
the trusted-identifier test, GraphQL node and nested-object IDOR, cross-tenant escalation,
write and delete operations. Bounded ID sampling, never range sweeps. Wiki-first, FIND schema
output. Trigger on IDOR, BOLA, broken access control, object level authorization, cross-tenant,
"read another user's data", "swap the id", or any API path or parameter carrying a numeric ID,
UUID, or account identifier.
---
# Hunt: IDOR / Broken Object Level Authorization
**Assumes `hunt-core`** for the scope gate, two-account rule, confirmation gate, enumeration
limits, stop conditions, wiki protocol, FIND output, and Deadends. Do not re-derive any of that here.
## Wiki
```
qmd_query "IDOR BOLA insecure direct object reference access control" via wiki-search MCP
```
Hub: [[web-moc]] (live web index). Primary page: [[access-control]]. Payload arsenal: `wiki/payloads/idor.md`.
Anchors: [[uuid-insecurities]] (v1-UUID timestamp/MAC when an object ID is a UUID rather than a
sequential integer), [[jwt-attacks]] (chain a trusted `sub`/`user_id` claim to ATO, hand off to hunt-auth).
## Attack surface signals
URL patterns: `/api/v1/users/{id}`, `/invoices?id=`, `/reports/{uuid}/`, `/messages/{thread_id}`,
`/admin/orgs/{org_id}/members`
GraphQL: any query or mutation taking an `id` argument. Check for `node(id: "...")` global
lookups - one endpoint reaching every object type, with per-type authorization frequently missing
on at least one.
**Rank before testing.** Not all endpoints are equally likely:
- **Newest features** - middleware follows codebase conventions; a feature written outside them
misses it.
- **Bulk and export endpoints** - one request, many objects, much higher severity per finding.
- **Anything carrying two identifiers** - see the trusted-identifier test below.
- **Cross-service internal calls** - the internal hop often carries no user context at all.
- **File and document access** - frequently a separate host or CDN with no authorization beyond
knowing the ID.
- **Non-GET verbs on read-looking endpoints** - `GET /orders/123` authorized, `PATCH` not.
## Where the identifiers are
Check all of these; the least obvious are the least protected. Path segments, query parameters,
request bodies (including nested objects), headers (`X-User-Id`, `X-Account-Id`, `X-Tenant-Id`),
cookies, JWT claims, GraphQL variables, WebSocket messages, and file/CDN URLs.
**Get B's identifiers from B.** Log in as B and read them from B's own traffic, profile page, or
JWT. Record in `identities.md`. Never obtain an identifier by incrementing until you find out
whose it is - that is enumerating real users.
**Decode opaque identifiers before assuming they are random.** Base64 and hex frequently decode
to a plain integer or a `type:id` pair. Where the ID is a hash, test whether it hashes something
knowable (email, user ID, timestamp) and compute B's offline.
## UUID: a two-part finding
A random UUID is not an authorization control, but it is a practical barrier. The finding becomes
a chain:
1. **The check is missing** - test with B's UUID, which you legitimately hold.
2. **The UUID is obtainable** - find where it leaks.
Report both together. Part 1 alone gets triaged down as "requires an unguessable identifier."
Leak sources worth working: search and autocomplete endpoints, notification and activity feeds,
team and member lists, verbose errors, notification emails, exported files, share and invite
links, and objects you *can* read that reference objects you cannot.
Also verify the UUID is actually random - v1 encodes timestamp and MAC ([[uuid-insecurities]]),
and hand-rolled implementations frequently emit sequential values dressed as UUIDs.
## Methodology
**Setup:** two accounts per `hunt-core`. A owns, B attacks. Separate profiles.
**Drive it through Burp** for operator visibility. With A's request captured in Repeater or proxy
history:
```
scripts/burp/idor-sweep.py <eng> <reqfile> --attacker-auth "Cookie: session=USER_B" --range 5
```
Sends owner-baseline / idor-test / bounded id sample through `send_http1_request`, diffs status
and body, prints a verdict and a ready `capture.sh burp` PoC line. Honors `no_bruteforce` ->
range 0. **Do not raise `--range` above the `hunt-core` limits without operator approval.**
For GUI-visible replay: `create_repeater_tab` for an A tab and a B tab, or `send_to_intruder`
with a bounded number payload.
1. **Log in as A, browse every feature, note every ID** - object, UUID, org, invoice, thread.
2. **Baseline both directions.** A requests A's object. B requests B's object. Record status,
length, body shape. You are testing against these, not against intuition.
3. **Cross-request:** B requests A's object with B's session.
```bash
# Baseline - A owns it
curl -s -H "Cookie: session=USER_A" https://target.com/api/v1/invoices/12345
# Cross-account - B attempts
curl -s -H "Cookie: session=USER_B" https://target.com/api/v1/invoices/12345
```
4. **Rule out legitimate access before claiming anything.** Is the object shared with B, in a
shared team, public, or org-visible? Check the UI as B. This is the single most common false
positive in this class - an app with sharing features generates them constantly.
5. **Trusted-identifier test - run this on every endpoint carrying two identifiers.** The highest
value test in IDOR and the most frequently skipped. Make the session and the parameter
disagree:
```
Session: account B
Body: {"user_id": "<A's id>", "action": "..."}
```
- Server acts on B -> identity derived from session, parameter ignored. Correct.
- Server acts on A -> **the parameter is trusted.** IDOR, usually critical, fully
attacker-controlled.
- Server errors on mismatch -> it compares them. Correct, and evasion is the next step.
Apply the same to JWT claims: modify `sub` / `user_id` and see whether the server derives the
acting user from the claim or looks it up. Trusted claim plus weak signature chains to arbitrary
account takeover - hand off to `hunt-auth`.
6. **All verbs.** GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Authorization applied per-method
rather than per-resource is common.
7. **When the direct swap 403s, it is not closed.** Parameter pollution (`?id=A&id=B` - the check
and the fetch may read different occurrences), array wrapping (`{"id":["A","B"]}`), nested
wrapping (`{"user":{"id":"B"}}`), path traversal in the identifier, encoding and case variation,
**version downgrade** (`/v1/` predates the middleware `/v2/` has - the most reliable of these),
and batch endpoints where per-item authorization is missing.
8. **Cross-tenant.** Two accounts in *different* orgs. Categorically more severe than
user-to-user inside one tenant: it breaks the isolation the product is sold on and implicates
every customer. Say "cross-tenant" in the title - it changes who reads the report.
9. **GraphQL.** Nested traversal is the signature bug - the entry point is authorized, the
resolvers are not:
```graphql
query { me { organization { members { id email phone } } } }
```
Walk every edge from an authorized root. Also test `node(id:)` global lookups, aliases for
parallel object access in one request, and mutations that introspection reveals but the UI never
calls.
10. **Write and delete.** More severe, more dangerous. Test on your own objects first to learn the
request shape. **Prefer reversible operations** - changing B's display name proves it;
deleting B's account proves the same and destroys your setup. Confirm from B's side.
**Never test a destructive write against an identifier you have not confirmed is B's.**
11. **Bounded ID sampling - never a range sweep.** Per `hunt-core`: five identifiers by default,
twenty ceiling with approval, zero under `no_bruteforce`. Two or three adjacent IDs establish
a sequential pattern; that is the whole proof. For scale, cite the `total` or pagination
count, not retrieved records.
```bash
# Bounded sample around a known ID - NOT a range sweep
known=48291
for i in $(seq $((known-2)) $((known+2))); do
printf '%s ' "$i"
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H "Authorization: Bearer USER_B_TOKEN" \
"https://target.com/api/v1/orders/$i"
done
```
12. **Automate the breadth, verify every flag by hand.** Session-replay tooling (Autorize-style)
replaying every request under B's session while you browse as A turns this into a background
pass. It produces false positives on endpoints returning identical public content - it narrows
the queue, it does not produce findings.
13. **Distill when confirmed** - reusable GraphQL IDOR or UUID-bypass technique, GENERIC, no
client host: `python3 scripts/wiki-stage.py --kind technique --slug <slug> --target-page techniques/web/access-control.md`
## Confirmation gate
**NOT confirmation:** a `200` with an empty or shell response; the object echoed back from your
own request; B seeing an object that is shared, public, or org-visible; a response you have not
compared against A's baseline; a write returning `200` with no state change verified from B's
side.
**IS confirmation:** B's session returns A's *data*, matching A's own baseline response, with A's
legitimate-access ruled out, reproduced in a clean session - and for writes, the change visible
in B's UI.
## Severity
Rated on the object, not the mechanism.
| Object | Typical |
|---|---|
| Session token, API key, reset token | critical - direct ATO |
| Full user records with PII | critical / high |
| Payment or billing detail | critical / high |
| Private documents, messages | high |
| Account settings (write) | high - enables takeover via email change |
| Internal identifiers only | low - enables other attacks |
**Write outranks read** at the same object. **Unauthenticated outranks authenticated** by a full
band. **Cross-tenant outranks cross-user.** An unguessable identifier you cannot show leaking
lowers it - chase the leak first.
## Deadends
```
Append: - [ ] IDOR on <host> <endpoint> -- 403/404 cross-account, authorization enforced;
tried pollution/array/verb/version downgrade
```
Record what you tried, not just that it failed. The next pass needs to know the boundary.