Skip to content

Commit f6912fc

Browse files
committed
Adding support for GraphQL (--graphql)
1 parent 2893fd5 commit f6912fc

11 files changed

Lines changed: 2207 additions & 8 deletions

File tree

data/txt/sha256sums.txt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,10 @@ ca86d61d3349ed2d94a6b164d4648cff9701199b5e32378c3f40fca0f517b128 extra/shutils/
160160
df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/recloak.sh
161161
1972990a67caf2d0231eacf60e211acf545d9d0beeb3c145a49ba33d5d491b3f extra/shutils/strip.sh
162162
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
163-
43214ecb0101bce72eb243c91b90db34693ebfd485d6c111a4ae22591ff7800b extra/vulnserver/vulnserver.py
163+
faaaa586baa4df245b8780a1a808ebf07e3027ce4245ded3274d908c49e1eecd extra/vulnserver/vulnserver.py
164164
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
165-
0c6433b289094d37f295238699042a34a6ab950bb3d11f74fe9a83d30bb7f4bd lib/controller/checks.py
166-
ea0fdf6bcda59aae4d093bada965654a0cd940227c2dbdf62b6ded79baa8dfad lib/controller/controller.py
165+
284b5b056f048e5951c43605965f6758cb9cefa54ca30d818b2c1d1c6713fb91 lib/controller/checks.py
166+
b1e89bff221cc907f5033bae941bf7929de9490f5dcdf2747cba676acd2da95b lib/controller/controller.py
167167
d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py
168168
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py
169169
9c5764c92ce536d1f0f96200359ee5ef1f37f9128769bf990cb77f1d1f8e17b1 lib/core/agent.py
@@ -181,26 +181,26 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
181181
5387168e5dfedd94ae22af7bb255f27d6baaca50b24179c6b98f4f325f5cc7b4 lib/core/exception.py
182182
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/core/__init__.py
183183
914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py
184-
056930fba3cf9827f97d280bc38ac785c93108eb84c922f5f39723bb04dcf403 lib/core/optiondict.py
184+
1b03686e1aa916ccad3cd86b8e4e6ea4baca5e30e05bf86a56f8df8dd4f44ba6 lib/core/optiondict.py
185185
4e7f2ad3d2866093aa195616a0e93de1687406edc0b9038fbfa76bf1c9c174b2 lib/core/option.py
186186
ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch.py
187187
49c0fa7e3814dfda610d665ee02b12df299b28bc0b6773815b4395514ddf8dec lib/core/profiling.py
188188
03db48f02c3d07a047ddb8fe33a757b6238867352d8ddda2a83e4fec09a98d04 lib/core/readlineng.py
189189
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
190190
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
191191
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
192-
ca14e55b4d49a9b9f4e547180828030e4fcc51176dc9036879dbdae05919dd02 lib/core/settings.py
192+
7032c06dba29cfc35330e022823b778aa87849d5e92a33f4daff2a364d0c9ecd lib/core/settings.py
193193
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
194194
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
195195
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
196-
e453904a50372216b09146ad9f11cdced2323c10f49c3d866238cc044dcb2cce lib/core/testing.py
196+
b63a8c4caed56796010e9b438ae6b4c398d4c4ed48d74b0a1a270302e0ce87ca lib/core/testing.py
197197
95656c44bab1771f4808030dd6a17eae5b129cb1234443f00b19695c7b712b86 lib/core/threads.py
198198
b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py
199199
53e396902cb2546eaa09e77073fcba8be8827ee9ce055cfc899e81b0e6ad4d6d lib/core/update.py
200200
2400e465fa4d13e4c32795910878c71ff212e4361b46428d57ce43983f5e997c lib/core/wordlist.py
201201
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py
202202
54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py
203-
223badcfd102cdf3313411b63d09b6c59599d58dfc40d27409b1bfa2efc1aa8f lib/parse/cmdline.py
203+
c515041ee2d50aded9afa371de47c3c44c81b30546fb1f6f170b2169ae5e64b4 lib/parse/cmdline.py
204204
02d82e4069bd98c52755417f8b8e306d79945672656ac24f1a45e7a6eff4b158 lib/parse/configfile.py
205205
c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py
206206
5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py
@@ -239,6 +239,8 @@ a66a4b9df6207dce722c9b71d290ea426723cb4b697b416065dc7dd5db96fe8e lib/techniques
239239
74ca78082dcd20b3faf07cc944cd65ea552996df40e6fb58d0a011b262528456 lib/techniques/dns/use.py
240240
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/error/__init__.py
241241
5bbef46c16e34fd80e3f9f0e9aa255ce2e39be0d0e57479e25890b041c7efc7d lib/techniques/error/use.py
242+
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/graphql/__init__.py
243+
a1c5ec208843eb93e0fab40daac090aa3bf914a7dd0afb0f7c55c2db4db8d72b lib/techniques/graphql/inject.py
242244
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/__init__.py
243245
44401cad3e39ae9fb899ed5d0e2fdd0879561de05c3117f17f3b0db54f4e3724 lib/techniques/nosql/__init__.py
244246
d62b28bf9f1544e65a1017994402f484166f4d64a1efb724351b15e27b851990 lib/techniques/nosql/inject.py
@@ -594,6 +596,7 @@ ed5a0e453b811dc3dcc5ca28e14a9d7552aacaa7e316e1bca1b042dc5939e204 tests/test_dns
594596
9cd5841349bc4db818658d12184929a96f7f279eff1f53ad18a54dbefbd6b276 tests/test_dump_jsonl.py
595597
2bbe4b01f79992cfa8884651fc0a28dbd0e3abb0cbea9eb7eadf1f98ca3c3420 tests/test_encoding.py
596598
bb6991260a994fcbe79e05febaa34affd5631d02299fbc626820addd5f6ea4f4 tests/test_error_engine.py
599+
4a5f9392b7fec7b40c4d865b83306b58b76f3423cebc2876e6e75fb91b037202 tests/test_graphql.py
597600
8105de9978fe286a29f6b635a58db1e9998d86e8dded54d7efdfb9d52a121094 tests/test_hashdb.py
598601
c04e8358fb6df45f69f2f26435c971acde280535bf304e84d30cf2681158c6a7 tests/test_hash.py
599602
d539d0ae758b5bb91e314ab82ab4fe03d6fb2f8b377d16aefa6d7d1d77a7d5a9 tests/test_identifiers_output.py

