Writing a Custom Burp Suite Extension to Fuzz GraphQL Endpoints for API Vulnerabilities

Crafting a Burp Suite Extension for GraphQL Fuzzing

Targeting GraphQL endpoints for API vulnerabilities requires specialized tools beyond standard HTTP request manipulation. Building a custom Burp Suite extension provides the necessary flexibility to understand GraphQL query structures, inject targeted payloads, and identify weaknesses like injection flaws, excessive data exposure, or insecure direct object references (IDORs).

Initial Setup: Burp Suite and Jython

Before coding, ensure your Burp Suite Professional instance is configured for Jython. Navigate to Extender -> Options, locate the "Python Environment" section, and point "Location of Jython standalone JAR file" to your Jython installation. Extensions are loaded via Extender -> Extensions -> Add, selecting "Python" as the extension type and pointing to your script file.

Our extension will implement the IBurpExtender and IHttpListener interfaces. IBurpExtender is for registration and callback setup, while IHttpListener processes every HTTP request/response passing through Burp.


from burp import IBurpExtender
from burp import IHttpListener
from burp import IHttpRequestResponse
from burp import IHttpService

import json
import re

class BurpExtender(IBurpExtender, IHttpListener):

    EXTENSION_NAME = "GraphQL Fuzzer"
    TARGET_GRAPHQL_PATH = "/graphql" # Adjust as needed
    FUZZ_PAYLOADS = [
        "' OR 1=1 --",
        "\" OR 1=1 --",
        "UNION SELECT NULL,NULL,NULL--",
        "<script>alert(1)</script>",
        "../../../../etc/passwd",
        "{ $regex: '.*' }",
        "DROP TABLE users;"
    ]
    
    # GraphQL field manipulation payloads for excessive data exposure
    FIELD_ADDITIONS = [
        "password", "email", "ssn", "creditCardNumber", "internalId"
    ]

    def registerExtenderCallbacks(self, callbacks):
        self._callbacks = callbacks
        self._helpers = callbacks.getHelpers()
        callbacks.setExtensionName(self.EXTENSION_NAME)
        callbacks.registerHttpListener(self)
        callbacks.printOutput(self.EXTENSION_NAME + " loaded successfully.")
        
        self.output_log = callbacks.get  Output()
        self.error_log = callbacks.getStderr()
        
        self.output_log.write("GraphQL Fuzzer Ready. Target path: " + self.TARGET_GRAPHQL_PATH + "\n")

    def processHttpMessage(self, toolFlag, messageIsRequest, messageInfo):
        if toolFlag == self._callbacks.TOOL_PROXY and messageIsRequest:
            requestInfo = self._helpers.analyzeRequest(messageInfo)
            url = requestInfo.getUrl()
            
            if self.TARGET_GRAPHQL_PATH in url.getPath():
                self.output_log.write("Intercepted potential GraphQL request to: " + str(url) + "\n")
                
                request_bytes = messageInfo.getRequest()
                request_body = self._helpers.bytesToString(request_bytes[requestInfo.getBodyOffset():])
                
                # Check for POST requests with JSON body
                if requestInfo.getMethod() == "POST" and "application/json" in requestInfo.getHeaders():
                    try:
                        json_body = json.loads(request_body)
                        if "query" in json_body:
                            graphql_query = json_body["query"]
                            self.output_log.write("Original GraphQL Query: " + graphql_query[:100] + "...\n")
                            
                            # Fuzzing GraphQL arguments
                            fuzzed_queries_args = self.fuzz_graphql_arguments(graphql_query)
                            for fuzzed_query in fuzzed_queries_args:
                                self.send_fuzzed_request(messageInfo, fuzzed_query, "ARG_FUZZ")
                                
                            # Fuzzing for excessive data exposure
                            fuzzed_queries_fields = self.fuzz_graphql_fields(graphql_query)
                            for fuzzed_query in fuzzed_queries_fields:
                                self.send_fuzzed_request(messageInfo, fuzzed_query, "FIELD_FUZZ")
                                
                    except ValueError as e:
                        self.error_log.write("Error parsing JSON body for GraphQL: " + str(e) + "\n")
                elif requestInfo.getMethod() == "GET" and "query=" in url.getQuery():
                    # Basic GET query parameter fuzzing (less common for complex GraphQL)
                    self.output_log.write("GET GraphQL queries not fully supported by this fuzzer for now.\n")

    def fuzz_graphql_arguments(self, graphql_query):
        fuzzed_queries = []
        # Regex to find arguments: (argName: "argValue") or (argName: argValue)
        # This is a simplified regex, real-world might need a proper parser
        arg_pattern = re.compile(r'(\w+:\s*["\']?)([^"\'\s,{}()]+)(["\']?,?)')
        
        for payload in self.FUZZ_PAYLOADS:
            def replace_arg(match):
                prefix = match.group(1)
                value = match.group(2)
                suffix = match.group(3)
                
                # Attempt to preserve quotes if original value had them, but inject the payload directly
                if prefix.endswith('"') or prefix.endswith("'"):
                    return f"{prefix}{payload}{suffix}"
                else:
                    return f"{prefix}{payload}{suffix}" # For non-quoted values like integers or booleans
            
            fuzzed_query = arg_pattern.sub(replace_arg, graphql_query)
            if fuzzed_query != graphql_query:
                fuzzed_queries.append(fuzzed_query)
        return fuzzed_queries

    def fuzz_graphql_fields(self, graphql_query):
        fuzzed_queries = []
        # This is an extremely simplified approach. A proper AST parser would be better.
        # This regex tries to find the closing brace of a selection set and injects new fields.
        # It's highly dependent on query formatting.
        field_set_pattern = re.compile(r'({[\s\w,]+\s*})') 

        # Example: Find a simple field list like { id name } and try to add more
        # This is more robust for simple top-level fields
        # Look for 'query { field1 field2 }' or 'mutation { field1 field2 }'
        # Or even '{ field1 field2 }' within a larger query.
        # A more effective approach would be to insert after existing fields, or before a closing brace
        
        # This simple example tries to insert into top-level query/mutation blocks
        # e.g., 'query { users { id name } }' -> 'query { users { id name password } }'
        
        # For simplicity, we'll try to append to the end of the first found selection set
        # This is very fragile. In a real-world scenario, you'd parse the AST.
        
        for field_to_add in self.FIELD_ADDITIONS:
            # Find the first closing curly brace that is not immediately followed by another
            # For queries like `query { user { id name } }`, it should target the `}` after `name`
            # This is a hacky attempt. Proper parsing is required for reliability.
            modified_query = graphql_query
            
            # Find the last closing brace of the main query block
            match = re.search(r'}\s*$', graphql_query.strip())
            if match:
                # Insert field before the very last closing brace of the query
                # This assumes a well-formed query that allows top-level field injection.
                # E.g., `query { user(id:1) { id name } }` -> `query { user(id:1) { id name password } }`
                # A more general approach is to find any `{ ... }` block and try to inject.
                
                # For basic example, let's try to append to the first selection set found
                # Example: `query { users { id name } }` -> `query { users { id name, password } }`
                # This needs careful regex to avoid breaking syntax.
                
                # Let's try to insert `,{field_to_add}` before the last `}` in a selection set.
                # This pattern targets `}` only if it's preceded by whitespace or a field name.
                # e.g. `id name }` becomes `id name ,password }`
                
                # This regex aims to target the closing brace `}` of a field selection set.
                # It looks for `}` preceded by an optional comma, whitespace, or another field.
                # Then it tries to insert the new field before it.
                # This is a heuristic and will likely fail on complex queries.
                
                # Simpler: find the *last* closing brace of *any* object selection that has content
                # e.g., `user { id name }` and inject into `id name`
                
                # For this demonstration, we will simplify heavily:
                # Find the *first* instance of `{ field }` or `{field}` and try to insert.
                # This is very naive.
                
                # Example: `query { user(id: 1) { id name } }`
                # Target the `}` after `name`.
                
                # Find the innermost closing brace that is preceded by a field
                # This is still a weak heuristic.
                
                # A more practical approach for an extension without a full parser:
                # Look for patterns like `field { ... }` or `{ field1, field2 }`.
                # Then insert `field_to_add` inside the inner curly braces.
                
                # Let's target the *last* occurrence of `}` that is part of a selection set.
                # This could be at the end of the whole query or an inner object.
                # This will break on introspection queries.
                
                # A robust approach: parse the query. Since we're avoiding full AST parsing for a quick script,
                # let's assume we want to inject into the primary selection set.
                # If the query is like `query { user(id:1) { id name } }`, we want to modify `user { id name }`.
                
                # Simpler, less error-prone example: if `query { something }` add to `something`
                # This is for top-level fields primarily.
                
                # Find the last '}' that closes a selection set
                idx = graphql_query.rfind('}')
                if idx != -1:
                    # Insert before the last closing brace
                    fuzzed_query = graphql_query[:idx] + f", {field_to_add}" + graphql_query[idx:]
                    fuzzed_queries.append(fuzzed_query)
        return fuzzed_queries


    def send_fuzzed_request(self, original_messageInfo, fuzzed_graphql_query, fuzz_type):
        httpService = original_messageInfo.getHttpService()
        original_requestInfo = self._helpers.analyzeRequest(original_messageInfo)
        
        # Reconstruct JSON body with fuzzed query
        request_body_offset = original_requestInfo.getBodyOffset()
        original_request_bytes = original_messageInfo.getRequest()
        original_request_body_bytes = original_request_bytes[request_body_offset:]
        original_request_body_str = self._helpers.bytesToString(original_request_body_bytes)
        
        try:
            json_body_obj = json.loads(original_request_body_str)
            json_body_obj["query"] = fuzzed_graphql_query
            fuzzed_request_body = self._helpers.stringToBytes(json.dumps(json_body_obj))
            
            # Rebuild request with new body
            headers = original_requestInfo.getHeaders()
            # Update Content-Length header if it exists
            new_headers = []
            content_length_set = False
            for header in headers:
                if header.lower().startswith("content-length:"):
                    new_headers.append(f"Content-Length: {len(fuzzed_request_body)}")
                    content_length_set = True
                else:
                    new_headers.append(header)
            
            if not content_length_set:
                new_headers.append(f"Content-Length: {len(fuzzed_request_body)}")

            fuzzed_request = self._helpers.buildHttpMessage(new_headers, fuzzed_request_body)
            
            # Send the request and add it to Burp's history
            # Using _callbacks.makeHttpRequest for logging and history
            new_messageInfo = self._callbacks.makeHttpRequest(httpService, fuzzed_request)
            
            self.output_log.write(f"Sent {fuzz_type} request for: {original_requestInfo.getUrl().getPath()} with payload: {fuzzed_graphql_query[:50]}...\n")
            
            # Optionally, analyze response for specific indicators (e.g., error messages, unexpected data)
            response_info = self._helpers.analyzeResponse(new_messageInfo.getResponse())
            if response_info.getStatusCode() >= 500:
                self.output_log.write(f"  --> Potential server error (Status {response_info.getStatusCode()}) detected!\n")
            if "error" in self._helpers.bytesToString(new_messageInfo.getResponse()):
                self.output_log.write(f"  --> 'error' keyword found in response for {fuzz_type}.\n")

        except Exception as e:
            self.error_log.write("Error sending fuzzed request: " + str(e) + "\n")

