From dcacb572380fd5f1c26a7fcca566da4e56f96786 Mon Sep 17 00:00:00 2001 From: Dolev Farhi Date: Sun, 29 Aug 2021 00:02:36 -0400 Subject: [PATCH] Initial commit --- .gitignore | 129 ++++++++++++ LICENSE | 29 +++ README.md | 117 +++++++++++ conf.py | 7 + docs/apollo.md | 18 ++ docs/ariadne.md | 17 ++ docs/gqlgen.md | 17 ++ docs/graphene.md | 17 ++ docs/graphql-go.md | 17 ++ docs/graphql-java.md | 17 ++ docs/graphql-php.md | 17 ++ docs/graphqlapiforwp.md | 25 +++ docs/hasura.md | 19 ++ docs/hypergraphql.md | 17 ++ docs/ruby-graphql.md | 17 ++ docs/templ.md | 9 + docs/wpgraphql.md | 16 ++ graphw00f/helpers.py | 133 +++++++++++++ graphw00f/lib.py | 429 ++++++++++++++++++++++++++++++++++++++++ main.py | 127 ++++++++++++ static/graphw00f.png | Bin 0 -> 22734 bytes version.py | 1 + 22 files changed, 1195 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 conf.py create mode 100644 docs/apollo.md create mode 100644 docs/ariadne.md create mode 100644 docs/gqlgen.md create mode 100644 docs/graphene.md create mode 100644 docs/graphql-go.md create mode 100644 docs/graphql-java.md create mode 100644 docs/graphql-php.md create mode 100644 docs/graphqlapiforwp.md create mode 100644 docs/hasura.md create mode 100644 docs/hypergraphql.md create mode 100644 docs/ruby-graphql.md create mode 100644 docs/templ.md create mode 100644 docs/wpgraphql.md create mode 100644 graphw00f/helpers.py create mode 100644 graphw00f/lib.py create mode 100644 main.py create mode 100644 static/graphw00f.png create mode 100644 version.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d6e0d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, graphw00f +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbb7a39 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +

+ +
+ graphw00f - GraphQL Fingerprinting +

