Skip to content

Commit 914f874

Browse files
authored
Merge pull request #20216 from sjanusz-r7/add-graphql-aux-scanner-module
Add GraphQL Auxiliary Scanner module
2 parents 60a6658 + cdc51b4 commit 914f874

File tree

2 files changed

+374
-0
lines changed

2 files changed

+374
-0
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
## Vulnerable Application
2+
3+
This module scans GraphQL endpoints to check if they have enabled introspection.
4+
This allows for gathering the schema for the endpoint, potentially leading to information disclosure.
5+
The module stores this as a vulnerability, and can also store the dumped schema as loot.
6+
7+
### Creating a Vulnerable Environment
8+
You can either target a public GraphQL endpoint present here: https://github.com/graphql-kit/graphql-apis
9+
Or set up a local server by following a tutorial here: https://www.apollographql.com/docs/apollo-server/getting-started
10+
11+
## Options
12+
13+
### TARGETURI
14+
15+
The GraphQL endpoint URI, which will receive the POST requests.
16+
17+
## Verification Steps
18+
19+
1. Do: run `msfconsole`
20+
2. Do: use `auxiliary/scanner/http/graphql_introspection_scanner`
21+
3. Do: set `RHOSTS [IP]`
22+
4. Do: set `TARGETURI [URI]`
23+
5. Do: `run`
24+
25+
## Scenarios
26+
27+
### Apollo Server - JavaScript
28+
```
29+
auxiliary(scanner/http/graphql_introspection_scanner) > check rport=4001
30+
[+] 127.0.0.1:4001 - The target is vulnerable. The server has introspection enabled.
31+
32+
auxiliary(scanner/http/graphql_introspection_scanner) > run rport=4001
33+
[*] Running module against 127.0.0.1
34+
[+] 127.0.0.1:4001 - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.
35+
[*] Auxiliary module execution completed
36+
37+
auxiliary(scanner/http/graphql_introspection_scanner) > vulns
38+
39+
Vulnerabilities
40+
===============
41+
42+
Timestamp Host Name References
43+
--------- ---- ---- ----------
44+
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
45+
rg/learn/introspection/
46+
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
47+
rg/learn/introspection/
48+
```
49+
50+
### Graphloc
51+
```
52+
auxiliary(scanner/http/graphql_introspection_scanner) > run rhost=https://graphloc.com/
53+
[*] Running module against 151.101.1.195
54+
[*] 151.101.1.195:443 - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.
55+
```
56+
57+
### catalysis-hub
58+
```
59+
uxiliary(scanner/http/graphql_introspection_scanner) > run rhost=https://api.catalysis-hub.org/graphql?
60+
[*] Running module against 3.33.161.45
61+
[*] 3.33.161.45:443 - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.
62+
```
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Auxiliary
7+
8+
include Msf::Exploit::Remote::HttpClient
9+
include Msf::Auxiliary::Report
10+
11+
def initialize(info = {})
12+
super(
13+
update_info(
14+
info,
15+
'Name' => 'GraphQL Introspection Scanner',
16+
'Description' => %q{
17+
This module queries a GraphQL API Endpoint to retrieve schema data by using
18+
introspection, if it is enabled on the server. This module works on all GraphQL versions.
19+
},
20+
'License' => MSF_LICENSE,
21+
'Author' => [
22+
'sjanusz-r7', # Metasploit module
23+
],
24+
'References' => [
25+
[ 'URL', 'https://portswigger.net/web-security/graphql' ],
26+
[ 'URL', 'https://graphql.org/learn/introspection/' ]
27+
],
28+
'Notes' => {
29+
'Stability' => [CRASH_SAFE],
30+
'SideEffects' => [IOC_IN_LOGS],
31+
'Reliability' => []
32+
}
33+
)
34+
)
35+
register_options([
36+
OptString.new('TARGETURI', [true, 'Base path of the GraphQL endpoint', '/'])
37+
])
38+
end
39+
40+
# Values that can be matched against to verify that introspection is not enabled on the server.
41+
# @return [Array<Regex>] An array of regular expressions
42+
def introspection_not_enabled_values
43+
[ /introspection is not (allowed|enabled)/i, /the query contained __schema/i, /to enable introspection/i ]
44+
end
45+
46+
# Check if the response received from the server suggests that introspection is enabled on the server, by comparing it
47+
# to a known good value.
48+
# @param response The response received from the server.
49+
# @return [TrueClass|FalseClass] True if the response matched a known introspection result, false otherwise.
50+
def responded_with_introspected_data?(response)
51+
return false if introspection_not_enabled_values.any? { |regex| response&.body.to_s.match?(regex) }
52+
53+
# Known good response
54+
response&.body.to_s == "{\"data\":{\"__schema\":{\"queryType\":{\"name\":\"Query\"}}}}\n"
55+
end
56+
57+
# Create a small query, used to test if introspection is enabledo n the GraphQL endpoint.
58+
# @return [String] The processed introspection probe query.
59+
def introspection_probe_query
60+
<<~EOF
61+
query {
62+
__schema {
63+
queryType {
64+
name
65+
}
66+
}
67+
}
68+
EOF
69+
end
70+
71+
# Create a unique query that will try to dump the GraphQL schema.
72+
# This dumps the data definitions, objects etc. not the data stored on the server.
73+
# Original query comes from: https://portswigger.net/web-security/graphql
74+
# @return [String] The processed schema dump query
75+
def schema_dump_query
76+
# Obfuscate the variable names with the hopes it will not get picked up by any logging solutions as suspicious.
77+
vars_map = {
78+
input_fragment: Rex::Text.rand_text_alpha(8),
79+
type_fragment: Rex::Text.rand_text_alpha(8),
80+
type_reference: Rex::Text.rand_text_alpha(8)
81+
}
82+
83+
# Fragments need to be present at the end, outside the curly braces of the 'query'
84+
<<~EOF
85+
query {
86+
__schema {
87+
queryType {
88+
name
89+
}
90+
mutationType {
91+
name
92+
}
93+
subscriptionType {
94+
name
95+
}
96+
types {
97+
...#{vars_map[:type_fragment]}
98+
}
99+
directives {
100+
name
101+
description
102+
args {
103+
...#{vars_map[:input_fragment]}
104+
}
105+
}
106+
}
107+
}
108+
fragment #{vars_map[:type_fragment]} on __Type {
109+
kind
110+
name
111+
description
112+
inputFields {
113+
...#{vars_map[:input_fragment]}
114+
}
115+
fields(includeDeprecated: true) {
116+
name
117+
description
118+
isDeprecated
119+
deprecationReason
120+
args {
121+
...#{vars_map[:input_fragment]}
122+
}
123+
type {
124+
...#{vars_map[:type_reference]}
125+
}
126+
}
127+
inputFields {
128+
...#{vars_map[:input_fragment]}
129+
}
130+
interfaces {
131+
...#{vars_map[:type_reference]}
132+
}
133+
enumValues(includeDeprecated: true) {
134+
name
135+
description
136+
isDeprecated
137+
deprecationReason
138+
}
139+
possibleTypes {
140+
...#{vars_map[:type_reference]}
141+
}
142+
}
143+
fragment #{vars_map[:input_fragment]} on __InputValue {
144+
name
145+
description
146+
defaultValue
147+
type {
148+
...#{vars_map[:type_reference]}
149+
}
150+
}
151+
fragment #{vars_map[:type_reference]} on __Type {
152+
kind
153+
name
154+
ofType {
155+
kind
156+
name
157+
ofType {
158+
kind
159+
name
160+
ofType {
161+
kind
162+
name
163+
}
164+
}
165+
}
166+
}
167+
EOF
168+
end
169+
170+
# Report a GraphQL instance on the current host and port.
171+
# @return [Mdm::Service] The reported service instance.
172+
def report_graphql_service
173+
report_service(
174+
host: rhost,
175+
port: rport,
176+
name: (ssl ? 'https' : 'http'),
177+
proto: 'tcp'
178+
)
179+
end
180+
181+
# Report a GraphQL Introspection vulnerability on the current host and port.
182+
# @return [Mdm::Vuln] The reported vulnerability instance.
183+
def report_graphql_vuln
184+
report_vuln(
185+
{
186+
host: rhost,
187+
port: rport,
188+
name: 'GraphQL Information Disclosure through Introspection',
189+
refs: references
190+
}
191+
)
192+
end
193+
194+
# Report a GraphQL Introspection web vulnerability on the current host and port.
195+
# @param service The GraphQL Mdm::Service instance.
196+
# @param query The query string used to check for the web vulnerability.
197+
# @param response The reponse from the server, used as proof that the vulnerability can be exploited.
198+
# @return [Mdm::WebVuln] The reported web vulnerability instance.
199+
def report_graphql_web_vuln(service, query, response)
200+
report_web_vuln(
201+
{
202+
host: rhost,
203+
port: rport,
204+
ssl: ssl,
205+
service: service,
206+
path: normalize_uri(target_uri.path),
207+
query: query,
208+
method: 'POST',
209+
params: [
210+
[ 'data', query ]
211+
],
212+
pname: 'path',
213+
proof: response.body,
214+
name: 'GraphQL Introspection',
215+
description: 'GraphQL endpoint has enabled introspection. This can lead to information disclosure',
216+
owner: self,
217+
category: 'Information Disclosure'
218+
}
219+
)
220+
end
221+
222+
# Send out a GraphQL request to the current endpoint, with the provided query string.
223+
# @param query The query string to execute.
224+
# @return (see Msf::Exploit::Remote::HttpClient#send_request_cgi)
225+
def send_graphql_request(query)
226+
send_request_cgi(
227+
'uri' => normalize_uri(target_uri.path),
228+
'method' => 'POST',
229+
'ctype' => 'application/json',
230+
'headers' => {
231+
'Accept' => 'application/json'
232+
},
233+
'data' => JSON.generate({ query: query })
234+
)
235+
end
236+
237+
# Process the errors array into a nice human-readable and formatted string.
238+
# @param errors An array of errors.
239+
# @return [String] A string with formatted error messages
240+
def process_errors(errors)
241+
return '' if errors&.empty?
242+
243+
# APIs aren't consistent. Some have an error message, some have title & detail.
244+
# Match all the known cases so far, otherwise return the inspected value.
245+
246+
errors.map do |error|
247+
" - #{error['message'] || error['detail'] || error['description']}"
248+
end.join("\n") || ''
249+
end
250+
251+
# Check if the current endpoint is vulnerable to GraphQL Introspection information disclosure.
252+
# @return [Exploit::CheckCode]
253+
def check
254+
query = introspection_probe_query
255+
res = send_graphql_request(query)
256+
257+
if res.nil?
258+
return Exploit::CheckCode::Unknown('The server did not send a response.')
259+
end
260+
261+
case res.code
262+
when 200
263+
graphql_service = report_graphql_service
264+
report_graphql_vuln
265+
report_graphql_web_vuln(graphql_service, query, res)
266+
267+
return Exploit::CheckCode::Vulnerable('The server has introspection enabled.')
268+
when 400
269+
parsed_body = JSON.parse!(res.body)
270+
error_messages = process_errors(parsed_body['errors'] || Array.wrap(parsed_body['error']))
271+
safe_message = "The server responded with an error status code and the following error(s) to the introspection request:\n#{error_messages}"
272+
return Exploit::CheckCode::Safe(safe_message)
273+
when 403
274+
# Don't report the GraphQL service here, as this could be a generic 'No Access', so we are not sure if the GraphQL
275+
# endpoint exists or not.
276+
return Exploit::CheckCode::Unknown('The server did not allow access to the GraphQL endpoint.')
277+
when 422
278+
# Rails application missing a CSRF token would return 422, but we are not 100% sure if this is a GraphQL endpoint.
279+
return Exploit::CheckCode::Unknown('The server required a CSRF token.')
280+
else
281+
# We are not 100% sure that the service is a GraphQL endpoint. It could be a generic 403 Access Denied.
282+
return Exploit::CheckCode::Unknown('The server is online, but returned an unexpected response code.')
283+
end
284+
end
285+
286+
# Attempt a schema dump request against a GraphQL endpoint
287+
# @return [nil]
288+
def run
289+
query = schema_dump_query
290+
res = send_graphql_request(query)
291+
292+
if res.nil?
293+
print_error("#{rhost}:#{rport} - The server did not send a response.")
294+
return
295+
end
296+
297+
if res.code == 200
298+
print_good("#{rhost}:#{rport} - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.")
299+
graphql_service = report_graphql_service
300+
report_graphql_vuln
301+
report_graphql_web_vuln(graphql_service, query, res)
302+
store_loot('graphql.schema', 'json', rhost, res.body, 'graphql-schema.json', 'GraphQL Schema Dump', graphql_service)
303+
else
304+
parsed_body = JSON.parse!(res.body)
305+
if parsed_body.include?('errors') || parsed_body.include?('error')
306+
print_error("#{rhost}:#{rport} - Server encountered the following error(s) (code: '#{res.code}'):\n#{process_errors(parsed_body['errors'] || Array.wrap(parsed_body['error']))}")
307+
else
308+
print_error("#{rhost}:#{rport} - Server replied with an unexpected status code: '#{res.code}'")
309+
end
310+
end
311+
end
312+
end

0 commit comments

Comments
 (0)