Identifying GraphQL Traffic

The processHttpMessage method is our entry point. We'll specifically look for POST requests where the URL path contains /graphql (a common endpoint, though customize TARGET_GRAPHQL_PATH for your target). We also check for application/json in the request headers, as most GraphQL queries are sent as JSON payloads containing a query key. Tools like GProxy can help route this traffic through Burp more reliably during complex engagements, ensuring all relevant requests are captured for analysis and fuzzing.

Fuzzing GraphQL Arguments

The fuzz_graphql_arguments function uses a simplified regex to locate potential argument values within the GraphQL query. It then iterates through a predefined list of FUZZ_PAYLOADS, injecting them into each identified argument. These payloads cover common attack vectors: SQL injection, XSS, path traversal, and NoSQL injection attempts. For instance, a query like query { user(id: "1") { name } } might be fuzzed to query { user(id: "' OR 1=1 --") { name } }.


    FUZZ_PAYLOADS = [
        "' OR 1=1 --",
        "\" OR 1=1 --",
        "UNION SELECT NULL,NULL,NULL--",
        "<script>alert(1)</script>",
        "../../../../etc/passwd",
        "{ $regex: '.*' }",
        "DROP TABLE users;" # For specific backend types
    ]
    # ... inside fuzz_graphql_arguments ...
    arg_pattern = re.compile(r'(\w+:\s*["\']?)([^"\'\s,{}()]+)(["\']?,?)')
    # This regex is an attempt to capture `key: "value"` or `key: value`.
    # Group 1 captures `key: "` or `key: `, Group 2 captures `value`, Group 3 captures `"` or `,`
    # It's highly heuristic and may not work for all GraphQL query variations.