+ +graphw00f (inspired by [wafw00f](https://github.com/EnableSecurity/wafw00f)) is a GraphQL fingerprinting tool. + +# Table of Contents +* [How does it work?](#how-does-it-work) +* [Detections](#detections) +* [GraphQL Technologies Defence Matrices](#graphql-technologies-defence-matrices) +* [Prerequisites](#prerequisites) +* [Installation](#installation) +* [Support & Issues](#support-and-issues) +* [Resources](#resources) + + +# How does it work? +graphw00f is a Python utility which attempts to send a mixture of benign and malformed queries to determine the GraphQL engine running behind the scenes. + +Different GraphQL servers respond uniquely to queries, mutations and subscriptions given the right payload, this makes it trivial to fingerprint and distinguish between the various GraphQL servers. (CWE: [CWE-200](#CWE-Reference)) + +# Detections +graphw00f currently attempts to discover the following GraphQL engines: +* Graphene +* Ariadne +* Apollo +* graphql-go +* gqlgen +* WPGraphQL +* GraphQL API for Wordpress +* Ruby GraphQL +* graphql-php +* Hasura +* HyperGraphQL +* GraphQL for Java + +# GraphQL Technologies Defence Matrices +Each fingerprinted technology (e.g. Graphene, Ariadne, ...) has an associated document ([example for graphene](https://github.com/dolevf/graphw00f/blob/main/docs/graphene.md)) which covers the security defence mechanisms the specific technology supports to give a better idea how the implementation may be attacked. + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|------------|-----------------| +| On by Default | No Support | No Support | No Support | Enabled by Default | N/A | Off by Default | +``` + +# Prerequisites +* python3 +* requests + +# Installation +## Clone Repository +`git clone git@github.com:dolevf/graphw00f.git` + +## Run graphw00f +`python3 main.py -h` + +``` +Usage: main.py -h + +Options: + -h, --help show this help message and exit + -r, --noredirect Do not follow redirections given by 3xx responses + -t URL, --target=URL target url with the path + -o OUTPUT_FILE, --output-file=OUTPUT_FILE + Output results to a file (CSV) + -l, --list List all GraphQL technologies graphw00f is able to + detect + -v, --version Print out the current version and exit. +``` + +# Example +``` +python3 main.py -t http://127.0.0.1:5000/graphql + ++-------------------+ +--------------------+ +| GRAPHQL | | FINGERPRINT | ++-------------------+ +--------------------+ + ** ** + *** *** + ** ** + +-------------------+ + | graphw00f | + +-------------------+ + *** *** + ** *** + ** ** + +--------------+ +--------------+ + | Node X | | Node Y | + +--------------+ +--------------+ + *** *** + ** ** + ** ** + +------------+ + | Node Z | + +------------+ + + graphw00f - v1.0.0 + The fingerprinting tool for GraphQL + +[*] Checking if GraphQL is available at http://127.0.0.1:8088/graphql... +[*] Found GraphQL. +[*] Attempting to fingerprint... +[*] Discovered GraphQL Engine! +[!] The site https://www.graphql-java.com is using: graphql-java - GraphQL for Java +[!] Attack Surface Matrix: https://github.com/dolevf/graphw00f/blob/main/docs/graphql-java.md +[!] Technologies: Java +[!] Homepage: https://www.graphql-java.com +[*] Completed. +``` + +# Support and Issues +Any issues with graphw00f such as false/true positives, inaccurate detections, etc. please create a GitHub issue with environment details. + +# Resources +Want to learn more about GraphQL? head over to my other project and hack GraphQL away: [Damn Vulnerable GraphQL Application](https://github.com/dolevf/Damn-Vulnerable-GraphQL-Application/) diff --git a/conf.py b/conf.py new file mode 100644 index 0000000..1138bf4 --- /dev/null +++ b/conf.py @@ -0,0 +1,7 @@ +# Custom Headers +# HEADERS = {"User-Agent":"My User Agent"} +HEADERS = {'User-Agent':'graphw00f'} + +# Custom Cookies +# COOKIES = {"PHPSESS":"DEADBEEF"} +COOKIES = {} diff --git a/docs/apollo.md b/docs/apollo.md new file mode 100644 index 0000000..a0eda0e --- /dev/null +++ b/docs/apollo.md @@ -0,0 +1,18 @@ +# Apollo + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +Apollo Server is a community-maintained open-source GraphQL server. It works with many Node.js HTTP server frameworks, or can run on its own with a built-in Express server. Apollo Server works with any GraphQL schema built with GraphQL.js--or define a schema's type definitions using schema definition language (SDL). +Apollo uses TypeScript as its language. + +# Security Features +Apollo offers the following features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|----------------------------------|----------------------------------|-----------------------------|------------------------------------------------|-------------------------------------------------------------------------------|-----------------| +| On by Default | Supported via External Libraries | Supported via External Libraries | Supported | Enabled if NODE_ENV is not set to 'production' | exception.stacktrace exists if NODE_ENV is not set to 'production' or 'test' | On by default | +``` \ No newline at end of file diff --git a/docs/ariadne.md b/docs/ariadne.md new file mode 100644 index 0000000..7b5c0a6 --- /dev/null +++ b/docs/ariadne.md @@ -0,0 +1,17 @@ +# Ariadne + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +Ariadne is a Python library for implementing GraphQL servers using a schema-first approach. + +# Security Features +Ariadne offers the following features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|----------------|-----------------| +| On by Default | Supported | Supported | No Support | Enabled by Default | Off by Default | No Support | +``` \ No newline at end of file diff --git a/docs/gqlgen.md b/docs/gqlgen.md new file mode 100644 index 0000000..81660ee --- /dev/null +++ b/docs/gqlgen.md @@ -0,0 +1,17 @@ +# gqlgen + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +gqlgen is a Go library for building GraphQL servers without any fuss, based on schema-first approach. + +# Security Features +gqlgen provides the following security features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|---------------|----------------|-----------------| +| On by Default | No Support | Off by Default | Off by Default | On by Default | Off by Default | Off by Default | +``` \ No newline at end of file diff --git a/docs/graphene.md b/docs/graphene.md new file mode 100644 index 0000000..d361a8f --- /dev/null +++ b/docs/graphene.md @@ -0,0 +1,17 @@ +# Graphene + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +Graphene-Python is a library for building GraphQL APIs in Python easily, its main goal is to provide a simple but extendable API for making developers' lives easier. + +# Security Features +Graphene offers the following features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|------------|-----------------| +| On by Default | No Support | No Support | No Support | Enabled by Default | N/A | Off by Default | +``` \ No newline at end of file diff --git a/docs/graphql-go.md b/docs/graphql-go.md new file mode 100644 index 0000000..b50d538 --- /dev/null +++ b/docs/graphql-go.md @@ -0,0 +1,17 @@ +# GraphQL-Go + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +An implementation of GraphQL in Go. + +# Security Features +graphql-go offers the following features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|----------------|-----------------| +| On by Default | No Support | No Support | No Support | Enabled by Default | Off by Default | No Support | +``` diff --git a/docs/graphql-java.md b/docs/graphql-java.md new file mode 100644 index 0000000..5d6b475 --- /dev/null +++ b/docs/graphql-java.md @@ -0,0 +1,17 @@ +# GraphQL Java + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +The GraphQL Java is an implementation of the GraphQL specification for the Java language. + +# Security Features +GraphQL Java offers the following security features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|------------|-----------------| +| On by Default | Off by Default | Off by Default | No Support | Enabled by Default | No Support | Off by Default | +``` \ No newline at end of file diff --git a/docs/graphql-php.md b/docs/graphql-php.md new file mode 100644 index 0000000..16f362e --- /dev/null +++ b/docs/graphql-php.md @@ -0,0 +1,17 @@ +# ProductName + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +graphql-php is a PHP implementation of the GraphQL specification. + +# Security Features +graphql-php offers the following features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|---------------------------------|---------------------------------|-----------------------------|--------------------|----------------|---------------------------------| +| On by Default | Supported - Disabled by Default | Supported - Disabled by Default | No Support | Enabled by Default | Off by Default | Supported - Disabled by Default | +``` \ No newline at end of file diff --git a/docs/graphqlapiforwp.md b/docs/graphqlapiforwp.md new file mode 100644 index 0000000..7cca930 --- /dev/null +++ b/docs/graphqlapiforwp.md @@ -0,0 +1,25 @@ +# GraphQL API For WordPress + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +GraphQL API For WordPress bring the most powerful GraphQL experience into your WordPress site + +# Security Features +GraphQL API For WordPress provides the followign security features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|---------------|------------|-----------------| +| On by Default | No Support | No Support | Off by Default | N/A | No Support | No Support | +``` + +While GraphQL API for Wordpress does not provide common security mechanisms out of the box, it does provide additional controls: + +* Access Control Lists +* Persisted Queries on custom endpoints +* Access granularity on schemas + +The existence of these features in practice depends on the WordPress Admin, they may or may not be enabled. \ No newline at end of file diff --git a/docs/hasura.md b/docs/hasura.md new file mode 100644 index 0000000..1798fb6 --- /dev/null +++ b/docs/hasura.md @@ -0,0 +1,19 @@ +# Hasura + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +The Hasura GraphQL engine makes your data instantly accessible over a real-time GraphQL API, so you can build and ship modern apps and APIs faster. Hasura connects to your databases, REST servers, GraphQL servers, and third party APIs to provide a unified realtime GraphQL API across all your data sources. + +# Security Features +While Hasura Cloud provides some security mechanisms, Hasura API (the non-cloud version) provides a limited set of security features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|---------------|------------|-----------------| +| On by Default | No Support | No Support | No Support | N/A | No Support | No Support | +``` + +Hasura non-cloud provides Access Control Lists options, however, they must be explicitly enabled and used. \ No newline at end of file diff --git a/docs/hypergraphql.md b/docs/hypergraphql.md new file mode 100644 index 0000000..f2f2170 --- /dev/null +++ b/docs/hypergraphql.md @@ -0,0 +1,17 @@ +# HyperGraphQL + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +An implementation of GraphQL in Java + +# Security Features +HyperGraphQL offers the following features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|------------|-----------------| +| No Support | No Support | No Support | No Support | Enabled by Default | No Support | No Support | +``` diff --git a/docs/ruby-graphql.md b/docs/ruby-graphql.md new file mode 100644 index 0000000..cf6b180 --- /dev/null +++ b/docs/ruby-graphql.md @@ -0,0 +1,17 @@ +# Ruby GraphQL + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +ruby-graphql is a Ruby implementation of the GraphQL specification. + +# Security Features +Ruby GraphQL provides the following security features: + +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|--------------------|------------|-----------------| +| On by Default | No Support | Off by Default | Off by Default | Enabled by Default | No Support | On by Default | +``` \ No newline at end of file diff --git a/docs/templ.md b/docs/templ.md new file mode 100644 index 0000000..39bb073 --- /dev/null +++ b/docs/templ.md @@ -0,0 +1,9 @@ +# ProductName + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About + +# Security Features \ No newline at end of file diff --git a/docs/wpgraphql.md b/docs/wpgraphql.md new file mode 100644 index 0000000..928ea2e --- /dev/null +++ b/docs/wpgraphql.md @@ -0,0 +1,16 @@ +# WPGraphQL + +# Table of Contents +* [About](#About) +* [Security Features](#Security-Features) + +# About +WPGraphQL is a WordPress plugin which provides a WordPress instance with immediate GraphQL API support. + +# Security Features +WPGraphQL offers the following security features: +``` +| Field Suggestions | Query Depth Limit | Query Cost Analysis | Automatic Persisted Queries | Introspection | Debug Mode | Batch Requests | +|-------------------|-------------------|---------------------|-----------------------------|----------------|----------------|-----------------| +| On by Default | Off by Default | No Support | No Support | Off by Default | Off by Default | On by Default | +``` \ No newline at end of file diff --git a/graphw00f/helpers.py b/graphw00f/helpers.py new file mode 100644 index 0000000..6bdab62 --- /dev/null +++ b/graphw00f/helpers.py @@ -0,0 +1,133 @@ + +import datetime +from urllib.parse import urlparse +from version import VERSION + +class bcolors: + OKBLUE = '\033[94m' + OKCYAN = '\033[96m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + +def error_contains(response, word_to_match): + if isinstance(response, dict): + if response.get('errors'): + for i in response['errors']: + err_message = i.get('message', '') + if word_to_match in err_message: + return True + return False + +def get_time(): + return datetime.datetime.now().strftime('%Y-%m-%d') + +def draw_art(): + return ''' ++-------------------+ +--------------------+ +| GRAPHQL | | FINGERPRINT | ++-------------------+ +--------------------+ + ** ** + *** *** + ** ** + +-------------------+ + | graphw00f | + +-------------------+ + *** *** + ** *** + ** ** + +--------------+ +--------------+ + | Node X | | Node Y | + +--------------+ +--------------+ + *** *** + ** ** + ** ** + +------------+ + | Node Z | + +------------+ + + graphw00f - v{version} + The fingerprinting tool for GraphQL + '''.format(version=VERSION) + +def get_engines(): + return { + 'apollo':{ + 'name':'Apollo', + 'url':'https://www.apollographql.com', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/apollo.md', + 'technology':['JavaScript', 'Node.js', 'TypeScript'] + }, + 'graphene':{ + 'name':'Graphene', + 'url':'https://graphene-python.org', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/graphene.md', + 'technology':['Python'] + }, + 'hasura':{ + 'name':'Hasura', + 'url':'https://hasura.io', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/hasura.md', + 'technology':['Haskell'] + }, + 'graphql-php':{ + 'name':'GraphQL PHP', + 'url':'https://webonyx.github.io/graphql-php', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/graphql-php.md', + 'technology':['PHP'] + }, + 'ruby-graphql':{ + 'name':'Ruby GraphQL', + 'url':'https://graphql-ruby.org', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/ruby-graphql.md', + 'technology':['Ruby'] + }, + 'hypergraphql':{ + 'name':'HyperGraphQL', + 'url':'https://www.hypergraphql.org', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/hypergraphql.md', + 'technology':['Java'] + }, + 'ariadne':{ + 'name':'Ariadne', + 'url':'https://ariadnegraphql.org', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/ariadne.md', + 'technology':['Python'] + }, + 'graphql-api-for-wp':{ + 'name':'GraphQL API for Wordpress', + 'url':'https://graphql-api.com', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/graphqlapiforwp.md', + 'technology':['PHP'], + }, + 'wpgraphql':{ + 'name':'WPGraphQL WordPress Plugin', + 'url':'https://www.wpgraphql.com', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/wpgraphql.md', + 'technology':['PHP'] + }, + 'gqlgen':{ + 'name':'gqlgen - GraphQL for Go', + 'url':'https://gqlgen.com', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/gqlgen.md', + 'technology':['Go'] + }, + 'graphql-go':{ + 'name':'graphql-go -GraphQL for Go', + 'url':'https://github.com/graphql-go/graphql', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/graphql-go.md', + 'technology':['Go'] + }, + 'graphql-java':{ + 'name':'graphql-java - GraphQL for Java', + 'url':'https://www.graphql-java.com', + 'ref':'https://github.com/dolevf/graphw00f/blob/main/docs/graphql-java.md', + 'technology':['Java'] + } + } + +def user_confirmed(choice): + if choice in ('yes', 'y'): + return True + return False diff --git a/graphw00f/lib.py b/graphw00f/lib.py new file mode 100644 index 0000000..a78512a --- /dev/null +++ b/graphw00f/lib.py @@ -0,0 +1,429 @@ +import requests + +from graphw00f.helpers import error_contains + +requests.packages.urllib3.disable_warnings() + +class GraphQLDetectionFailed(Exception): + pass + +class GraphQLUnknownState(Exception): + pass + +class GraphQLError(Exception): + pass + +class GRAPHW00F: + def __init__(self, headers, + cookies, + follow_redirects=False): + self.url = 'http://example.com' + self.cookies = cookies + self.headers = headers + self.follow_redirects = follow_redirects + + def check(self, url): + query = ''' + query { + __typename + } + ''' + response = self.graph_query(url, payload=query) + if response.get('data', {}).get('__typename', '') in ('Query', 'QueryRoot', 'query_root'): + return True + elif response.get('errors') and any('locations' in i for i in response['errors']): + return True + elif response.get('data'): + return True + else: + raise GraphQLDetectionFailed + raise GraphQLUnknownState + + def execute(self, url): + self.url = url + if self.engine_graphene(): + return 'graphene' + elif self.engine_ariadne(): + return 'ariadne' + elif self.engine_apollo(): + return 'apollo' + elif self.engine_hasura(): + return 'hasura' + elif self.engine_wpgraphql(): + return 'wpgraphql' + elif self.engine_graphqlapiforwp(): + return 'graphql-api-for-wp' + elif self.engine_graphqljava(): + return 'graphql-java' + elif self.engine_hypergraphql(): + return 'hypergraphql' + elif self.engine_ruby(): + return 'ruby-graphql' + elif self.engine_graphqlphp(): + return 'graphql-php' + elif self.engine_gqlgen(): + return 'gqlgen' + elif self.engine_graphqlgo(): + return 'graphql-go' + return None + + def graph_query(self, url, operation='query', payload={}): + try: + response = requests.post(url, + headers=self.headers, + cookies=self.cookies, + verify=False, + allow_redirects=self.follow_redirects, + json={operation:payload}) + return response.json() + except GraphQLError: + return {} + except: + return {} + + def engine_apollo(self): + query = ''' + query @skip { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Directive "@skip" argument "if" of type "Boolean!" is required, but it was not provided.'): + return True + + query = ''' + query @deprecated { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Directive "@deprecated" may not be used on QUERY.'): + return True + + def engine_graphene(self): + query = '' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Must provide query string.'): + return True + + query = '''aaa''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Syntax Error GraphQL (1:1)'): + return True + + return False + + def engine_hasura(self): + query = ''' + query @cached { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if response.get('data'): + if response.get('data', {}).get('__typename', '') == 'query_root': + return True + + query = ''' + query { + __schema + } + ''' + response = self.graph_query(self.url, payload=query) + + if error_contains(response, 'missing selection set for "__Schema"'): + return True + + query = ''' + query { + aa + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'field "aaa" not found in type: \'query_root\''): + return True + + query = ''' + query @skip { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'directive "skip" is not allowed on a query'): + return True + + return False + + def engine_graphqlphp(self): + query = ''' + query ! { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Syntax Error: Cannot parse the unexpected character "?".'): + return True + + query = ''' + subscription { + s + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Schema is not configured for subscriptions.'): + return True + + return False + + def engine_ruby(self): + query = ''' + query @skip { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, '\'@skip\' can\'t be applied to queries (allowed: fields, fragment spreads, inline fragments)'): + return True + elif error_contains(response, 'Directive \'skip\' is missing required arguments: if'): + return True + + query = ''' + query @deprecated { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, '\'@deprecated\' can\'t be applied to queries'): + return True + + query = ''' + query aa@aa { + __schema { + directives { + description + } + } + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Directive @aa is not defined'): + return True + + query = ''' + query { + __schema + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Field must have selections (field \'__schema\' returns __Schema but has no selections.'): + return True + + return False + + def engine_hypergraphql(self): + query = ''' + zzz { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Validation error of type InvalidSyntax: Invalid query syntax.'): + return True + + query = ''' + query { + __schema { + directives { + descriptio + } + } + } + ''' + response = self.graph_query(self.url, payload=query) + if response.get('errors'): + for i in response['errors']: + qp = i.get('queryPath', []) + matches = ['__schema', 'directives', 'descriptio'] + if qp == matches: + return True + + return False + + def engine_graphqljava(self): + query = ''' + queryy { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Invalid Syntax : offending token \'queryy\''): + return True + + query = ''' + query @aaa@aaa { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Validation error of type DuplicateDirectiveName: Directives must be uniquely named within a location.'): + return True + + query = '' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Invalid Syntax : offending token \'\''): + return True + + return False + + def engine_ariadne(self): + query = ''' + query { + __schema + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Field \'__schema\' of type \'__Schema!\' must have a selection of subfields.'): + return True + + query = ''' + subscription { + s + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Could not connect to websocket endpoint'): + return True + + query = '' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'The query must be a string.'): + return True + + return False + + def engine_graphqlapiforwp(self): + query = ''' + query { + alias1$1:__schema + } + ''' + response = self.graph_query(self.url, payload=query) + if response.get('data'): + if response.get('data').get('alias1$1', '') == 'schema': + return True + + query = '''query aa#aa { __typename }''' + response = self.graph_query(self.url, payload=query) + + if error_contains(response, 'Unexpected token "END"'): + return True + + query = ''' + query @skip { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Argument \'if\' cannot be empty, so directive \'skip\' has been ignored'): + return True + + query = ''' + query @doesnotexist { + __typename + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'No DirectiveResolver resolves directive with name \'doesnotexist\''): + return True + + query = '' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'The query in the body is empty'): + return True + + return False + + def engine_wpgraphql(self): + query = '' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'GraphQL Request must include at least one of those two parameters: "query" or "queryId"'): + return True + + query = ''' + query { + alias1$1:__schema + } + ''' + response = self.graph_query(self.url, payload=query) + if not error_contains(response, 'Syntax Error: Expected Name, found $'): + return False + + try: + debug_msg = response['extensions']['debug'][0] + if debug_msg['type'] == 'DEBUG_LOGS_INACTIVE' or \ + debug_msg['message'] == 'GraphQL Debug logging is not active. To see debug logs, GRAPHQL_DEBUG must be enabled.': + return True + except KeyError: + pass + + return False + + def engine_gqlgen(self): + query = ''' + query @aa@aa { + __schema + } + ''' + response = self.graph_query(self.url, payload=query) + + if error_contains(response, 'The directive "aa" can only be used once at this location.'): + return True + + query = ''' + queryyy { + __schema + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Unexpected Name "queryyy"'): + return True + return False + + def engine_graphqlgo(self): + query = ''' + query @skip { + abc + } + ''' + response = self.graph_query(self.url, payload=query) + if error_contains(response, 'Directive "skip" may not be used on QUERY. Directive "@skip" argument "if" of type "Boolean!" is required but not provided'): + return True + + query = '' + response = self.graph_query(self.url, payload=query) + + if error_contains(response, 'Must provide an operation.'): + return True + + query = ''' + query ? { + __schema + } + ''' + response = self.graph_query(self.url, payload=query) + + if error_contains(response, 'Unexpected character "?"'): + return True + + query = ''' + query ? { + __schema + } + ''' + + response = self.graph_query(self.url, payload=query) + + if error_contains(response, 'Syntax Error GraphQL request'): + return True + + return False diff --git a/main.py b/main.py new file mode 100644 index 0000000..9167e48 --- /dev/null +++ b/main.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys +import conf + +from graphw00f.helpers import ( + get_time, + draw_art, + get_engines, + user_confirmed, + bcolors +) +from time import sleep +from urllib.parse import urlparse +from optparse import OptionParser + +from version import VERSION +from graphw00f.lib import ( + GRAPHW00F, + GraphQLDetectionFailed, + GraphQLUnknownState +) + + +def main(): + parser = OptionParser(usage='%prog -t http://example.com/graphql') + parser.add_option('-r', '--noredirect', action='store_false', dest='followredirect', default=True, + + help='Do not follow redirections given by 3xx responses') + parser.add_option('-t', '--target', dest='url', help='target url with the path') + parser.add_option('-o', '--output-file', dest='output_file', + help='Output results to a file (CSV)', default=None) + parser.add_option('-l', '--list', dest='list', action='store_true', default=False, + help='List all GraphQL technologies graphw00f is able to detect') + parser.add_option('--version', '-v', dest='version', action='store_true', default=False, + help='Print out the current version and exit.') + options, args = parser.parse_args() + + if options.list: + print(draw_art()) + for k, v in graphw00f.helpers.get_engines().items(): + print('{key}: {name} ({language})'.format( + key=k, + name=v['name'], + language=', '.join(v['language'])) + ) + sys.exit(0) + + if options.version: + print('version:', VERSION) + sys.exit(0) + + if not options.url: + parser.print_help() + sys.exit(1) + + url = options.url + url_path = urlparse(url).path + url_scheme = urlparse(url).scheme + url_netloc = urlparse(url).netloc + + print(draw_art()) + + if url_scheme not in ('http', 'https'): + print('URL is missing a scheme (http|https)') + sys.exit(1) + + if not url_netloc: + print('url {url} does not seem right.'.format(url=url)) + sys.exit(1) + + if not url_path: + print('[*] No URL path was provided.') + print('[*[ are you sure you want to fingerprint the server without a path? [y/n]') + choice = input().lower() + if not user_confirmed(choice): + sys.exit(1) + + + print('[*] Checking if GraphQL is available at {url}...'.format(url=url)) + + g = GRAPHW00F(follow_redirects=options.followredirect, + headers=conf.HEADERS, + cookies=conf.COOKIES) + detected = None + try: + if g.check(url): + print('[*] Found GraphQL.') + except GraphQLDetectionFailed: + print(bcolors.FAIL + '[x] Could not determine existence of GraphQL (GraphQLDetectionFailed)' + bcolors.ENDC) + print('[*] Continue anyway? [y/n]'.format(url=url)) + choice = input().lower() + if not user_confirmed(choice): + print('Quitting.') + sys.exit(1) + except GraphQLUnknownState: + print('Something went wrong.') + sys.exit(1) + + print('[*] Attempting to fingerprint...') + result = g.execute(url) + + if result: + name = get_engines()[result]['name'] + url = get_engines()[result]['url'] + ref = get_engines()[result]['ref'] + technologies = ', '.join(get_engines()[result]['technology']) + detected = name + print(bcolors.OKGREEN + '[*] Discovered GraphQL Engine!') + print('[!] The site {} is using: {}'.format(url, name)) + print('[!] Attack Surface Matrix: {}'.format(ref)) + print('[!] Technologies: {}'.format(technologies)) + print('[!] Homepage: {}'.format(url)) + else: + print('[x] Nothing was found :-(') + + if options.output_file: + f = open(options.output_file, 'w') + f.write('url,detected_engine,timestamp\n') + f.write('{},{},{}\n'.format(url_netloc, detected, get_time())) + f.close() + + print('[*] Completed.') + +if __name__ == '__main__': + main() + \ No newline at end of file diff --git a/static/graphw00f.png b/static/graphw00f.png new file mode 100644 index 0000000000000000000000000000000000000000..1fd8057133cae021b462ce347568bf6da4874aa2 GIT binary patch literal 22734 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�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 dQXu~n_