-
Notifications
You must be signed in to change notification settings - Fork 14.3k
Add GraphQL Auxiliary Scanner module #20216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sjanusz-r7
wants to merge
5
commits into
rapid7:master
Choose a base branch
from
sjanusz-r7:add-graphql-aux-scanner-module
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
4075e1a
Add GraphQL Auxiliary Scanner module
sjanusz-r7 7277210
Use JSON.generate for GraphQL Introspection queries
sjanusz-r7 9e4d0c9
Try to handle more errors for GraphQL Introspection
sjanusz-r7 9cea289
Address GraphQL Introspection comments
sjanusz-r7 cdc51b4
Add GraphQL Introspection Scanner documentation
sjanusz-r7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
62 changes: 62 additions & 0 deletions
62
documentation/modules/auxiliary/scanner/http/graphql_introspection_scanner.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
## Vulnerable Application | ||
|
||
This module scans GraphQL endpoints to check if they have enabled introspection. | ||
This allows for gathering the schema for the endpoint, potentially leading to information disclosure. | ||
The module stores this as a vulnerability, and can also store the dumped schema as loot. | ||
|
||
### Creating a Vulnerable Environment | ||
You can either target a public GraphQL endpoint present here: https://github.com/graphql-kit/graphql-apis | ||
Or set up a local server by following a tutorial here: https://www.apollographql.com/docs/apollo-server/getting-started | ||
|
||
## Options | ||
|
||
### TARGETURI | ||
|
||
The GraphQL endpoint URI, which will receive the POST requests. | ||
|
||
## Verification Steps | ||
|
||
1. Do: run `msfconsole` | ||
2. Do: use `auxiliary/scanner/http/graphql_introspection_scanner` | ||
3. Do: set `RHOSTS [IP]` | ||
4. Do: set `TARGETURI [URI]` | ||
5. Do: `run` | ||
|
||
## Scenarios | ||
|
||
### Apollo Server - JavaScript | ||
``` | ||
auxiliary(scanner/http/graphql_introspection_scanner) > check rport=4001 | ||
[+] 127.0.0.1:4001 - The target is vulnerable. The server has introspection enabled. | ||
|
||
auxiliary(scanner/http/graphql_introspection_scanner) > run rport=4001 | ||
[*] Running module against 127.0.0.1 | ||
[+] 127.0.0.1:4001 - Server responded with introspected data. Reporting a vulnerability, and storing it as loot. | ||
[*] Auxiliary module execution completed | ||
|
||
auxiliary(scanner/http/graphql_introspection_scanner) > vulns | ||
|
||
Vulnerabilities | ||
=============== | ||
|
||
Timestamp Host Name References | ||
--------- ---- ---- ---------- | ||
2025-05-27 16:12:25 UTC 127.0.0.1 GraphQL Information Disclosure through Introspection URL-https://portswigger.net/web-security/graphql,URL-https://graphql.o | ||
rg/learn/introspection/ | ||
2025-05-27 16:12:34 UTC 127.0.0.1 GraphQL Introspection Scanner URL-https://portswigger.net/web-security/graphql,URL-https://graphql.o | ||
rg/learn/introspection/ | ||
``` | ||
|
||
### Graphloc | ||
``` | ||
auxiliary(scanner/http/graphql_introspection_scanner) > run rhost=https://graphloc.com/ | ||
[*] Running module against 151.101.1.195 | ||
[*] 151.101.1.195:443 - Server responded with introspected data. Reporting a vulnerability, and storing it as loot. | ||
``` | ||
|
||
### catalysis-hub | ||
``` | ||
uxiliary(scanner/http/graphql_introspection_scanner) > run rhost=https://api.catalysis-hub.org/graphql? | ||
[*] Running module against 3.33.161.45 | ||
[*] 3.33.161.45:443 - Server responded with introspected data. Reporting a vulnerability, and storing it as loot. | ||
``` |
312 changes: 312 additions & 0 deletions
312
modules/auxiliary/scanner/http/graphql_introspection_scanner.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,312 @@ | ||
## | ||
# This module requires Metasploit: https://metasploit.com/download | ||
# Current source: https://github.com/rapid7/metasploit-framework | ||
## | ||
|
||
class MetasploitModule < Msf::Auxiliary | ||
|
||
include Msf::Exploit::Remote::HttpClient | ||
include Msf::Auxiliary::Report | ||
|
||
def initialize(info = {}) | ||
super( | ||
update_info( | ||
info, | ||
'Name' => 'GraphQL Introspection Scanner', | ||
'Description' => %q{ | ||
This module queries a GraphQL API Endpoint to retrieve schema data by using | ||
introspection, if it is enabled on the server. This module works on all GraphQL versions. | ||
}, | ||
'License' => MSF_LICENSE, | ||
'Author' => [ | ||
'sjanusz-r7', # Metasploit module | ||
], | ||
'References' => [ | ||
[ 'URL', 'https://portswigger.net/web-security/graphql' ], | ||
[ 'URL', 'https://graphql.org/learn/introspection/' ] | ||
], | ||
'Notes' => { | ||
'Stability' => [CRASH_SAFE], | ||
'SideEffects' => [IOC_IN_LOGS], | ||
'Reliability' => [] | ||
} | ||
) | ||
) | ||
register_options([ | ||
OptString.new('TARGETURI', [true, 'Base path of the GraphQL endpoint', '/']) | ||
]) | ||
end | ||
|
||
# Values that can be matched against to verify that introspection is not enabled on the server. | ||
# @return [Array<Regex>] An array of regular expressions | ||
def introspection_not_enabled_values | ||
[ /introspection is not (allowed|enabled)/i, /the query contained __schema/i, /to enable introspection/i ] | ||
end | ||
|
||
# Check if the response received from the server suggests that introspection is enabled on the server, by comparing it | ||
# to a known good value. | ||
# @param response The response received from the server. | ||
# @return [TrueClass|FalseClass] True if the response matched a known introspection result, false otherwise. | ||
def responded_with_introspected_data?(response) | ||
return false if introspection_not_enabled_values.any? { |regex| response&.body.to_s.match?(regex) } | ||
|
||
# Known good response | ||
response&.body.to_s == "{\"data\":{\"__schema\":{\"queryType\":{\"name\":\"Query\"}}}}\n" | ||
end | ||
|
||
# Create a small query, used to test if introspection is enabledo n the GraphQL endpoint. | ||
# @return [String] The processed introspection probe query. | ||
def introspection_probe_query | ||
<<~EOF | ||
query { | ||
__schema { | ||
queryType { | ||
name | ||
} | ||
} | ||
} | ||
EOF | ||
end | ||
|
||
# Create a unique query that will try to dump the GraphQL schema. | ||
# This dumps the data definitions, objects etc. not the data stored on the server. | ||
# Original query comes from: https://portswigger.net/web-security/graphql | ||
# @return [String] The processed schema dump query | ||
def schema_dump_query | ||
# Obfuscate the variable names with the hopes it will not get picked up by any logging solutions as suspicious. | ||
vars_map = { | ||
input_fragment: Rex::Text.rand_text_alpha(8), | ||
type_fragment: Rex::Text.rand_text_alpha(8), | ||
type_reference: Rex::Text.rand_text_alpha(8) | ||
} | ||
|
||
# Fragments need to be present at the end, outside the curly braces of the 'query' | ||
<<~EOF | ||
query { | ||
__schema { | ||
queryType { | ||
name | ||
} | ||
mutationType { | ||
name | ||
} | ||
subscriptionType { | ||
name | ||
} | ||
types { | ||
...#{vars_map[:type_fragment]} | ||
} | ||
directives { | ||
name | ||
description | ||
args { | ||
...#{vars_map[:input_fragment]} | ||
} | ||
} | ||
} | ||
} | ||
fragment #{vars_map[:type_fragment]} on __Type { | ||
kind | ||
name | ||
description | ||
inputFields { | ||
...#{vars_map[:input_fragment]} | ||
} | ||
fields(includeDeprecated: true) { | ||
name | ||
description | ||
isDeprecated | ||
deprecationReason | ||
args { | ||
...#{vars_map[:input_fragment]} | ||
} | ||
type { | ||
...#{vars_map[:type_reference]} | ||
} | ||
} | ||
inputFields { | ||
...#{vars_map[:input_fragment]} | ||
} | ||
interfaces { | ||
...#{vars_map[:type_reference]} | ||
} | ||
enumValues(includeDeprecated: true) { | ||
name | ||
description | ||
isDeprecated | ||
deprecationReason | ||
} | ||
possibleTypes { | ||
...#{vars_map[:type_reference]} | ||
} | ||
} | ||
fragment #{vars_map[:input_fragment]} on __InputValue { | ||
name | ||
description | ||
defaultValue | ||
type { | ||
...#{vars_map[:type_reference]} | ||
} | ||
} | ||
fragment #{vars_map[:type_reference]} on __Type { | ||
kind | ||
name | ||
ofType { | ||
kind | ||
name | ||
ofType { | ||
kind | ||
name | ||
ofType { | ||
kind | ||
name | ||
} | ||
} | ||
} | ||
} | ||
EOF | ||
end | ||
|
||
# Report a GraphQL instance on the current host and port. | ||
# @return [Mdm::Service] The reported service instance. | ||
def report_graphql_service | ||
report_service( | ||
host: rhost, | ||
port: rport, | ||
name: (ssl ? 'https' : 'http'), | ||
proto: 'tcp' | ||
) | ||
end | ||
|
||
# Report a GraphQL Introspection vulnerability on the current host and port. | ||
# @return [Mdm::Vuln] The reported vulnerability instance. | ||
def report_graphql_vuln | ||
report_vuln( | ||
{ | ||
host: rhost, | ||
port: rport, | ||
name: 'GraphQL Information Disclosure through Introspection', | ||
refs: references | ||
} | ||
) | ||
end | ||
|
||
# Report a GraphQL Introspection web vulnerability on the current host and port. | ||
# @param service The GraphQL Mdm::Service instance. | ||
# @param query The query string used to check for the web vulnerability. | ||
# @param response The reponse from the server, used as proof that the vulnerability can be exploited. | ||
# @return [Mdm::WebVuln] The reported web vulnerability instance. | ||
def report_graphql_web_vuln(service, query, response) | ||
report_web_vuln( | ||
{ | ||
host: rhost, | ||
port: rport, | ||
ssl: ssl, | ||
service: service, | ||
path: normalize_uri(target_uri.path), | ||
query: query, | ||
method: 'POST', | ||
params: [ | ||
[ 'data', query ] | ||
], | ||
pname: 'path', | ||
proof: response.body, | ||
name: 'GraphQL Introspection', | ||
description: 'GraphQL endpoint has enabled introspection. This can lead to information disclosure', | ||
owner: self, | ||
category: 'Information Disclosure' | ||
} | ||
) | ||
end | ||
|
||
# Send out a GraphQL request to the current endpoint, with the provided query string. | ||
# @param query The query string to execute. | ||
# @return (see Msf::Exploit::Remote::HttpClient#send_request_cgi) | ||
def send_graphql_request(query) | ||
send_request_cgi( | ||
'uri' => normalize_uri(target_uri.path), | ||
'method' => 'POST', | ||
'ctype' => 'application/json', | ||
'headers' => { | ||
'Accept' => 'application/json' | ||
}, | ||
'data' => JSON.generate({ query: query }) | ||
) | ||
end | ||
|
||
# Process the errors array into a nice human-readable and formatted string. | ||
# @param errors An array of errors. | ||
# @return [String] A string with formatted error messages | ||
def process_errors(errors) | ||
return '' if errors&.empty? | ||
|
||
# APIs aren't consistent. Some have an error message, some have title & detail. | ||
# Match all the known cases so far, otherwise return the inspected value. | ||
|
||
errors.map do |error| | ||
" - #{error['message'] || error['detail'] || error['description']}" | ||
end.join("\n") || '' | ||
end | ||
|
||
# Check if the current endpoint is vulnerable to GraphQL Introspection information disclosure. | ||
# @return [Exploit::CheckCode] | ||
def check | ||
query = introspection_probe_query | ||
res = send_graphql_request(query) | ||
|
||
if res.nil? | ||
return Exploit::CheckCode::Unknown('The server did not send a response.') | ||
end | ||
|
||
case res.code | ||
when 200 | ||
graphql_service = report_graphql_service | ||
report_graphql_vuln | ||
report_graphql_web_vuln(graphql_service, query, res) | ||
|
||
return Exploit::CheckCode::Vulnerable('The server has introspection enabled.') | ||
when 400 | ||
parsed_body = JSON.parse!(res.body) | ||
error_messages = process_errors(parsed_body['errors'] || Array.wrap(parsed_body['error'])) | ||
safe_message = "The server responded with an error status code and the following error(s) to the introspection request:\n#{error_messages}" | ||
return Exploit::CheckCode::Safe(safe_message) | ||
when 403 | ||
# Don't report the GraphQL service here, as this could be a generic 'No Access', so we are not sure if the GraphQL | ||
# endpoint exists or not. | ||
return Exploit::CheckCode::Unknown('The server did not allow access to the GraphQL endpoint.') | ||
when 422 | ||
# Rails application missing a CSRF token would return 422, but we are not 100% sure if this is a GraphQL endpoint. | ||
return Exploit::CheckCode::Unknown('The server required a CSRF token.') | ||
else | ||
# We are not 100% sure that the service is a GraphQL endpoint. It could be a generic 403 Access Denied. | ||
return Exploit::CheckCode::Unknown('The server is online, but returned an unexpected response code.') | ||
end | ||
end | ||
|
||
# Attempt a schema dump request against a GraphQL endpoint | ||
# @return [nil] | ||
def run | ||
query = schema_dump_query | ||
res = send_graphql_request(query) | ||
|
||
if res.nil? | ||
print_error("#{rhost}:#{rport} - The server did not send a response.") | ||
return | ||
end | ||
|
||
if res.code == 200 | ||
print_good("#{rhost}:#{rport} - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.") | ||
graphql_service = report_graphql_service | ||
report_graphql_vuln | ||
report_graphql_web_vuln(graphql_service, query, res) | ||
store_loot('graphql.schema', 'json', rhost, res.body, 'graphql-schema.json', 'GraphQL Schema Dump', graphql_service) | ||
else | ||
parsed_body = JSON.parse!(res.body) | ||
if parsed_body.include?('errors') || parsed_body.include?('error') | ||
print_error("#{rhost}:#{rport} - Server encountered the following error(s) (code: '#{res.code}'):\n#{process_errors(parsed_body['errors'] || Array.wrap(parsed_body['error']))}") | ||
else | ||
print_error("#{rhost}:#{rport} - Server replied with an unexpected status code: '#{res.code}'") | ||
end | ||
end | ||
end | ||
end |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we create a rex random identifier?