A proper GraphQL parser (like graphql-core if you were building a standalone Python script, though less straightforward in Jython for Burp extensions) would offer more robust argument identification by traversing the Abstract Syntax Tree (AST). For a quick field-notes style extension, regex serves as a practical, albeit less reliable, starting point.

Testing for Excessive Data Exposure

GraphQL's flexibility allows clients to request specific fields. This can become a vulnerability if unauthorized or sensitive fields can be requested. Our fuzz_graphql_fields function attempts to inject common sensitive field names into existing selection sets. For example, if the original query is query { user(id: 1) { name } }, the fuzzer might transform it to query { user(id: 1) { name, password, email } }.


    FIELD_ADDITIONS = [
        "password", "email", "ssn", "creditCardNumber", "internalId", # Common sensitive fields
        "isAdmin", "role", "permissions" # Common privilege-related fields
    ]
    # ... inside fuzz_graphql_fields ...
    # Simplified approach to append fields to the last selection set
    idx = graphql_query.rfind('}')
    if idx != -1:
        fuzzed_query = graphql_query[:idx] + f", {field_to_add}" + graphql_query[idx:]
        fuzzed_queries.append(fuzzed_query)

This injection attempts to see if the GraphQL server returns data for these fields, even if the legitimate client application doesn't explicitly request them. Discovering exposed services and API endpoints is often the first step in such tests, a task where platforms like Zondex can be invaluable for broader reconnaissance before diving into specific API analysis.

