Skip to main content
Educational Purpose Only - IDOR attacks violate user privacy and can expose sensitive personal information. Only test on authorized systems.

Overview

Insecure Direct Object Reference (IDOR) is an access control vulnerability where an application exposes references to internal objects (like database keys) and fails to verify that users are authorized to access those objects. In this demo, the profile page allows any authenticated user to view any other user’s profile by simply changing the id parameter in the URL.

Severity Rating

MEDIUM to HIGH - CVSS Score: 6.5/10CWE Reference: CWE-639: Authorization Bypass Through User-Controlled Key

Vulnerable Code

The vulnerability exists in the profile route (vulnerable/app.py:89-110):
@app.route('/profile')
def profile():
    if 'user_id' not in session:
        flash('Debes iniciar sesión', 'warning')
        return redirect('/login')
    
    # VULNERABLE: No authorization check
    # Takes user_id from URL parameter without verification
    user_id = request.args.get('id', session['user_id'])
    
    connection = create_connection()
    cursor = connection.cursor()
    
    try:
        # VULNERABLE: Also uses string formatting (SQL injection risk)
        cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
        user = cursor.fetchone()
    except sqlite3.Error as e:
        flash(f'Error: {str(e)}', 'danger')
        user = None
    finally:
        cursor.close()
        connection.close()
    
    return render_template('profile.html', user=user)

Why This Is Dangerous

  1. No Authorization Check: The application checks authentication (if user is logged in) but not authorization (if user can access this specific profile)
  2. User-Controlled Object Reference: The id parameter comes directly from user input (request.args.get('id'))
  3. Predictable IDs: Sequential integer IDs (1, 2, 3…) are easy to enumerate
  4. No Access Control Logic: Missing validation like “can user A access user B’s profile?”
  5. Full Data Exposure: Returns complete user record including password hash

Exploitation Steps

1

Authenticate as a Regular User

Log in with a non-admin account:
URL: https://auth-vulnerable.onrender.com/login
Username: usuario
Password: password123
Note your user ID (visible in your profile URL).
2

Access Your Own Profile

Navigate to your profile:
https://auth-vulnerable.onrender.com/profile
Or explicitly:
https://auth-vulnerable.onrender.com/profile?id=2
This works as expected - you see your own information.
3

Modify the ID Parameter

Change the id parameter to access another user’s profile:
https://auth-vulnerable.onrender.com/profile?id=1
Result: You can now see the admin user’s profile, including:
  • Username
  • Email address
  • Role (admin)
  • Password hash
  • Account creation date
4

Extract Sensitive Information

Once you have access, you can extract:
  • Email addresses (for phishing)
  • Usernames
  • Role information (identify admins)
  • Password hashes (for offline cracking)
  • Account metadata

Impact Analysis

Confirmed in Demo

  • Unauthorized profile access
  • User enumeration
  • Email disclosure
  • Password hash exposure
  • Role/privilege information leak

Real-World Risks

  • Privacy violations (GDPR, CCPA)
  • Identity theft
  • Account takeover
  • Privilege escalation
  • Data scraping
  • Targeted phishing

IDOR Variants

Pattern: Sequential or predictable identifiers
# Vulnerable
user_id = request.args.get('id')
user = get_user(user_id)  # No authorization check
Examples:
  • /profile?id=123
  • /invoice?invoice_id=456
  • /document?doc_id=789
Pattern: Application-generated references (GUIDs, hashes)
# Still vulnerable without authorization
file_token = request.args.get('token')  # UUID
file = get_file_by_token(file_token)
# If no ownership check, still vulnerable
Note: Non-sequential IDs don’t prevent IDOR, they just make it harder to exploit.
Pattern: Modifying unauthorized fields
# Vulnerable user update
@app.route('/update_profile', methods=['POST'])
def update_profile():
    user = get_current_user()
    # Blindly accepting all form fields
    user.update(request.form)  # VULNERABLE
    user.save()
Attack:
<form method="POST" action="/update_profile">
    <input name="email" value="[email protected]">
    <input name="role" value="admin">  <!-- Escalate privilege -->
    <input name="balance" value="999999">  <!-- Modify balance -->
</form>
Pattern: Object references in POST/PUT request bodies
POST /api/transfer
{
    "from_account": "123",
    "to_account": "456",
    "amount": 1000
}
Attacker changes from_account to another user’s account ID.

Secure Implementation

The secure version implements proper authorization (secure/app.py:139-179):
@app.route('/profile')
def profile():
    # SECURE: Authentication check
    if 'user_id' not in session:
        flash('Debes iniciar sesión', 'warning')
        return redirect('/login')
    
    # Get requested profile ID
    requested_id = request.args.get('id', session['user_id'])
    
    # SECURE: Input validation
    try:
        requested_id = int(requested_id)
    except ValueError:
        flash('ID inválido', 'danger')
        return redirect('/dashboard')
    
    # SECURE: Authorization check - users can only view their own profile
    # unless they are admins
    if requested_id != session['user_id'] and session.get('role') != 'admin':
        flash('No tienes permiso para ver este perfil', 'danger')
        return redirect('/dashboard')
    
    connection = create_connection()
    if not connection:
        flash('Error de conexión', 'danger')
        return redirect('/dashboard')
    
    cursor = connection.cursor()
    
    # SECURE: Prepared statement + Limited data exposure
    query = "SELECT id, username, email, role, created_at FROM users WHERE id = %s"
    cursor.execute(query, (requested_id,))
    user = cursor.fetchone()
    
    cursor.close()
    connection.close()
    
    if not user:
        flash('Usuario no encontrado', 'danger')
        return redirect('/dashboard')
    
    return render_template('profile.html', user=user)

