Skip to content

Commit 4075e1a

Browse files
committed
Add GraphQL Auxiliary Scanner module
1 parent 0600de2 commit 4075e1a

File tree

1 file changed

+316
-0
lines changed

1 file changed

+316
-0
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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+
# Process a query before sending it off in a web request.
58+
# @param query The string query to process.
59+
# @return [String] The processed query, with spaces and new-lines (\r and \n) removed.
60+
def process_query(query)
61+
query.gsub(/ +/, ' ').gsub(/\r?\n/, '')
62+
end
63+
64+
# Create a small query, used to test if introspection is enabledo n the GraphQL endpoint.
65+
# @return [String] The processed introspection probe query.
66+
def introspection_probe_query
67+
raw_query = '{"query": "
68+
query {
69+
__schema {
70+
queryType {
71+
name
72+
}
73+
}
74+
}"
75+
}'
76+
process_query(raw_query)
77+
end
78+
79+
# Create a unique query that will try to dump the GraphQL schema.
80+
# This dumps the data definitions, objects etc. not the data stored on the server.
81+
# Original query comes from: https://portswigger.net/web-security/graphql
82+
# @return [String] The processed schema dump query
83+
def schema_dump_query
84+
# Obfuscate the variable names with the hopes it will not get picked up by any logging solutions as suspicious.
85+
vars_map = {
86+
input_fragment: Rex::Text.rand_text_alpha(8),
87+
type_fragment: Rex::Text.rand_text_alpha(8),
88+
type_reference: Rex::Text.rand_text_alpha(8)
89+
}
90+
91+
# Remove extra spaces, and new lines.
92+
# Remember, fragments need to be present at the end, outside the curly braces, but as part
93+
# of the quoted 'query' param.
94+
raw_query = "{\"query\": \"query {
95+
__schema {
96+
queryType {
97+
name
98+
}
99+
mutationType {
100+
name
101+
}
102+
subscriptionType {
103+
name
104+
}
105+
types {
106+
...#{vars_map[:type_fragment]}
107+
}
108+
directives {
109+
name
110+
description
111+
args {
112+
...#{vars_map[:input_fragment]}
113+
}
114+
}
115+
}
116+
}
117+
fragment #{vars_map[:type_fragment]} on __Type {
118+
kind
119+
name
120+
description
121+
inputFields {
122+
...#{vars_map[:input_fragment]}
123+
}
124+
fields(includeDeprecated: true) {
125+
name
126+
description
127+
isDeprecated
128+
deprecationReason
129+
args {
130+
...#{vars_map[:input_fragment]}
131+
}
132+
type {
133+
...#{vars_map[:type_reference]}
134+
}
135+
}
136+
inputFields {
137+
...#{vars_map[:input_fragment]}
138+
}
139+
interfaces {
140+
...#{vars_map[:type_reference]}
141+
}
142+
enumValues(includeDeprecated: true) {
143+
name
144+
description
145+
isDeprecated
146+
deprecationReason
147+
}
148+
possibleTypes {
149+
...#{vars_map[:type_reference]}
150+
}
151+
}
152+
fragment #{vars_map[:input_fragment]} on __InputValue {
153+
name
154+
description
155+
defaultValue
156+
type {
157+
...#{vars_map[:type_reference]}
158+
}
159+
}
160+
fragment #{vars_map[:type_reference]} on __Type {
161+
kind
162+
name
163+
ofType {
164+
kind
165+
name
166+
ofType {
167+
kind
168+
name
169+
ofType {
170+
kind
171+
name
172+
}
173+
}
174+
}
175+
}
176+
\"}"
177+
process_query(raw_query)
178+
end
179+
180+
# Report a GraphQL instance on the current host and port.
181+
# @return [Mdm::Service] The reported service instance.
182+
def report_graphql_service
183+
report_service(
184+
host: rhost,
185+
port: rport,
186+
name: (ssl ? 'https' : 'http'),
187+
proto: 'tcp'
188+
)
189+
end
190+
191+
# Report a GraphQL Introspection vulnerability on the current host and port.
192+
# @return [Mdm::Vuln] The reported vulnerability instance.
193+
def report_graphql_vuln
194+
report_vuln(
195+
{
196+
host: rhost,
197+
port: rport,
198+
name: 'GraphQL Information Disclosure through Introspection',
199+
refs: references
200+
}
201+
)
202+
end
203+
204+
# Report a GraphQL Introspection web vulnerability on the current host and port.
205+
# @param service The GraphQL Mdm::Service instance.
206+
# @param query The query string used to check for the web vulnerability.
207+
# @param response The reponse from the server, used as proof that the vulnerability can be exploited.
208+
# @return [Mdm::WebVuln] The reported web vulnerability instance.
209+
def report_graphql_web_vuln(service, query, response)
210+
report_web_vuln(
211+
{
212+
host: rhost,
213+
port: rport,
214+
ssl: ssl,
215+
service: service,
216+
path: normalize_uri(target_uri.path),
217+
query: query,
218+
method: 'POST',
219+
params: [
220+
[ 'data', query ]
221+
],
222+
pname: 'path',
223+
proof: response.body,
224+
name: 'GraphQL Introspection',
225+
description: 'GraphQL endpoint has enabled introspection. This can lead to information disclosure',
226+
owner: self
227+
}
228+
)
229+
end
230+
231+
# Send out a GraphQL request to the current endpoint, with the provided query string.
232+
# @param query The query string to execute.
233+
# @return (see Msf::Exploit::Remote::HttpClient#send_request_cgi)
234+
def send_graphql_request(query)
235+
send_request_cgi(
236+
'uri' => normalize_uri(target_uri.path),
237+
'method' => 'POST',
238+
'ctype' => 'application/json',
239+
'headers' => {
240+
'Accept' => 'application/json'
241+
},
242+
'data' => query
243+
)
244+
end
245+
246+
# Process the errors array into a nice human-readable and formatted string.
247+
# @param errors An array of errors.
248+
# @return [String] A string with formatted error messages
249+
def process_errors(errors)
250+
return '' if errors&.empty?
251+
252+
errors.map { |error| " - #{error['message']}" }&.join("\n") || ''
253+
end
254+
255+
# Check if the current endpoint is vulnerable to GraphQL Introspection information disclosure.
256+
# @return [Exploit::CheckCode]
257+
def check
258+
query = introspection_probe_query
259+
res = send_graphql_request(query)
260+
261+
if res.nil?
262+
return Exploit::CheckCode::Unknown('The server did not send a response.')
263+
end
264+
265+
case res.code
266+
when 200
267+
graphql_service = report_graphql_service
268+
report_graphql_vuln
269+
report_graphql_web_vuln(graphql_service, query, res)
270+
271+
return Exploit::CheckCode::Vulnerable('The server has introspection enabled.')
272+
when 400
273+
parsed_body = JSON.parse!(res.body)
274+
error_messages = process_errors(parsed_body['errors'])
275+
safe_message = "The server responded with an error status code and the following error(s) to the introspection request:\n#{error_messages}"
276+
return Exploit::CheckCode::Safe(safe_message)
277+
when 403
278+
# Don't report the GraphQL service here, as this could be a generic 'No Access', so we are not sure if the GraphQL
279+
# endpoint exists or not.
280+
return Exploit::CheckCode::Unknown('The server did not allow access to the GraphQL endpoint.')
281+
when 422
282+
# Rails application missing a CSRF token would return 422, but we are not 100% sure if this is a GraphQL endpoint.
283+
return Exploit::CheckCode::Unknown('The server required a CSRF token.')
284+
else
285+
# We are not 100% sure that the service is a GraphQL endpoint. It could be a generic 403 Access Denied.
286+
return Exploit::CheckCode::Unknown('The server is online, but returned an unexpected response code.')
287+
end
288+
end
289+
290+
# Attempt a schema dump request against a GraphQL endpoint
291+
# @return [nil]
292+
def run
293+
query = schema_dump_query
294+
res = send_graphql_request(query)
295+
296+
if res.nil?
297+
print_error("#{rhost}:#{rport} - The server did not send a response.")
298+
return
299+
end
300+
301+
if res.code == 200
302+
print_status("#{rhost}:#{rport} - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.")
303+
graphql_service = report_graphql_service
304+
report_graphql_vuln
305+
report_graphql_web_vuln(graphql_service, query, res)
306+
store_loot('graphql.schema', 'json', rhost, res.body, 'graphql-schema.json', 'GraphQL Schema Dump', graphql_service)
307+
else
308+
parsed_body = JSON.parse!(res.body)
309+
if parsed_body.include?('errors')
310+
print_error("#{rhost}:#{rport} - Server encountered the following error(s) (code: '#{res.code}'):\n#{process_errors(parsed_body['errors'])}")
311+
else
312+
print_error("#{rhost}:#{rport} - Server replied with an unexpected status code: '#{res.code}'")
313+
end
314+
end
315+
end
316+
end

0 commit comments

Comments
 (0)