Sending Fuzzed Requests and Analyzing Responses

The send_fuzzed_request function is crucial. It rebuilds the HTTP request with our modified GraphQL query and sends it using _callbacks.makeHttpRequest. This ensures the fuzzed request appears in Burp's HTTP history, allowing manual review of responses. The function also performs a basic check for 5xx status codes or the presence of the "error" keyword in the response body, which can indicate potential issues.

Automated vulnerability scanning tools, such as Secably, often incorporate similar logic for API security testing, but a custom Burp extension provides the fine-grained control needed for specific, heuristic-based fuzzing against novel GraphQL implementations.

Considerations and Enhancements

  • Complex Queries: This extension uses simplified regex. For deeply nested or highly complex GraphQL queries, a full AST parser (if compatible with Jython) would be more robust for precise argument and field injection.
  • Query Variables: GraphQL often uses query variables alongside the main query string. This extension currently only fuzzed the `query` string directly. Future enhancements could parse and fuzz the `variables` JSON object as well.
  • Introspection Queries: Consider adding logic to automatically send and parse introspection queries (`__schema` and `__type` fields) to dynamically discover fields and arguments for more targeted fuzzing.
  • Response Analysis: Enhance response analysis to look for specific error messages (e.g., SQL errors, stack traces), unexpected data types, or changes in data volume indicating successful data exposure.
  • Rate Limiting: Fuzzing can generate many requests. Implement options for rate limiting or configurable delays to avoid overwhelming the target or triggering WAFs.

This approach provides a foundation for extending Burp Suite's capabilities to specifically target the nuances of GraphQL APIs, moving beyond generic HTTP fuzzing to more intelligent, structure-aware vulnerability discovery.