extra/vulnserver/vulnserver.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,232 @@ def waf_score(value, ua=None, level=0):
246246
retVal += WAF_SCANNER_UA_WEIGHT
247247
return retVal
248248

249+
# --- GraphQL endpoint (vulnerable Apollo-style, backed by the same SQLite database) ----------
250+
251+
# Hard-coded introspection response matching the schema below. Every GraphQL tool (including
252+
# sqlmap's --graphql engine) uses this to discover fields, arguments, and types.
253+
def _graphql_introspection():
254+
return {
255+
"data": {
256+
"__schema": {
257+
"queryType": {"name": "Query"},
258+
"mutationType": {"name": "Mutation"},
259+
"subscriptionType": None,
260+
"directives": [],
261+
"types": [
262+
{"kind": "OBJECT", "name": "Query", "fields": [
263+
{"name": "user", "args": [
264+
{"name": "username", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
265+
], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
266+
{"name": "search", "args": [
267+
{"name": "term", "defaultValue": None, "type": {"kind": "SCALAR", "name": "String", "ofType": None}}
268+
], "type": {"kind": "LIST", "name": None, "ofType": {"kind": "OBJECT", "name": "User", "ofType": None}}},
269+
{"name": "login", "args": [
270+
{"name": "username", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}},
271+
{"name": "password", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
272+
], "type": {"kind": "OBJECT", "name": "AuthPayload", "ofType": None}},
273+
], "inputFields": None, "enumValues": None},
274+
{"kind": "OBJECT", "name": "Mutation", "fields": [
275+
{"name": "updateUser", "args": [
276+
{"name": "id", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}},
277+
{"name": "email", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
278+
], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
279+
], "inputFields": None, "enumValues": None},
280+
{"kind": "INPUT_OBJECT", "name": "UpdateUserInput", "inputFields": [
281+
{"name": "id", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}},
282+
{"name": "email", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}
283+
]},
284+
{"kind": "SCALAR", "name": "Int"},
285+
{"kind": "SCALAR", "name": "String"},
286+
{"kind": "SCALAR", "name": "Boolean"},
287+
{"kind": "SCALAR", "name": "Float"},
288+
{"kind": "SCALAR", "name": "ID"},
289+
{"kind": "OBJECT", "name": "User", "fields": [
290+
{"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "Int", "ofType": None}},
291+
{"name": "name", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
292+
{"name": "surname", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
293+
], "inputFields": None, "enumValues": None},
294+
{"kind": "OBJECT", "name": "AuthPayload", "fields": [
295+
{"name": "token", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}},
296+
{"name": "user", "args": [], "type": {"kind": "OBJECT", "name": "User", "ofType": None}},
297+
], "inputFields": None, "enumValues": None},
298+
]
299+
}
300+
}
301+
}
302+
303+
304+
def _graphql_arg(raw):
305+
"""Parse a single GraphQL argument value: strip quotes from strings, keep numbers as-is"""
306+
raw = raw.strip()
307+
if raw.startswith('"') and raw.endswith('"'):
308+
return raw[1:-1].replace('\\"', '"')
309+
return raw
310+
311+
312+
def _graphql_match(text, start):
313+
"""Index just past the bracket matching the one at text[start] ('(' or '{'), skipping over
314+
double-quoted strings so brackets inside argument literals (e.g. an injected SQL payload) and
315+
nested selection sets do not throw off the balance."""
316+
317+
pairs = {'(': ')', '{': '}'}
318+
opener, closer = text[start], pairs[text[start]]
319+
depth, i, n = 0, start, len(text)
320+
while i < n:
321+
char = text[i]
322+
if char == '"':
323+
i += 1
324+
while i < n and text[i] != '"':
325+
i += 2 if text[i] == '\\' else 1
326+
elif char == opener:
327+
depth += 1
328+
elif char == closer:
329+
depth -= 1
330+
if depth == 0:
331+
return i + 1
332+
i += 1
333+
return n
334+
335+
336+
def _graphql_selections(body):
337+
"""Split a selection set into its top-level (alias, field, rawArgs) fields, tolerating aliasing,
338+
argument literals carrying brackets/quotes, and nested selection sets (which are skipped over)."""
339+
340+
identifier = re.compile(r'[A-Za-z_]\w*')
341+
selections, i, n = [], 0, len(body)
342+
while i < n:
343+
while i < n and body[i] in ' \t\r\n,':
344+
i += 1
345+
match = identifier.match(body, i)
346+
if not match:
347+
i += 1
348+
continue
349+
name, i = match.group(0), match.end()
350+
351+
j = i
352+
while j < n and body[j] in ' \t\r\n':
353+
j += 1
354+
if j < n and body[j] == ':': # 'name' was an alias; the real field follows
355+
j += 1
356+
while j < n and body[j] in ' \t\r\n':
357+
j += 1
358+
match = identifier.match(body, j)
359+
if not match:
360+
continue
361+
alias, field, i = name, match.group(0), match.end()
362+
else:
363+
alias, field = None, name
364+
365+
while i < n and body[i] in ' \t\r\n':
366+
i += 1
367+
rawArgs = ""
368+
if i < n and body[i] == '(':
369+
end = _graphql_match(body, i)
370+
rawArgs, i = body[i + 1:end - 1], end
371+
372+
while i < n and body[i] in ' \t\r\n':
373+
i += 1
374+
if i < n and body[i] == '{': # skip this field's (possibly nested) selection set
375+
i = _graphql_match(body, i)
376+
377+
selections.append((alias, field, rawArgs))
378+
return selections
379+
380+
381+
def _graphql_resolve(query, variables):
382+
"""Minimal GraphQL resolver: parse the query, call the matching resolver for each top-level field,
383+
and return (data_dict_or_None, errors_list). Multiple aliased fields are supported in one request
384+
(alias:field(args){...} ...), so a client can batch independent probes into a single round-trip."""
385+
386+
variables = variables or {}
387+
errors = []
388+
data = {}
389+
390+
op = "query"
391+
for keyword in ("mutation", "subscription"):
392+
if query.strip().startswith(keyword):
393+
op = keyword
394+
break
395+
396+
start = query.find('{')
397+
if start == -1:
398+
errors.append({"message": "Cannot parse query", "extensions": {"code": "GRAPHQL_PARSE_FAILED"}})
399+
return None, errors
400+
401+
for alias, field, rawArgs in _graphql_selections(query[start + 1:_graphql_match(query, start) - 1]):
402+
key = alias or field
403+
404+
# Parse arguments
405+
args = {}
406+
for am in re.finditer(r'(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|\$?\w+(?:\.\w+)?)', rawArgs):
407+
name, val = am.group(1), am.group(2)
408+
if val.startswith('$'):
409+
args[name] = variables.get(val[1:], None)
410+
else:
411+
args[name] = _graphql_arg(val)
412+
413+
try:
414+
if field in ("__typename", "__schema"):
415+
data[key] = op.title()
416+
elif field == "user":
417+
data[key] = _resolver_user(args.get("username"))
418+
elif field == "search":
419+
data[key] = _resolver_search(args.get("term"))
420+
elif field == "login":
421+
data[key] = _resolver_login(args.get("username"), args.get("password"))
422+
elif field == "updateUser":
423+
data[key] = _resolver_updateUser(args.get("id"), args.get("email"))
424+
else:
425+
errors.append({"message": "Cannot query field '%s' on type '%s'. Did you mean 'user', 'search', 'login', or 'updateUser'?" % (field, op.title()),
426+
"extensions": {"code": "GRAPHQL_VALIDATION_FAILED"}})
427+
except Exception as ex:
428+
# Leak the backend error through the GraphQL error envelope (as many real servers do
429+
# in development mode) -- this drives error-based detection
430+
errors.append({"message": "%s: %s" % (re.search(r"'([^']+)'", str(type(ex))).group(1), ex),
431+
"path": [key], "extensions": {"exception": str(ex)}})
432+
433+
if not data and not errors:
434+
return None, errors
435+
return data, errors
436+
437+
438+
# --- Vulnerable resolvers (direct string concatenation into SQLite) ------------------------
439+
440+
def _resolver_user(username):
441+
if not username:
442+
return None
443+
with _lock:
444+
_cursor.execute("SELECT id, name, surname FROM users WHERE name='%s'" % username)
445+
row = _cursor.fetchone()
446+
return {"id": row[0], "name": row[1], "surname": row[2]} if row else None
447+
448+
449+
def _resolver_search(term):
450+
with _lock:
451+
_cursor.execute("SELECT id, name, surname FROM users WHERE name LIKE '%%%s%%'" % (term or ""))
452+
rows = _cursor.fetchall()
453+
return [{"id": r[0], "name": r[1], "surname": r[2]} for r in (rows or [])]
454+
455+
456+
def _resolver_login(username, password):
457+
if not username or not password:
458+
return None
459+
with _lock:
460+
_cursor.execute("SELECT u.id, u.name, u.surname FROM users u JOIN creds c ON u.id=c.user_id WHERE u.name='%s' AND c.password_hash='%s'" % (username, password))
461+
row = _cursor.fetchone()
462+
if row:
463+
return {"token": "tok_%d_%s" % (row[0], row[1]), "user": {"id": row[0], "name": row[1], "surname": row[2]}}
464+
return None # returns null in data (boolean oracle: true=object, false=null)
465+
466+
467+
def _resolver_updateUser(id_, email):
468+
with _lock:
469+
_cursor.execute("UPDATE users SET surname='%s' WHERE id=%s" % (email, id_))
470+
_cursor.execute("SELECT id, name, surname FROM users WHERE id=%s" % id_)
471+
row = _cursor.fetchone()
472+
return {"id": row[0], "name": row[1], "surname": row[2]} if row else None
473+
474+
249475
class ReqHandler(BaseHTTPRequestHandler):
250476
def do_REQUEST(self):
251477
path, query = self.path.split('?', 1) if '?' in self.path else (self.path, "")
@@ -339,6 +565,35 @@ def do_REQUEST(self):
339565
self.wfile.write(output.encode(UNICODE_ENCODING))
340566
return
341567

568+
if self.url == "/graphql":
569+
self.send_response(OK)
570+
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
571+
self.send_header("Connection", "close")
572+
self.end_headers()
573+
574+
query = self.params.get("query", "")
575+
variables = self.params.get("variables") or {}
576+
577+
if not isinstance(variables, dict):
578+
try:
579+
variables = json.loads(str(variables))
580+
except Exception:
581+
variables = {}
582+
583+
if "__schema" in query:
584+
output = json.dumps(_graphql_introspection())
585+
else:
586+
data, errors = _graphql_resolve(query, variables)
587+
resp = {}
588+
if errors:
589+
resp["errors"] = errors
590+
if data:
591+
resp["data"] = data
592+
output = json.dumps(resp, default=str)
593+
594+
self.wfile.write(output.encode(UNICODE_ENCODING))
595+
return
596+
342597
if self.url == '/':
343598
if not any(_ in self.params for _ in ("id", "query")):
344599
self.send_response(OK)

lib/controller/checks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
from lib.core.settings import DUMMY_NON_SQLI_CHECK_APPENDIX
8080
from lib.core.settings import FI_ERROR_REGEX
8181
from lib.core.settings import FORMAT_EXCEPTION_STRINGS
82+
from lib.core.settings import GRAPHQL_ERROR_REGEX
8283
from lib.core.settings import HEURISTIC_CHECK_ALPHABET
8384
from lib.core.settings import INFERENCE_EQUALS_CHAR
8485
from lib.core.settings import IPS_WAF_CHECK_PAYLOAD
@@ -1178,6 +1179,13 @@ def _(page):
11781179
if conf.beep:
11791180
beep()
11801181

1182+
if not conf.graphql and re.search(GRAPHQL_ERROR_REGEX, page or ""):
1183+
infoMsg = "heuristic (GraphQL) test shows that %sparameter '%s' appears to be a GraphQL endpoint (rerun with switch '--graphql')" % ("%s " % paramType if paramType != parameter else "", parameter)
1184+
logger.info(infoMsg)
1185+
1186+
if conf.beep:
1187+
beep()
1188+
11811189
kb.disableHtmlDecoding = False
11821190
kb.heuristicMode = False
11831191

lib/controller/controller.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,8 +504,21 @@ def start():
504504
infoMsg = "testing URL '%s'" % targetUrl
505505
logger.info(infoMsg)
506506

507+
if conf.graphql and PLACE.GET not in conf.parameters:
508+
# graphqlScan() is self-contained and operates on the GraphQL
509+
# document, not on HTTP parameters. A dummy GET parameter keeps
510+
# _setRequestParams() from appending the URI injection marker ('*')
511+
# to a bare endpoint URL (which would break detection under
512+
# '--batch'); it is discarded by graphqlScan() on entry.
513+
conf.parameters[PLACE.GET] = "x"
514+
507515
setupTargetEnv()
508516

517+
if conf.graphql:
518+
from lib.techniques.graphql.inject import graphqlScan
519+
graphqlScan()
520+
continue
521+
509522
if not checkConnection(suppressOutput=conf.forms):
510523
continue
511524

lib/core/optiondict.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"Techniques": {
120120
"technique": "string",
121121
"nosql": "boolean",
122+
"graphql": "boolean",
122123
"timeSec": "integer",
123124
"uCols": "string",
124125
"uChar": "string",

0 commit comments

Comments
 (0)