Mitigation Strategies

Always verify that the current user is authorized to access the requested resource:
def check_authorization(current_user_id, requested_user_id, required_role=None):
    # Check if user is accessing their own resource
    if current_user_id == requested_user_id:
        return True
    
    # Check if user has required role (e.g., admin)
    if required_role and current_user.role == required_role:
        return True
    
    # Access denied
    return False

# Usage
if not check_authorization(session['user_id'], requested_id, 'admin'):
    abort(403)  # Forbidden
Map direct object references to user-specific indirect references:
# Create session-specific mapping
session['document_refs'] = {
    'a7f3b': 123,  # Internal ID
    'c9e2d': 456,
    'f1a8b': 789
}

# User requests /document?ref=a7f3b
doc_ref = request.args.get('ref')
if doc_ref not in session['document_refs']:
    abort(403)

internal_id = session['document_refs'][doc_ref]
document = get_document(internal_id)
Maintain explicit ownership/permission records:
# Database schema
CREATE TABLE resource_permissions (
    user_id INT,
    resource_id INT,
    permission VARCHAR(20),  -- 'read', 'write', 'delete'
    PRIMARY KEY (user_id, resource_id, permission)
);

# Check permission
def has_permission(user_id, resource_id, permission='read'):
    query = """
        SELECT 1 FROM resource_permissions 
        WHERE user_id = %s AND resource_id = %s AND permission = %s
    """
    result = db.execute(query, (user_id, resource_id, permission))
    return result is not None
While not a complete fix, UUIDs make enumeration harder:
import uuid

# In database schema
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(50),
    ...
);

# URLs become
/profile?id=f47ac10b-58cc-4372-a567-0e02b2c3d479

# Still need authorization checks!
Always include ownership in queries:
# Bad: Fetches any document by ID
query = "SELECT * FROM documents WHERE id = %s"
cursor.execute(query, (doc_id,))

# Good: Only fetches documents owned by current user
query = "SELECT * FROM documents WHERE id = %s AND owner_id = %s"
cursor.execute(query, (doc_id, current_user_id))
Prevent automated enumeration attacks:
from flask_limiter import Limiter

limiter = Limiter(
    app,
    key_func=lambda: session.get('user_id'),
    default_limits=["100 per hour"]
)

@app.route('/profile')
@limiter.limit("10 per minute")
def profile():
    # ...

Testing for IDOR

Manual Testing Checklist

  • Identify all endpoints that accept object IDs (user IDs, document IDs, etc.)
  • Create two test accounts (user A and user B)
  • Log in as user A and note their resource IDs
  • Log in as user B and try accessing user A’s resources
  • Test all HTTP methods: GET, POST, PUT, DELETE
  • Test both URL parameters and request body parameters
  • Check if sequential ID enumeration is possible

Burp Suite Workflow

1

Intercept Legitimate Request

GET /profile?id=2 HTTP/1.1
Host: target.com
Cookie: session=user2_session_token
2

Send to Intruder

Configure payload position:
GET /profile?id=§1§ HTTP/1.1
Set payload type to “Numbers” (1-100)
3

Analyze Results

Look for:
  • 200 OK responses (successful access)
  • Different response lengths
  • Emails, usernames, or other data in responses

Automated Testing

import requests
import json

def test_idor(base_url, auth_tokens):
    """
    Test IDOR vulnerability with two different user accounts.
    
    auth_tokens: {'user1': 'token1', 'user2': 'token2'}
    """
    results = []
    
    # Test with user1's token trying to access user2's resources
    for resource_id in range(1, 100):
        headers = {'Authorization': f'Bearer {auth_tokens["user1"]}'}
        response = requests.get(
            f'{base_url}/profile?id={resource_id}',
            headers=headers
        )
        
        if response.status_code == 200:
            data = response.json()
            if data.get('user_id') != 1:  # user1's ID
                results.append({
                    'vulnerable': True,
                    'accessed_id': resource_id,
                    'data': data
                })
                print(f"[!] IDOR found: User 1 accessed resource {resource_id}")
    
    return results

Real-World Examples

Uber IDOR (2016)

Issue: Trip receipts accessible via predictable UUIDsImpact: Any user could view other users’ trip history, locations, namesBounty: $10,000

Facebook IDOR (2013)

Issue: Graph API allowed access to private photosImpact: Could view photos not visible on profileFix: Enhanced authorization checks

Instagram IDOR (2019)

Issue: Delete any photo via direct object referenceImpact: Could delete other users’ photosBounty: $10,000

USPS IDOR (2018)

Issue: Access anyone’s account detailsImpact: 60 million user accounts exposedResult: Massive privacy breach

References

Next Steps

Related vulnerabilities:

Build docs developers (and LLMs) love